Просмотр исходного кода

Merge branch 'dev' into feature/queue-item-eta

MartinNYHC 1 месяц назад
Родитель
Сommit
1cdc0e289f
35 измененных файлов с 2033 добавлено и 378 удалено
  1. 4 0
      CHANGELOG.md
  2. 12 15
      CONTRIBUTING.md
  3. 18 0
      backend/app/api/routes/kprofiles.py
  4. 8 0
      backend/app/schemas/printer.py
  5. 227 115
      backend/app/services/bambu_mqtt.py
  6. 49 0
      backend/app/utils/printer_models.py
  7. 298 0
      backend/tests/unit/services/test_bambu_mqtt.py
  8. 37 0
      backend/tests/unit/test_printer_models.py
  9. 54 0
      frontend/src/__tests__/hooks/useCancellableTimeout.test.ts
  10. 0 35
      frontend/src/__tests__/i18n/locales.test.ts
  11. 148 1
      frontend/src/__tests__/pages/SettingsPage.test.tsx
  12. 346 0
      frontend/src/__tests__/utils/filamentPresets.test.ts
  13. 4 0
      frontend/src/api/client.ts
  14. 6 2
      frontend/src/components/ConfigureAmsSlotModal.tsx
  15. 272 116
      frontend/src/components/KProfilesView.tsx
  16. 37 0
      frontend/src/hooks/useCancellableTimeout.ts
  17. 11 1
      frontend/src/i18n/locales/de.ts
  18. 11 1
      frontend/src/i18n/locales/en.ts
  19. 11 1
      frontend/src/i18n/locales/es.ts
  20. 11 1
      frontend/src/i18n/locales/fr.ts
  21. 11 1
      frontend/src/i18n/locales/it.ts
  22. 11 1
      frontend/src/i18n/locales/ja.ts
  23. 11 1
      frontend/src/i18n/locales/ko.ts
  24. 11 1
      frontend/src/i18n/locales/pt-BR.ts
  25. 11 1
      frontend/src/i18n/locales/ru.ts
  26. 11 1
      frontend/src/i18n/locales/tr.ts
  27. 11 1
      frontend/src/i18n/locales/uk.ts
  28. 11 1
      frontend/src/i18n/locales/zh-CN.ts
  29. 11 1
      frontend/src/i18n/locales/zh-TW.ts
  30. 120 78
      frontend/src/pages/SettingsPage.tsx
  31. 247 0
      frontend/src/utils/filamentPresets.ts
  32. 1 0
      static/assets/index-C_6BSgrK.css
  33. 0 0
      static/assets/index-CbDmTKuP.js
  34. 0 1
      static/assets/index-oReXTzKG.css
  35. 2 2
      static/index.html

Разница между файлами не показана из-за своего большого размера
+ 4 - 0
CHANGELOG.md


+ 12 - 15
CONTRIBUTING.md

@@ -223,21 +223,18 @@ The frontend uses [react-i18next](https://react.i18next.com/) for all user-facin
 
 
 ### Locale Files
 ### Locale Files
 
 
-Translations live in `frontend/src/i18n/locales/`:
-
-| File | Language |
-|------|----------|
-| `en.ts` | English (primary) |
-| `de.ts` | German |
-| `fr.ts` | French |
-| `ja.ts` | Japanese |
-| `pt-BR.ts` | Brazilian Portuguese |
-[...]
-check for possibly more files!!!
+Translations live in `frontend/src/i18n/locales/`. `en.ts` is the reference locale; every other `*.ts` file in that directory is checked against it. The parity check discovers the directory at runtime, so a new locale is picked up automatically — this file never needs updating when one is added.
+
+To see the current set of locales and check your work:
+
+```bash
+cd frontend
+npm run check:i18n
+```
 
 
 ### Adding New Strings
 ### Adding New Strings
 
 
-1. Add the key to the appropriate section in **all three** locale files
+1. Add the key to the appropriate section in **every** locale file
 2. Use the `useTranslation` hook in your component:
 2. Use the `useTranslation` hook in your component:
 
 
 ```tsx
 ```tsx
@@ -253,9 +250,9 @@ function MyComponent() {
 
 
 ### Important Notes
 ### Important Notes
 
 
-- All three locale files must use the **same key structure** — same nesting, same key paths
-- Always add keys to all three locales to maintain parity
-- Run frontend tests after changes — locale parity is validated
+- Every locale file must use the **same key structure** — same nesting, same key paths
+- Always add keys to **every** locale to maintain parity, with real translations rather than English placeholders — the check flags leaves that are identical to `en`
+- Run `npm run test:run` before pushing — it chains the parity check, which CI runs too. Plain `npm test` is vitest in watch mode and skips it
 - If you find structural inconsistencies between locales, fix them — different key paths cause silent fallback to English
 - If you find structural inconsistencies between locales, fix them — different key paths cause silent fallback to English
 
 
 ## Authentication & Permissions
 ## Authentication & Permissions

+ 18 - 0
backend/app/api/routes/kprofiles.py

@@ -148,6 +148,9 @@ async def set_kprofile(
         )
         )
         if not delete_success:
         if not delete_success:
             raise HTTPException(500, "Failed to delete existing K-profile for edit")
             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
         # Wait for printer to process the delete before adding
         await asyncio.sleep(0.5)
         await asyncio.sleep(0.5)
@@ -179,6 +182,13 @@ async def set_kprofile(
     if not success:
     if not success:
         raise HTTPException(500, "Failed to send K-profile command")
         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"
     message = "K-profile updated successfully" if is_edit else "K-profile added successfully"
     return {"success": True, "message": message}
     return {"success": True, "message": message}
 
 
@@ -239,6 +249,10 @@ async def set_kprofiles_batch(
     if not success:
     if not success:
         raise HTTPException(500, "Failed to send K-profiles batch command")
         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"}
     return {"success": True, "message": f"Added {len(profiles)} K-profiles"}
 
 
 
 
@@ -283,6 +297,10 @@ async def delete_kprofile(
     if not success:
     if not success:
         raise HTTPException(500, "Failed to send K-profile delete command")
         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
     # Wait for printer to process the delete before frontend refetches
     await asyncio.sleep(0.5)
     await asyncio.sleep(0.5)
 
 

+ 8 - 0
backend/app/schemas/printer.py

@@ -2,6 +2,8 @@ from datetime import datetime
 
 
 from pydantic import BaseModel, Field, field_validator
 from pydantic import BaseModel, Field, field_validator
 
 
+from backend.app.utils.printer_models import supports_nozzle_flow_type
+
 
 
 class PrinterBase(BaseModel):
 class PrinterBase(BaseModel):
     name: str = Field(..., min_length=1, max_length=100)
     name: str = Field(..., min_length=1, max_length=100)
@@ -81,6 +83,11 @@ class PrinterResponse(PrinterBase):
     id: int
     id: int
     is_active: bool
     is_active: bool
     nozzle_count: int = 1  # 1 or 2, auto-detected from MQTT
     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
     print_hours_offset: float = 0.0
     external_camera_url: str | None = None
     external_camera_url: str | None = None
     external_camera_type: str | None = None
     external_camera_type: str | None = None
@@ -113,6 +120,7 @@ class PrinterResponse(PrinterBase):
             "camera_rotation": printer.camera_rotation,
             "camera_rotation": printer.camera_rotation,
             "is_active": printer.is_active,
             "is_active": printer.is_active,
             "nozzle_count": printer.nozzle_count,
             "nozzle_count": printer.nozzle_count,
+            "supports_nozzle_flow_type": supports_nozzle_flow_type(printer.model),
             "print_hours_offset": printer.print_hours_offset,
             "print_hours_offset": printer.print_hours_offset,
             "plate_detection_enabled": printer.plate_detection_enabled,
             "plate_detection_enabled": printer.plate_detection_enabled,
             "created_at": printer.created_at,
             "created_at": printer.created_at,

+ 227 - 115
backend/app/services/bambu_mqtt.py

@@ -812,10 +812,17 @@ class BambuMQTTClient:
         # so that missing-serial / missing-firmware warnings fire only once per connection.
         # so that missing-serial / missing-firmware warnings fire only once per connection.
         self._ams_version_warned: set[tuple[int | str, str]] = set()
         self._ams_version_warned: set[tuple[int | str, str]] = set()
 
 
-        # K-profile command tracking
+        # K-profile command tracking. One entry per in-flight extrusion_cali_get,
+        # keyed by the sequence_id we sent, so two concurrent requests for
+        # different nozzle sizes can't steal each other's response (#1748).
+        # Value: {"nozzle": str, "event": asyncio.Event, "profiles": list | None}.
         self._sequence_id: int = 0
         self._sequence_id: int = 0
-        self._pending_kprofile_response: asyncio.Event | None = None
-        self._kprofile_response_data: list | None = None
+        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
         # Xcam hold timers - OrcaSlicer pattern: ignore incoming data for 3 seconds after command
         # Key: module_name, Value: timestamp when command was sent
         # Key: module_name, Value: timestamp when command was sent
@@ -1616,7 +1623,24 @@ class BambuMQTTClient:
             if "command" in print_data:
             if "command" in print_data:
                 cmd = print_data.get("command")
                 cmd = print_data.get("command")
                 logger.debug("[%s] Received command response: %s", self.serial_number, cmd)
                 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)
                     logger.debug("[%s] %s response: %s", self.serial_number, cmd, print_data)
                 # AMS drying responses are rare (user-initiated only) and the
                 # AMS drying responses are rare (user-initiated only) and the
                 # full payload — including `result` and any `reason` code —
                 # full payload — including `result` and any `reason` code —
@@ -5412,98 +5436,120 @@ class BambuMQTTClient:
             self._drying_targets.pop(ams_id, None)
             self._drying_targets.pop(ams_id, None)
         return True
         return True
 
 
+    @staticmethod
+    def _parse_kprofile_entries(filaments: list, response_nozzle: str | None, log_errors: bool) -> list[KProfile]:
+        """Build KProfile objects from an ``extrusion_cali_get`` filaments array.
+
+        The printer reports ``nozzle_diameter`` **only on the response
+        envelope** — the per-filament entries carry just setting_id,
+        filament_id, name, k_value, n_coef and cali_idx. Defaulting the
+        per-entry lookup to "0.4" therefore stamped every profile 0.4mm on
+        single-nozzle printers regardless of the installed nozzle (#1748),
+        which broke the K-Profiles display and, worse, the cali_idx cascade
+        in the inventory/Spoolman assign paths that matches on
+        nozzle_diameter. Fall back to the envelope value instead, and only
+        to "0.4" when the envelope has none either.
+
+        ``or`` rather than a dict default on purpose: it also covers an entry
+        that carries the key with an empty value, and stops ``str()`` turning
+        a missing envelope value into the literal "None".
+        """
+        profiles: list[KProfile] = []
+        for i, f in enumerate(filaments):
+            if not isinstance(f, dict):
+                continue
+            try:
+                profiles.append(
+                    KProfile(
+                        # cali_idx is the actual slot/calibration index from the printer
+                        slot_id=f.get("cali_idx", i),
+                        extruder_id=int(f.get("extruder_id", 0)),
+                        nozzle_id=str(f.get("nozzle_id", "")),
+                        nozzle_diameter=str(f.get("nozzle_diameter") or response_nozzle or "0.4"),
+                        filament_id=str(f.get("filament_id", "")),
+                        name=str(f.get("name", "")),
+                        k_value=str(f.get("k_value", "0.000000")),
+                        n_coef=str(f.get("n_coef", "0.000000")),
+                        ams_id=int(f.get("ams_id", 0)),
+                        tray_id=int(f.get("tray_id", -1)),
+                        setting_id=f.get("setting_id"),
+                    )
+                )
+            except (ValueError, TypeError) as e:
+                # Skip malformed entries; the remaining profiles stay usable.
+                # Unsolicited broadcasts arrive constantly, so only a response
+                # someone is actually waiting on is worth a warning.
+                if log_errors:
+                    logger.warning("Failed to parse K-profile: %s", e)
+                else:
+                    logger.debug("Failed to parse K-profile from broadcast: %s", e)
+        return profiles
+
     def _handle_kprofile_response(self, data: dict):
     def _handle_kprofile_response(self, data: dict):
         """Handle K-profile response from printer."""
         """Handle K-profile response from printer."""
         response_nozzle = data.get("nozzle_diameter")
         response_nozzle = data.get("nozzle_diameter")
-        response_seq_id = data.get("sequence_id", "?")
+        response_seq_id = str(data.get("sequence_id", ""))
         filaments = data.get("filaments", [])
         filaments = data.get("filaments", [])
-        expected_nozzle = getattr(self, "_expected_kprofile_nozzle", None)
-        has_pending_request = self._pending_kprofile_response is not None
 
 
-        # Log all incoming responses when we have a pending request (for debugging)
-        if has_pending_request:
+        # Snapshot the map: the asyncio thread adds and removes entries while
+        # this MQTT callback thread walks it.
+        pending = dict(self._pending_kprofile_requests)
+        request = pending.get(response_seq_id)
+
+        if request is None and pending:
+            # Firmware that doesn't echo our sequence_id still has to be
+            # served, so fall back to the pre-#1748 rule of matching on the
+            # nozzle size. Only requests still waiting are eligible, and the
+            # sequence_id lookup above has already claimed any response that
+            # identifies itself, so this can no longer hand request A's
+            # answer to request B when both are in flight.
+            request = next(
+                (r for r in pending.values() if r["nozzle"] == response_nozzle and r["profiles"] is None),
+                None,
+            )
+
+        if pending:
             logger.info(
             logger.info(
-                f"[{self.serial_number}] K-profile response: nozzle={response_nozzle}, "
-                f"seq_id={response_seq_id}, {len(filaments)} profiles, expected={expected_nozzle}"
+                "[%s] K-profile response: nozzle=%s, seq_id=%s, %d profiles, matched=%s",
+                self.serial_number,
+                response_nozzle,
+                response_seq_id or "?",
+                len(filaments),
+                request is not None,
             )
             )
 
 
-        # If we have a pending request, only accept responses with matching nozzle_diameter
-        # The printer broadcasts 0.4mm profiles constantly - we need to wait for the actual response
-        if has_pending_request and expected_nozzle and response_nozzle != expected_nozzle:
-            # Ignore this broadcast, keep waiting for matching response
+        if request is None and pending:
+            # A request is outstanding and this isn't its answer. The printer
+            # broadcasts extrusion_cali_get unsolicited, so letting this
+            # through would replace state.kprofiles with another nozzle's
+            # profiles while the caller is still waiting.
             logger.debug(
             logger.debug(
-                f"[{self.serial_number}] Ignoring broadcast: got nozzle={response_nozzle}, waiting for {expected_nozzle}"
+                "[%s] Ignoring unmatched K-profile response: nozzle=%s, seq_id=%s",
+                self.serial_number,
+                response_nozzle,
+                response_seq_id or "?",
             )
             )
             return
             return
 
 
-        # If no pending request, this is just a broadcast - update state silently and return early
-        if not has_pending_request:
-            # Still parse profiles to keep state updated, but don't log
-            profiles = []
-            for f in filaments:
-                if isinstance(f, dict):
-                    try:
-                        cali_idx = f.get("cali_idx", 0)
-                        profiles.append(
-                            KProfile(
-                                slot_id=cali_idx,
-                                extruder_id=int(f.get("extruder_id", 0)),
-                                nozzle_id=str(f.get("nozzle_id", "")),
-                                nozzle_diameter=str(f.get("nozzle_diameter", "0.4")),
-                                filament_id=str(f.get("filament_id", "")),
-                                name=str(f.get("name", "")),
-                                k_value=str(f.get("k_value", "0.000000")),
-                                n_coef=str(f.get("n_coef", "0.000000")),
-                                ams_id=int(f.get("ams_id", 0)),
-                                tray_id=int(f.get("tray_id", -1)),
-                                setting_id=f.get("setting_id"),
-                            )
-                        )
-                    except (ValueError, TypeError):
-                        pass  # Skip malformed K-profile entries; remaining profiles still usable
-            self.state.kprofiles = profiles
-            return
+        profiles = self._parse_kprofile_entries(filaments, response_nozzle, log_errors=request is not None)
+        self.state.kprofiles = profiles
 
 
-        profiles = []
+        if request is None:
+            # Unsolicited broadcast with nothing in flight: state is refreshed,
+            # nobody to wake.
+            return
 
 
-        for i, f in enumerate(filaments):
-            if isinstance(f, dict):
-                try:
-                    # cali_idx is the actual slot/calibration index from the printer
-                    cali_idx = f.get("cali_idx", i)
-                    profiles.append(
-                        KProfile(
-                            slot_id=cali_idx,
-                            extruder_id=int(f.get("extruder_id", 0)),
-                            nozzle_id=str(f.get("nozzle_id", "")),
-                            nozzle_diameter=str(f.get("nozzle_diameter", "0.4")),
-                            filament_id=str(f.get("filament_id", "")),
-                            name=str(f.get("name", "")),
-                            k_value=str(f.get("k_value", "0.000000")),
-                            n_coef=str(f.get("n_coef", "0.000000")),
-                            ams_id=int(f.get("ams_id", 0)),
-                            tray_id=int(f.get("tray_id", -1)),
-                            setting_id=f.get("setting_id"),
-                        )
-                    )
-                except (ValueError, TypeError) as e:
-                    logger.warning("Failed to parse K-profile: %s", e)
+        logger.info("[%s] Got %s K-profiles for nozzle=%s", self.serial_number, len(profiles), response_nozzle)
+        request["profiles"] = profiles
 
 
-        self.state.kprofiles = profiles
-        self._kprofile_response_data = profiles
-
-        # Signal that we received the response (only if we were waiting for one)
-        # Use thread-safe method since MQTT callbacks run in a different thread
-        # Capture in local var to avoid TOCTOU race: asyncio thread can clear
-        # self._pending_kprofile_response between the check and the .set() call
-        event = self._pending_kprofile_response
-        if event:
-            logger.info("[%s] Got %s K-profiles for nozzle=%s", self.serial_number, len(profiles), response_nozzle)
-            if self._loop and self._loop.is_running():
-                self._loop.call_soon_threadsafe(event.set)
-            else:
-                # Fallback for when loop is not available
-                event.set()
+        # Signal the waiter. Use the thread-safe path since MQTT callbacks run
+        # in a different thread than the event loop.
+        event = request["event"]
+        if self._loop and self._loop.is_running():
+            self._loop.call_soon_threadsafe(event.set)
+        else:
+            # Fallback for when loop is not available
+            event.set()
 
 
     async def get_kprofiles(
     async def get_kprofiles(
         self, nozzle_diameter: str = "0.4", timeout: float = 5.0, max_retries: int = 3
         self, nozzle_diameter: str = "0.4", timeout: float = 5.0, max_retries: int = 3
@@ -5533,11 +5579,13 @@ class BambuMQTTClient:
             return []
             return []
 
 
         for attempt in range(max_retries):
         for attempt in range(max_retries):
-            # Set up response event for this attempt
+            # Register this attempt under its own sequence_id so a concurrent
+            # request for a different nozzle size can't consume its response
+            # (#1748) — the pending map is keyed by exactly the id we send.
             self._sequence_id += 1
             self._sequence_id += 1
-            self._pending_kprofile_response = asyncio.Event()
-            self._kprofile_response_data = None
-            self._expected_kprofile_nozzle = nozzle_diameter  # Track which nozzle response we expect
+            seq_id = str(self._sequence_id)
+            request: dict = {"nozzle": nozzle_diameter, "event": asyncio.Event(), "profiles": None}
+            self._pending_kprofile_requests[seq_id] = request
 
 
             # Send the command with nozzle_diameter filter
             # Send the command with nozzle_diameter filter
             command = {
             command = {
@@ -5545,20 +5593,20 @@ class BambuMQTTClient:
                     "command": "extrusion_cali_get",
                     "command": "extrusion_cali_get",
                     "filament_id": "",
                     "filament_id": "",
                     "nozzle_diameter": nozzle_diameter,
                     "nozzle_diameter": nozzle_diameter,
-                    "sequence_id": str(self._sequence_id),
+                    "sequence_id": seq_id,
                 }
                 }
             }
             }
 
 
             logger.info(
             logger.info(
-                f"[{self.serial_number}] Requesting K-profiles for nozzle_diameter={nozzle_diameter} (attempt {attempt + 1}/{max_retries})"
+                f"[{self.serial_number}] Requesting K-profiles for nozzle_diameter={nozzle_diameter} (attempt {attempt + 1}/{max_retries}, seq_id={seq_id})"
             )
             )
             logger.debug("[%s] K-profile request JSON: %s", self.serial_number, json.dumps(command))
             logger.debug("[%s] K-profile request JSON: %s", self.serial_number, json.dumps(command))
-            self._client.publish(self.topic_publish, json.dumps(command), qos=1)
 
 
-            # Wait for response (response handler already filters by nozzle_diameter)
+            # Wait for the response (the handler matches it back to this entry)
             try:
             try:
-                await asyncio.wait_for(self._pending_kprofile_response.wait(), timeout=timeout)
-                profiles = self._kprofile_response_data or []
+                self._client.publish(self.topic_publish, json.dumps(command), qos=1)
+                await asyncio.wait_for(request["event"].wait(), timeout=timeout)
+                profiles = request["profiles"] or []
                 logger.info(
                 logger.info(
                     f"[{self.serial_number}] Got {len(profiles)} K-profiles for nozzle={nozzle_diameter} on attempt {attempt + 1}"
                     f"[{self.serial_number}] Got {len(profiles)} K-profiles for nozzle={nozzle_diameter} on attempt {attempt + 1}"
                 )
                 )
@@ -5571,12 +5619,56 @@ class BambuMQTTClient:
                     # Brief delay before retry
                     # Brief delay before retry
                     await asyncio.sleep(0.5)
                     await asyncio.sleep(0.5)
             finally:
             finally:
-                self._pending_kprofile_response = None
-                self._expected_kprofile_nozzle = None
+                self._pending_kprofile_requests.pop(seq_id, None)
 
 
         logger.error("[%s] Failed to get K-profiles after %s attempts", self.serial_number, max_retries)
         logger.error("[%s] Failed to get K-profiles after %s attempts", self.serial_number, max_retries)
         return []
         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(
     def set_kprofile(
         self,
         self,
         filament_id: str,
         filament_id: str,
@@ -5588,7 +5680,7 @@ class BambuMQTTClient:
         setting_id: str | None = None,
         setting_id: str | None = None,
         slot_id: int = 0,
         slot_id: int = 0,
         cali_idx: int | None = None,
         cali_idx: int | None = None,
-    ) -> bool:
+    ) -> str | None:
         """Set/update a K-profile on the printer.
         """Set/update a K-profile on the printer.
 
 
         Args:
         Args:
@@ -5603,13 +5695,16 @@ class BambuMQTTClient:
             cali_idx: For edits, the existing slot being edited (enables in-place edit)
             cali_idx: For edits, the existing slot being edited (enables in-place edit)
 
 
         Returns:
         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:
         if not self._client or not self.state.connected:
             logger.warning("[%s] Cannot set K-profile: not connected", self.serial_number)
             logger.warning("[%s] Cannot set K-profile: not connected", self.serial_number)
-            return False
+            return None
 
 
         self._sequence_id += 1
         self._sequence_id += 1
+        seq_id = str(self._sequence_id)
 
 
         # Build the filament entry - printer uses cali_idx for profile identification
         # 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
         # For new profiles (slot_id=0), use cali_idx=-1 to tell printer to create new slot
@@ -5637,7 +5732,13 @@ class BambuMQTTClient:
             "nozzle_diameter": nozzle_diameter,
             "nozzle_diameter": nozzle_diameter,
             "nozzle_id": nozzle_id,
             "nozzle_id": nozzle_id,
             "setting_id": setting_id if setting_id else "",
             "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 = {
         command = {
@@ -5645,7 +5746,7 @@ class BambuMQTTClient:
                 "command": "extrusion_cali_set",
                 "command": "extrusion_cali_set",
                 "filaments": [filament_entry],
                 "filaments": [filament_entry],
                 "nozzle_diameter": nozzle_diameter,
                 "nozzle_diameter": nozzle_diameter,
-                "sequence_id": str(self._sequence_id),
+                "sequence_id": seq_id,
             }
             }
         }
         }
 
 
@@ -5654,14 +5755,14 @@ class BambuMQTTClient:
             f"[{self.serial_number}] Setting K-profile: {name} = {k_value} (cali_idx={effective_cali_idx}, new={slot_id == 0})"
             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)
         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(
     def set_kprofiles_batch(
         self,
         self,
         profiles: list[dict],
         profiles: list[dict],
         nozzle_diameter: str = "0.4",
         nozzle_diameter: str = "0.4",
-    ) -> bool:
+    ) -> str | None:
         """Set multiple K-profiles in a single command (for dual-nozzle).
         """Set multiple K-profiles in a single command (for dual-nozzle).
 
 
         Args:
         Args:
@@ -5670,15 +5771,17 @@ class BambuMQTTClient:
             nozzle_diameter: Common nozzle diameter for all profiles
             nozzle_diameter: Common nozzle diameter for all profiles
 
 
         Returns:
         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:
         if not self._client or not self.state.connected:
             logger.warning("[%s] Cannot set K-profiles batch: not connected", self.serial_number)
             logger.warning("[%s] Cannot set K-profiles batch: not connected", self.serial_number)
-            return False
+            return None
 
 
         import random
         import random
 
 
         self._sequence_id += 1
         self._sequence_id += 1
+        seq_id = str(self._sequence_id)
 
 
         filament_entries = []
         filament_entries = []
         for p in profiles:
         for p in profiles:
@@ -5706,7 +5809,9 @@ class BambuMQTTClient:
                     "nozzle_diameter": nozzle_diameter,
                     "nozzle_diameter": nozzle_diameter,
                     "nozzle_id": p.get("nozzle_id", f"HS00-{nozzle_diameter}"),
                     "nozzle_id": p.get("nozzle_id", f"HS00-{nozzle_diameter}"),
                     "setting_id": setting_id if setting_id else "",
                     "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,
                 }
                 }
             )
             )
 
 
@@ -5715,15 +5820,15 @@ class BambuMQTTClient:
                 "command": "extrusion_cali_set",
                 "command": "extrusion_cali_set",
                 "filaments": filament_entries,
                 "filaments": filament_entries,
                 "nozzle_diameter": nozzle_diameter,
                 "nozzle_diameter": nozzle_diameter,
-                "sequence_id": str(self._sequence_id),
+                "sequence_id": seq_id,
             }
             }
         }
         }
 
 
         command_json = json.dumps(command)
         command_json = json.dumps(command)
         logger.info("[%s] Setting %s K-profiles in batch", self.serial_number, len(filament_entries))
         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)
         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(
     def delete_kprofile(
         self,
         self,
@@ -5733,7 +5838,7 @@ class BambuMQTTClient:
         nozzle_diameter: str = "0.4",
         nozzle_diameter: str = "0.4",
         extruder_id: int = 0,
         extruder_id: int = 0,
         setting_id: str | None = None,
         setting_id: str | None = None,
-    ) -> bool:
+    ) -> str | None:
         """Delete a K-profile from the printer.
         """Delete a K-profile from the printer.
 
 
         Args:
         Args:
@@ -5745,13 +5850,15 @@ class BambuMQTTClient:
             setting_id: Unique setting identifier (for X1C series)
             setting_id: Unique setting identifier (for X1C series)
 
 
         Returns:
         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:
         if not self._client or not self.state.connected:
             logger.warning("[%s] Cannot delete K-profile: not connected", self.serial_number)
             logger.warning("[%s] Cannot delete K-profile: not connected", self.serial_number)
-            return False
+            return None
 
 
         self._sequence_id += 1
         self._sequence_id += 1
+        seq_id = str(self._sequence_id)
 
 
         # Dual-nozzle K-profile delete uses the extruder_id/nozzle_id format;
         # 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.
         # single-nozzle printers (X1C/P1/A1/P2S/H2S) need the setting_id form.
@@ -5767,7 +5874,7 @@ class BambuMQTTClient:
             command = {
             command = {
                 "print": {
                 "print": {
                     "command": "extrusion_cali_del",
                     "command": "extrusion_cali_del",
-                    "sequence_id": str(self._sequence_id),
+                    "sequence_id": seq_id,
                     "extruder_id": extruder_id,
                     "extruder_id": extruder_id,
                     "nozzle_id": nozzle_id,
                     "nozzle_id": nozzle_id,
                     "filament_id": filament_id,
                     "filament_id": filament_id,
@@ -5781,7 +5888,7 @@ class BambuMQTTClient:
             command = {
             command = {
                 "print": {
                 "print": {
                     "command": "extrusion_cali_del",
                     "command": "extrusion_cali_del",
-                    "sequence_id": str(self._sequence_id),
+                    "sequence_id": seq_id,
                     "filament_id": filament_id,
                     "filament_id": filament_id,
                     "cali_idx": cali_idx,
                     "cali_idx": cali_idx,
                     "setting_id": setting_id if setting_id else "",
                     "setting_id": setting_id if setting_id else "",
@@ -5796,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}"
             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)
         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
     # Printer Control Commands
@@ -6641,6 +6748,11 @@ class BambuMQTTClient:
             logger.warning("[%s] Cannot set K value: not connected", self.serial_number)
             logger.warning("[%s] Cannot set K value: not connected", self.serial_number)
             return False
             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}"
         nozzle_id = f"HS00-{nozzle_diameter}"
 
 
         # A2L AMS-Lite: a normalised global tray (24-27) must go out as the
         # A2L AMS-Lite: a normalised global tray (24-27) must go out as the

+ 49 - 0
backend/app/utils/printer_models.py

@@ -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).
 # Models without any external storage (MicroSD / SD card slot).
 # The A1 and A1 Mini ship with internal storage only — there is no
 # The A1 and A1 Mini ship with internal storage only — there is no
 # firmware-side "Store sent files on external storage" toggle and 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
     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:
 def get_rod_type(model: str | None) -> str | None:
     """Return the rod/rail type for a printer model.
     """Return the rod/rail type for a printer model.
 
 

+ 298 - 0
backend/tests/unit/services/test_bambu_mqtt.py

@@ -4,9 +4,11 @@ Tests for the BambuMQTTClient service.
 These tests focus on timelapse tracking during prints.
 These tests focus on timelapse tracking during prints.
 """
 """
 
 
+import asyncio
 import json
 import json
 import logging
 import logging
 import time
 import time
+from unittest.mock import MagicMock
 
 
 import pytest
 import pytest
 
 
@@ -6832,6 +6834,302 @@ class TestKProfileResponseDoesNotClobberNozzle:
         assert mqtt_client.state.nozzles[0].nozzle_diameter == "0.4"
         assert mqtt_client.state.nozzles[0].nozzle_diameter == "0.4"
 
 
 
 
+class TestKProfileNozzleDiameterFromEnvelope:
+    """#1748: every K-profile came back as 0.4mm on single-nozzle printers.
+
+    ``extrusion_cali_get`` carries ``nozzle_diameter`` only on the response
+    envelope — the per-filament entries hold just setting_id, filament_id,
+    name, k_value, n_coef and cali_idx. The parser read the field per entry
+    with a "0.4" default, so a 0.6/0.8 nozzle's profiles were all stamped 0.4.
+    Beyond the K-Profiles display that broke the cali_idx cascade in the
+    inventory and Spoolman assign paths, which match on nozzle_diameter.
+    """
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        return BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="X1ETEST",
+            access_code="12345678",
+        )
+
+    @staticmethod
+    def _response(nozzle="0.8", entries=None, seq="48"):
+        """A verbatim-shaped extrusion_cali_get payload from the #1748 report."""
+        if entries is None:
+            entries = [
+                {
+                    "setting_id": "GFSNLS02_07",
+                    "filament_id": "GFSNL02",
+                    "name": "SUNLU PLA Matte WHITE 0.8",
+                    "k_value": "0.01750",
+                    "n_coef": "1.000",
+                    "cali_idx": 265,
+                    "is_history_setting": True,
+                }
+            ]
+        print_data = {"command": "extrusion_cali_get", "filament_id": "", "filaments": entries}
+        if nozzle is not None:
+            print_data["nozzle_diameter"] = nozzle
+        if seq is not None:
+            print_data["sequence_id"] = seq
+        return {"print": print_data}
+
+    def test_broadcast_uses_envelope_diameter(self, mqtt_client):
+        # No request in flight: the unsolicited broadcast still has to record
+        # the right diameter, because state.kprofiles is what the assign paths
+        # read when nobody has just fetched.
+        mqtt_client._process_message(self._response(nozzle="0.8"))
+        assert [p.nozzle_diameter for p in mqtt_client.state.kprofiles] == ["0.8"]
+
+    @pytest.mark.asyncio
+    async def test_awaited_response_uses_envelope_diameter(self, mqtt_client):
+        profiles = await self._fetch(mqtt_client, "0.6", self._response(nozzle="0.6", seq="7"))
+        assert [p.nozzle_diameter for p in profiles] == ["0.6"]
+
+    def test_entry_value_still_wins(self, mqtt_client):
+        # Dual-nozzle firmware does put the field on each entry; that stays
+        # authoritative, since a batch can legitimately span nozzles.
+        entries = [{"cali_idx": 1, "filament_id": "GFA00", "name": "PLA", "nozzle_diameter": "0.4"}]
+        mqtt_client._process_message(self._response(nozzle="0.8", entries=entries))
+        assert mqtt_client.state.kprofiles[0].nozzle_diameter == "0.4"
+
+    def test_empty_entry_value_falls_back_to_envelope(self, mqtt_client):
+        entries = [{"cali_idx": 1, "filament_id": "GFA00", "name": "PLA", "nozzle_diameter": ""}]
+        mqtt_client._process_message(self._response(nozzle="0.8", entries=entries))
+        assert mqtt_client.state.kprofiles[0].nozzle_diameter == "0.8"
+
+    def test_no_envelope_value_falls_back_to_default(self, mqtt_client):
+        # Neither source available: keep the old default rather than let
+        # str(None) write the literal string "None" into the profile.
+        mqtt_client._process_message(self._response(nozzle=None))
+        assert mqtt_client.state.kprofiles[0].nozzle_diameter == "0.4"
+
+    @staticmethod
+    async def _fetch(client, nozzle, response):
+        """Run get_kprofiles, feeding `response` in as the printer's answer."""
+        client.state.connected = True
+        client._client = MagicMock()
+        client._client.publish.side_effect = lambda *a, **kw: client._process_message(response)
+        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.
+
+    Responses were matched to requests by nozzle diameter alone, held in one
+    shared ``_expected_kprofile_nozzle`` slot. A second request overwrote the
+    first's expectation, so the first's valid answer was discarded as a
+    mismatch and that request timed out even though the printer had replied.
+    Correlation now runs off the sequence_id we send, with the nozzle match
+    kept as a fallback for firmware that doesn't echo it.
+    """
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="X1ETEST",
+            access_code="12345678",
+        )
+        client.state.connected = True
+        client._client = MagicMock()
+        return client
+
+    @staticmethod
+    def _response(nozzle, seq, name):
+        return {
+            "print": {
+                "command": "extrusion_cali_get",
+                "nozzle_diameter": nozzle,
+                "sequence_id": seq,
+                "filaments": [{"cali_idx": 1, "filament_id": "GFA00", "name": name, "k_value": "0.020000"}],
+            }
+        }
+
+    @pytest.mark.asyncio
+    async def test_concurrent_requests_each_get_their_own_response(self, mqtt_client):
+        # The failing sequence from the report: 0.8 is requested, then 0.4,
+        # then the 0.8 answer lands. Under nozzle-only matching the expected
+        # slot already said 0.4, so the 0.8 answer was dropped on the floor.
+        seen: list[str] = []
+
+        def publish(_topic, payload, **_kw):
+            seen.append(json.loads(payload)["print"]["sequence_id"])
+
+        mqtt_client._client.publish.side_effect = publish
+
+        big = asyncio.create_task(mqtt_client.get_kprofiles(nozzle_diameter="0.8", timeout=5.0))
+        small = asyncio.create_task(mqtt_client.get_kprofiles(nozzle_diameter="0.4", timeout=5.0))
+        await asyncio.sleep(0)  # let both publish before either answer arrives
+        assert len(seen) == 2
+
+        mqtt_client._process_message(self._response("0.8", seen[0], "wide"))
+        mqtt_client._process_message(self._response("0.4", seen[1], "narrow"))
+
+        assert [p.name for p in await big] == ["wide"]
+        assert [p.name for p in await small] == ["narrow"]
+
+    @pytest.mark.asyncio
+    async def test_falls_back_to_nozzle_match_when_sequence_id_is_not_echoed(self, mqtt_client):
+        # Firmware that answers with its own sequence_id must keep working.
+        mqtt_client._client.publish.side_effect = lambda *a, **kw: mqtt_client._process_message(
+            self._response("0.6", "9999", "echoed-nothing")
+        )
+        profiles = await mqtt_client.get_kprofiles(nozzle_diameter="0.6", timeout=2.0)
+        assert [p.name for p in profiles] == ["echoed-nothing"]
+
+    @pytest.mark.asyncio
+    async def test_unrelated_broadcast_does_not_clobber_a_pending_fetch(self, mqtt_client):
+        # The printer broadcasts 0.4 profiles unsolicited. One arriving while a
+        # 0.8 fetch is open must neither satisfy nor overwrite it.
+        def publish(_topic, payload, **_kw):
+            seq = json.loads(payload)["print"]["sequence_id"]
+            mqtt_client._process_message(self._response("0.4", "9999", "broadcast"))
+            mqtt_client._process_message(self._response("0.8", seq, "wanted"))
+
+        mqtt_client._client.publish.side_effect = publish
+        profiles = await mqtt_client.get_kprofiles(nozzle_diameter="0.8", timeout=2.0)
+        assert [p.name for p in profiles] == ["wanted"]
+        assert [p.name for p in mqtt_client.state.kprofiles] == ["wanted"]
+
+    @pytest.mark.asyncio
+    async def test_pending_entry_is_released_on_timeout(self, mqtt_client):
+        # A timed-out attempt must not leave its entry behind, or a later
+        # broadcast would be matched to a request nobody is waiting on.
+        profiles = await mqtt_client.get_kprofiles(nozzle_diameter="0.8", timeout=0.01, max_retries=1)
+        assert profiles == []
+        assert mqtt_client._pending_kprofile_requests == {}
+
+
 class TestConnectRefusalReporting:
 class TestConnectRefusalReporting:
     """#2698: a refused CONNACK must leave a trace.
     """#2698: a refused CONNACK must leave a trace.
 
 

+ 37 - 0
backend/tests/unit/test_printer_models.py

@@ -14,6 +14,7 @@ from backend.app.utils.printer_models import (
     is_dual_nozzle_model,
     is_dual_nozzle_model,
     normalize_printer_model,
     normalize_printer_model,
     normalize_printer_model_id,
     normalize_printer_model_id,
+    supports_nozzle_flow_type,
 )
 )
 
 
 
 
@@ -213,6 +214,42 @@ class TestDualNozzleModel:
         assert is_dual_nozzle_model("") is False
         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:
 class TestHasExternalStorage:
     """Pins which Bambu models have a MicroSD slot. The connection
     """Pins which Bambu models have a MicroSD slot. The connection
     diagnostic flips its ``external_storage`` check from ``fail`` to
     diagnostic flips its ``external_storage`` check from ``fail`` to

+ 54 - 0
frontend/src/__tests__/hooks/useCancellableTimeout.test.ts

@@ -0,0 +1,54 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { useCancellableTimeout } from '../../hooks/useCancellableTimeout';
+
+describe('useCancellableTimeout', () => {
+  beforeEach(() => vi.useFakeTimers());
+  afterEach(() => vi.useRealTimers());
+
+  it('runs the callback after the delay', () => {
+    const fn = vi.fn();
+    const { result } = renderHook(() => useCancellableTimeout());
+    act(() => result.current.schedule(fn, 1500));
+    expect(fn).not.toHaveBeenCalled();
+    act(() => void vi.advanceTimersByTime(1500));
+    expect(fn).toHaveBeenCalledTimes(1);
+  });
+
+  it('does not run the callback after unmount', () => {
+    // The bug this exists for: a modal that defers its own close by 1.5s fired
+    // setState and onClose after the component was gone — which throws outright
+    // once the DOM around it has been torn down.
+    const fn = vi.fn();
+    const { result, unmount } = renderHook(() => useCancellableTimeout());
+    act(() => result.current.schedule(fn, 1500));
+    unmount();
+    act(() => void vi.advanceTimersByTime(5000));
+    expect(fn).not.toHaveBeenCalled();
+  });
+
+  it('cancel() stops a pending callback', () => {
+    const fn = vi.fn();
+    const { result } = renderHook(() => useCancellableTimeout());
+    act(() => result.current.schedule(fn, 1000));
+    act(() => result.current.cancel());
+    act(() => void vi.advanceTimersByTime(2000));
+    expect(fn).not.toHaveBeenCalled();
+  });
+
+  it('scheduling again replaces the pending callback', () => {
+    const first = vi.fn();
+    const second = vi.fn();
+    const { result } = renderHook(() => useCancellableTimeout());
+    act(() => result.current.schedule(first, 1000));
+    act(() => result.current.schedule(second, 1000));
+    act(() => void vi.advanceTimersByTime(1000));
+    expect(first).not.toHaveBeenCalled();
+    expect(second).toHaveBeenCalledTimes(1);
+  });
+
+  it('is safe to cancel when nothing is pending', () => {
+    const { result } = renderHook(() => useCancellableTimeout());
+    expect(() => act(() => result.current.cancel())).not.toThrow();
+  });
+});

+ 0 - 35
frontend/src/__tests__/i18n/locales.test.ts

@@ -1,35 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import en from '../../i18n/locales/en';
-import de from '../../i18n/locales/de';
-
-/**
- * Recursively extracts all keys from a nested object as dot-notation paths.
- * Example: { foo: { bar: 'baz' } } => ['foo.bar']
- */
-const getKeys = (obj: object, prefix = ''): string[] => {
-  return Object.entries(obj).flatMap(([key, value]) => {
-    const path = prefix ? `${prefix}.${key}` : key;
-    return typeof value === 'object' && value !== null
-      ? getKeys(value, path)
-      : [path];
-  });
-};
-
-describe('i18n locale parity', () => {
-  const enKeys = new Set(getKeys(en));
-  const deKeys = new Set(getKeys(de));
-
-  it('German locale has all English keys', () => {
-    const missingInGerman = [...enKeys].filter((k) => !deKeys.has(k)).sort();
-    expect(missingInGerman, `Missing ${missingInGerman.length} key(s) in German locale`).toEqual([]);
-  });
-
-  it('English locale has all German keys', () => {
-    const missingInEnglish = [...deKeys].filter((k) => !enKeys.has(k)).sort();
-    expect(missingInEnglish, `Missing ${missingInEnglish.length} key(s) in English locale`).toEqual([]);
-  });
-
-  it('both locales have the same number of keys', () => {
-    expect(enKeys.size).toBe(deKeys.size);
-  });
-});

+ 148 - 1
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -3,9 +3,14 @@
  */
  */
 
 
 import { describe, it, expect, beforeEach, vi } from 'vitest';
 import { describe, it, expect, beforeEach, vi } from 'vitest';
-import { fireEvent, screen, waitFor, within } from '@testing-library/react';
+import { act, fireEvent, render as rtlRender, screen, waitFor, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import userEvent from '@testing-library/user-event';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { BrowserRouter } from 'react-router-dom';
 import { render } from '../utils';
 import { render } from '../utils';
+import { ThemeProvider } from '../../contexts/ThemeContext';
+import { ToastProvider } from '../../contexts/ToastContext';
+import { AuthProvider } from '../../contexts/AuthContext';
 import { SettingsPage } from '../../pages/SettingsPage';
 import { SettingsPage } from '../../pages/SettingsPage';
 import { http, HttpResponse } from 'msw';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
 import { server } from '../mocks/server';
@@ -1457,3 +1462,145 @@ describe('SettingsPage — sponsor banner audience', () => {
     expect(screen.getByText(/6 printers/i)).toBeInTheDocument();
     expect(screen.getByText(/6 printers/i)).toBeInTheDocument();
   });
   });
 });
 });
+
+describe('SettingsPage — settings changed outside the page (#2716)', () => {
+  const restoreLabel = 'Restore plate for finish photo';
+  // external_url is deliberately populated: when the server has none the page
+  // detects one from the browser and saves it unprompted, which would show up
+  // as a PUT in tests that assert none was made. That behaviour has its own
+  // test at the end of this block.
+  const baseSettings = { ...mockSettings, external_url: window.location.origin };
+
+  let queryClient: QueryClient;
+  let puts: Record<string, unknown>[];
+  let served: Record<string, unknown>;
+
+  function renderPage() {
+    queryClient = new QueryClient({
+      defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } },
+    });
+    return rtlRender(
+      <QueryClientProvider client={queryClient}>
+        <BrowserRouter>
+          <AuthProvider>
+            <ThemeProvider>
+              <ToastProvider>
+                <SettingsPage />
+              </ToastProvider>
+            </ThemeProvider>
+          </AuthProvider>
+        </BrowserRouter>
+      </QueryClientProvider>
+    );
+  }
+
+  /** Change the settings row server-side and let the page's query observe it. */
+  async function changeOnServer(patch: Record<string, unknown>) {
+    served = { ...served, ...patch };
+    await act(async () => {
+      await queryClient.invalidateQueries({ queryKey: ['settings'] });
+    });
+  }
+
+  /** Wait out the 100ms initial-load suppression, then flip a checkbox. */
+  async function toggleRestorePlate() {
+    const label = await screen.findByText(restoreLabel);
+    await new Promise((resolve) => setTimeout(resolve, 200));
+    const row = label.closest('div')!.parentElement!;
+    await userEvent.click(within(row).getByRole('checkbox'));
+  }
+
+  beforeEach(() => {
+    window.history.replaceState({}, '', '/');
+    localStorage.clear();
+    setAuthToken(null);
+    puts = [];
+    served = { ...baseSettings };
+
+    server.use(
+      http.get('/api/v1/settings/', () => HttpResponse.json(served)),
+      http.put('/api/v1/settings/', async ({ request }) => {
+        const body = (await request.json()) as Record<string, unknown>;
+        puts.push(body);
+        served = { ...served, ...body };
+        return HttpResponse.json(served);
+      })
+    );
+  });
+
+  it('does not write its stale copy back over a server-side change', async () => {
+    // The defect: the page diffed the live query cache against its own copy, so
+    // a refetch that carried someone else's change read as a local edit and was
+    // reverted ~500ms later with no user interaction at all.
+    renderPage();
+    await screen.findByText(restoreLabel);
+    await new Promise((resolve) => setTimeout(resolve, 200));
+
+    await changeOnServer({ currency: 'EUR' });
+
+    // Well past the 500ms debounce.
+    await new Promise((resolve) => setTimeout(resolve, 1200));
+    expect(puts).toEqual([]);
+  });
+
+  it('adopts the server value, so a later save carries it rather than the stale one', async () => {
+    renderPage();
+    await new Promise((resolve) => setTimeout(resolve, 200));
+    await changeOnServer({ currency: 'EUR' });
+
+    await toggleRestorePlate();
+
+    await waitFor(() => expect(puts).toHaveLength(1), { timeout: 3000 });
+    // The user's edit is saved...
+    expect(puts[0].finish_photo_restore_plate).toBe(false);
+    // ...and the field they never touched goes back as the server's value, not
+    // the USD the page loaded with.
+    expect(puts[0].currency).toBe('EUR');
+  });
+
+  it('never reverts a pending user edit that the server changed too', async () => {
+    renderPage();
+    await toggleRestorePlate();
+    // Lands while the edit is still sitting in the 500ms debounce, i.e. before
+    // the page has committed it. Adopting the server's value here would throw
+    // the edit away silently.
+    await changeOnServer({ finish_photo_restore_plate: true });
+
+    await waitFor(() => expect(puts.length).toBeGreaterThan(0), { timeout: 3000 });
+    await new Promise((resolve) => setTimeout(resolve, 1200));
+    // Asserted over every request rather than a particular one: whichever order
+    // the refetch and the debounce happen to land in, no write may carry the
+    // server's value back over the user's.
+    expect(puts.map((p) => p.finish_photo_restore_plate)).toEqual(puts.map(() => false));
+  });
+
+  it('saves once per edit — the baseline moves with the saved row', async () => {
+    // Guards the failure mode the baseline introduces if it is not advanced on
+    // save: every render would diff against the pre-save snapshot and re-send.
+    renderPage();
+    await toggleRestorePlate();
+
+    await waitFor(() => expect(puts).toHaveLength(1), { timeout: 3000 });
+    await new Promise((resolve) => setTimeout(resolve, 1500));
+    expect(puts).toHaveLength(1);
+  });
+
+  it('still persists the external_url it detects from the browser', async () => {
+    // The page seeds external_url from window.location.origin when the server
+    // has none and relies on the auto-save to persist it. That only works
+    // because the baseline is the raw server row: seed the baseline from the
+    // adjusted copy instead and the detected URL matches it, so nothing ever
+    // marks it as needing a save.
+    served = { ...mockSettings };
+    renderPage();
+    await screen.findByText(restoreLabel);
+    await new Promise((resolve) => setTimeout(resolve, 200));
+
+    // A refetch carrying a field this page does not manage. It is enough to
+    // re-run the diff, and the only thing that differs is the detected URL.
+    await changeOnServer({ spoolman_url: 'http://spoolman.example' });
+
+    await waitFor(() => expect(puts).toHaveLength(1), { timeout: 3000 });
+    expect(puts[0].external_url).toBe(window.location.origin);
+  });
+});

+ 346 - 0
frontend/src/__tests__/utils/filamentPresets.test.ts

@@ -0,0 +1,346 @@
+import { describe, it, expect, vi } from 'vitest';
+import {
+  buildFilamentPresetOptions,
+  genericFilamentIdForMaterial,
+  presetDisplayName,
+  resolveFilamentId,
+} from '../../utils/filamentPresets';
+import type { BuiltinFilament, LocalPreset, OrcaProfileMeta, SlicerSetting } from '../../api/client';
+
+const localPreset = (over: Partial<LocalPreset> = {}): LocalPreset => ({
+  id: 1,
+  name: 'Elegoo PLA+ @BBL X1C 0.4 nozzle',
+  preset_type: 'filament',
+  source: 'orca',
+  filament_type: 'PLA',
+  filament_vendor: 'Elegoo',
+  nozzle_temp_min: 190,
+  nozzle_temp_max: 230,
+  pressure_advance: null,
+  default_filament_colour: null,
+  filament_cost: null,
+  filament_density: null,
+  compatible_printers: null,
+  inherits: null,
+  version: null,
+  created_at: '',
+  updated_at: '',
+  ...over,
+});
+
+const orcaProfile = (over: Partial<OrcaProfileMeta> = {}): OrcaProfileMeta => ({
+  setting_id: 'a1b2c3',
+  name: 'Sunlu PETG',
+  type: 'filament',
+  version: null,
+  user_id: null,
+  updated_time: null,
+  is_custom: true,
+  ...over,
+});
+
+const cloudSetting = (over: Partial<SlicerSetting> = {}): SlicerSetting => ({
+  setting_id: 'GFSA00',
+  name: 'Bambu PLA Basic @BBL X1C',
+  type: 'filament',
+  version: null,
+  user_id: null,
+  updated_time: null,
+  is_custom: false,
+  ...over,
+});
+
+const builtin = (filament_id: string, name: string): BuiltinFilament => ({ filament_id, name });
+
+describe('genericFilamentIdForMaterial', () => {
+  it('maps an exact material', () => {
+    expect(genericFilamentIdForMaterial('PETG')).toBe('GFG99');
+  });
+
+  it('is case and whitespace tolerant', () => {
+    expect(genericFilamentIdForMaterial('  pla  ')).toBe('GFL99');
+  });
+
+  it('falls back to the base material when a suffix is unknown', () => {
+    // "PLA-GF" has no generic of its own; the PLA generic is the honest answer.
+    expect(genericFilamentIdForMaterial('PLA-GF')).toBe('GFL99');
+  });
+
+  it('returns empty rather than guessing for an unknown material', () => {
+    expect(genericFilamentIdForMaterial('UNOBTANIUM')).toBe('');
+    expect(genericFilamentIdForMaterial('')).toBe('');
+    expect(genericFilamentIdForMaterial(null)).toBe('');
+  });
+});
+
+describe('presetDisplayName', () => {
+  it('strips the printer/nozzle suffix', () => {
+    expect(presetDisplayName('Bambu PLA Basic @BBL X1C 0.4 nozzle')).toBe('Bambu PLA Basic');
+  });
+
+  it('strips the custom-preset marker', () => {
+    expect(presetDisplayName('# My PLA @BBL P1S')).toBe('My PLA');
+  });
+});
+
+describe('buildFilamentPresetOptions', () => {
+  it('is empty when every source is', () => {
+    expect(buildFilamentPresetOptions({})).toEqual([]);
+  });
+
+  it('ranks the tiers local > orca > cloud > builtin', () => {
+    const options = buildFilamentPresetOptions({
+      builtinFilaments: [builtin('GFA00', 'Bambu PLA Basic')],
+      cloudSettings: [cloudSetting({ setting_id: 'GFSB99', name: 'Generic ABS' })],
+      orcaProfiles: [orcaProfile()],
+      localPresets: [localPreset()],
+    });
+    expect(options.map(o => o.source)).toEqual(['local', 'orca_cloud', 'cloud', 'builtin']);
+  });
+
+  it('sorts by name inside a tier', () => {
+    const options = buildFilamentPresetOptions({
+      builtinFilaments: [builtin('GFA01', 'Bambu PLA Matte'), builtin('GFA00', 'Bambu PLA Basic')],
+    });
+    expect(options.map(o => o.name)).toEqual(['Bambu PLA Basic', 'Bambu PLA Matte']);
+  });
+
+  it('takes a builtin filament id straight from the table', () => {
+    const [option] = buildFilamentPresetOptions({ builtinFilaments: [builtin('GFA00', 'Bambu PLA Basic')] });
+    expect(option).toMatchObject({ id: 'builtin_GFA00', filamentId: 'GFA00' });
+  });
+
+  it('derives a Bambu official cloud preset id from its setting_id', () => {
+    const [option] = buildFilamentPresetOptions({ cloudSettings: [cloudSetting({ setting_id: 'GFSG98_09' })] });
+    expect(option.filamentId).toBe('GFG98');
+  });
+
+  it('leaves a cloud user preset unresolved for the detail lookup', () => {
+    // PFUS ids are setting ids, not filament ids — the printer rejects them,
+    // so guessing one here would file the calibration under nothing.
+    const [option] = buildFilamentPresetOptions({
+      cloudSettings: [cloudSetting({ setting_id: 'PFUS9ac902733670a9', name: 'My PETG', is_custom: true })],
+    });
+    expect(option.filamentId).toBe('');
+  });
+
+  it('gives local and orca presets the generic id for their material', () => {
+    const options = buildFilamentPresetOptions({
+      localPresets: [localPreset({ filament_type: 'PETG' })],
+      orcaProfiles: [orcaProfile({ name: 'Sunlu ABS @BBL X1C' })],
+    });
+    expect(options.find(o => o.source === 'local')?.filamentId).toBe('GFG99');
+    expect(options.find(o => o.source === 'orca_cloud')?.filamentId).toBe('GFB99');
+  });
+
+  it('parses the material from the name when a local preset declares none', () => {
+    const [option] = buildFilamentPresetOptions({
+      localPresets: [localPreset({ filament_type: null, name: 'Overture TPU @BBL X1C' })],
+    });
+    expect(option.filamentId).toBe('GFU99');
+  });
+
+  it('collapses a cloud filament duplicated once per printer model', () => {
+    // Every Bambu Cloud account carries one copy per model. They share a
+    // filament id and, with the "@…" suffix stripped, one visible name.
+    const options = buildFilamentPresetOptions({
+      cloudSettings: [
+        cloudSetting({ setting_id: 'GFSA00_00', name: 'Bambu PLA Basic @BBL X1C' }),
+        cloudSetting({ setting_id: 'GFSA00_01', name: 'Bambu PLA Basic @BBL P1S' }),
+        cloudSetting({ setting_id: 'GFSA00_02', name: 'Bambu PLA Basic @BBL A1' }),
+      ],
+    });
+    expect(options).toHaveLength(1);
+    expect(options[0]).toMatchObject({ name: 'Bambu PLA Basic', filamentId: 'GFA00' });
+  });
+
+  it('collapses a cloud user preset duplicated per model, which has no filament id to key on', () => {
+    const options = buildFilamentPresetOptions({
+      cloudSettings: [
+        cloudSetting({ setting_id: 'PFUSaaa', name: 'My PETG @BBL X1C', is_custom: true }),
+        cloudSetting({ setting_id: 'PFUSbbb', name: 'My PETG @BBL P1S', is_custom: true }),
+      ],
+    });
+    expect(options).toHaveLength(1);
+  });
+
+  it('keeps distinct cloud filaments apart', () => {
+    const options = buildFilamentPresetOptions({
+      cloudSettings: [
+        cloudSetting({ setting_id: 'GFSA00_00', name: 'Bambu PLA Basic @BBL X1C' }),
+        cloudSetting({ setting_id: 'GFSA01_00', name: 'Bambu PLA Matte @BBL X1C' }),
+      ],
+    });
+    expect(options.map(o => o.name)).toEqual(['Bambu PLA Basic', 'Bambu PLA Matte']);
+  });
+
+  it('collapses one imported filament re-imported for several printers', () => {
+    const options = buildFilamentPresetOptions({
+      localPresets: [
+        localPreset({ id: 1, name: 'Elegoo PLA+ @BBL X1C 0.4 nozzle' }),
+        localPreset({ id: 2, name: 'Elegoo PLA+ @BBL P1S 0.4 nozzle' }),
+      ],
+    });
+    expect(options).toHaveLength(1);
+  });
+
+  it('keeps imported presets of different materials that share a generic id path', () => {
+    // Keyed by name, not by generic id — otherwise two distinct PLA imports
+    // would collapse into one because both map to GFL99.
+    const options = buildFilamentPresetOptions({
+      localPresets: [
+        localPreset({ id: 1, name: 'Elegoo PLA+' }),
+        localPreset({ id: 2, name: 'Polymaker PolyLite PLA' }),
+      ],
+    });
+    expect(options).toHaveLength(2);
+  });
+
+  it('collapses Orca Cloud copies of one filament', () => {
+    const options = buildFilamentPresetOptions({
+      orcaProfiles: [
+        orcaProfile({ setting_id: 'u1', name: 'Sunlu PETG @BBL X1C' }),
+        orcaProfile({ setting_id: 'u2', name: 'Sunlu PETG @BBL A1' }),
+      ],
+    });
+    expect(options).toHaveLength(1);
+  });
+
+  it('drops a builtin the cloud tier covers under a variant setting_id', () => {
+    // Cloud ids carry a "_NN" variant suffix; without normalising it the
+    // builtin tier lists the same filament a second time.
+    const options = buildFilamentPresetOptions({
+      cloudSettings: [cloudSetting({ setting_id: 'GFSA00_01', name: 'Bambu PLA Basic @BBL X1C' })],
+      builtinFilaments: [builtin('GFA00', 'Bambu PLA Basic'), builtin('GFA01', 'Bambu PLA Matte')],
+    });
+    expect(options.filter(o => o.source === 'builtin').map(o => o.filamentId)).toEqual(['GFA01']);
+  });
+
+  it('drops a builtin already offered by a cloud tier, matching the S-infix spelling', () => {
+    const options = buildFilamentPresetOptions({
+      cloudSettings: [cloudSetting({ setting_id: 'GFSA00', name: 'Bambu PLA Basic' })],
+      builtinFilaments: [builtin('GFA00', 'Bambu PLA Basic'), builtin('GFA01', 'Bambu PLA Matte')],
+    });
+    expect(options.filter(o => o.source === 'builtin').map(o => o.filamentId)).toEqual(['GFA01']);
+  });
+
+  it('drops a bambu cloud preset Orca Cloud already covers', () => {
+    const options = buildFilamentPresetOptions({
+      orcaProfiles: [orcaProfile({ setting_id: 'shared-id' })],
+      cloudSettings: [cloudSetting({ setting_id: 'shared-id' })],
+    });
+    expect(options.map(o => o.source)).toEqual(['orca_cloud']);
+  });
+
+  it('keeps an Orca Cloud library that overlaps an imported bundle by name', () => {
+    // These are usually the same profiles reached two ways. Letting the
+    // imported tier claim the name emptied the Orca Cloud group down to
+    // whatever happened not to be imported too.
+    const options = buildFilamentPresetOptions({
+      localPresets: [
+        localPreset({ id: 1, name: 'Elegoo PLA+ @BBL X1C' }),
+        localPreset({ id: 2, name: 'Sunlu PETG @BBL X1C' }),
+      ],
+      orcaProfiles: [
+        orcaProfile({ setting_id: 'u1', name: 'Elegoo PLA+ @BBL X1C' }),
+        orcaProfile({ setting_id: 'u2', name: 'Sunlu PETG @BBL X1C' }),
+      ],
+    });
+    expect(options.filter(o => o.source === 'local')).toHaveLength(2);
+    expect(options.filter(o => o.source === 'orca_cloud')).toHaveLength(2);
+  });
+
+  it('still drops a cross-tier row that carries an id a higher tier claimed', () => {
+    // A shared id is true identity, unlike a shared name.
+    const options = buildFilamentPresetOptions({
+      orcaProfiles: [orcaProfile({ setting_id: 'shared-id', name: 'Sunlu PETG' })],
+      cloudSettings: [cloudSetting({ setting_id: 'shared-id', name: 'Something Else' })],
+    });
+    expect(options.map(o => o.source)).toEqual(['orca_cloud']);
+  });
+
+  it('never echoes a filament the tiers above already offered back from the builtin table', () => {
+    // The builtin tier is a static copy of the same Bambu catalogue, so
+    // without a name check it re-listed everything under a fourth heading.
+    const options = buildFilamentPresetOptions({
+      localPresets: [localPreset({ name: 'Bambu PLA Basic @BBL X1C 0.4 nozzle' })],
+      builtinFilaments: [builtin('GFA00', 'Bambu PLA Basic'), builtin('GFA01', 'Bambu PLA Matte')],
+    });
+    expect(options.map(o => [o.source, o.name])).toEqual([
+      ['local', 'Bambu PLA Basic'],
+      ['builtin', 'Bambu PLA Matte'],
+    ]);
+  });
+
+  it('suppresses a builtin a cloud tier already named, even with no id overlap', () => {
+    const options = buildFilamentPresetOptions({
+      cloudSettings: [cloudSetting({ setting_id: 'PFUSaaa', name: 'Bambu PLA Basic @BBL X1C', is_custom: true })],
+      builtinFilaments: [builtin('GFA00', 'Bambu PLA Basic')],
+    });
+    expect(options.map(o => o.source)).toEqual(['cloud']);
+  });
+
+  it('does not let one import swallow every filament of the same material', () => {
+    // Imports resolve to a shared generic id (all PLA → GFL99); claiming that
+    // id would hide every other PLA behind the first one imported.
+    const options = buildFilamentPresetOptions({
+      localPresets: [
+        localPreset({ id: 1, name: 'Elegoo PLA+', filament_type: 'PLA' }),
+        localPreset({ id: 2, name: 'Polymaker PolyLite PLA', filament_type: 'PLA' }),
+      ],
+      builtinFilaments: [builtin('GFL99', 'Generic PLA')],
+    });
+    expect(options.map(o => o.name)).toEqual([
+      'Elegoo PLA+',
+      'Polymaker PolyLite PLA',
+      'Generic PLA',
+    ]);
+  });
+
+  it('strips printer suffixes from displayed names', () => {
+    const [option] = buildFilamentPresetOptions({ localPresets: [localPreset()] });
+    expect(option.name).toBe('Elegoo PLA+');
+  });
+});
+
+describe('resolveFilamentId', () => {
+  const option = (over = {}) => ({
+    id: 'PFUS9ac902733670a9',
+    name: 'My PETG',
+    source: 'cloud' as const,
+    filamentId: '',
+    filamentType: 'PETG',
+    ...over,
+  });
+
+  it('returns an already-known id without fetching', async () => {
+    const fetchDetail = vi.fn();
+    await expect(resolveFilamentId(option({ filamentId: 'GFA00' }), fetchDetail)).resolves.toBe('GFA00');
+    expect(fetchDetail).not.toHaveBeenCalled();
+  });
+
+  it('fetches the cloud detail for a user preset', async () => {
+    const fetchDetail = vi.fn().mockResolvedValue({ filament_id: 'P285e239' });
+    await expect(resolveFilamentId(option(), fetchDetail)).resolves.toBe('P285e239');
+    expect(fetchDetail).toHaveBeenCalledWith('PFUS9ac902733670a9');
+  });
+
+  it('returns empty when the detail carries no filament_id', async () => {
+    // Never fall back to base_id: that collapses a custom preset onto the
+    // generic it inherits from (#1053).
+    const fetchDetail = vi.fn().mockResolvedValue({ base_id: 'GFSG98_09' });
+    await expect(resolveFilamentId(option(), fetchDetail)).resolves.toBe('');
+  });
+
+  it('returns empty when the detail lookup fails', async () => {
+    const fetchDetail = vi.fn().mockRejectedValue(new Error('offline'));
+    await expect(resolveFilamentId(option(), fetchDetail)).resolves.toBe('');
+  });
+
+  it('does not fetch for a non-cloud tier that resolved to nothing', async () => {
+    const fetchDetail = vi.fn();
+    const unknown = option({ source: 'local' as const, filamentType: 'UNOBTANIUM' });
+    await expect(resolveFilamentId(unknown, fetchDetail)).resolves.toBe('');
+    expect(fetchDetail).not.toHaveBeenCalled();
+  });
+});

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

@@ -357,6 +357,10 @@ export interface Printer {
   model: string | null;
   model: string | null;
   location: string | null;  // Group/location name
   location: string | null;  // Group/location name
   nozzle_count: number;  // 1 or 2, auto-detected from MQTT
   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;
   is_active: boolean;
   auto_archive: boolean;
   auto_archive: boolean;
   external_camera_url: string | null;
   external_camera_url: string | null;

+ 6 - 2
frontend/src/components/ConfigureAmsSlotModal.tsx

@@ -8,6 +8,7 @@ import { matchesPrinterModelSuffix, presetCompatibility, buildCompatibilityIndex
 import { toFilamentId } from './spool-form/utils';
 import { toFilamentId } from './spool-form/utils';
 import { Button } from './Button';
 import { Button } from './Button';
 import { getAmsLabel } from '../utils/amsHelpers';
 import { getAmsLabel } from '../utils/amsHelpers';
+import { useCancellableTimeout } from '../hooks/useCancellableTimeout';
 
 
 interface SlotInfo {
 interface SlotInfo {
   amsId: number;
   amsId: number;
@@ -305,6 +306,9 @@ export function ConfigureAmsSlotModal({
   const [showSuccess, setShowSuccess] = useState(false);
   const [showSuccess, setShowSuccess] = useState(false);
   const [showExtendedColors, setShowExtendedColors] = useState(false);
   const [showExtendedColors, setShowExtendedColors] = useState(false);
   const scrolledToRef = useRef<string>('');
   const scrolledToRef = useRef<string>('');
+  // The success state is held briefly before the modal closes itself; that
+  // timer must not outlive the modal.
+  const { schedule: scheduleClose } = useCancellableTimeout();
 
 
   // Fetch cloud settings (gracefully handle 401 when logged out)
   // Fetch cloud settings (gracefully handle 401 when logged out)
   const { data: cloudSettings, isLoading: settingsLoading, isError: cloudError } = useQuery({
   const { data: cloudSettings, isLoading: settingsLoading, isError: cloudError } = useQuery({
@@ -614,7 +618,7 @@ export function ConfigureAmsSlotModal({
       setShowSuccess(true);
       setShowSuccess(true);
       onSuccess?.();
       onSuccess?.();
       // Close after showing success briefly
       // Close after showing success briefly
-      setTimeout(() => {
+      scheduleClose(() => {
         setShowSuccess(false);
         setShowSuccess(false);
         onClose();
         onClose();
       }, 1500);
       }, 1500);
@@ -629,7 +633,7 @@ export function ConfigureAmsSlotModal({
     onSuccess: () => {
     onSuccess: () => {
       setShowSuccess(true);
       setShowSuccess(true);
       onSuccess?.();
       onSuccess?.();
-      setTimeout(() => {
+      scheduleClose(() => {
         setShowSuccess(false);
         setShowSuccess(false);
         onClose();
         onClose();
       }, 1500);
       }, 1500);

+ 272 - 116
frontend/src/components/KProfilesView.tsx

@@ -21,10 +21,17 @@ import {
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type { KProfile, KProfileCreate, KProfileDelete, Permission } from '../api/client';
 import type { KProfile, KProfileCreate, KProfileDelete, Permission } from '../api/client';
+import {
+  buildFilamentPresetOptions,
+  resolveFilamentId,
+  type FilamentPresetOption,
+  type FilamentPresetSource,
+} from '../utils/filamentPresets';
 import { Card, CardContent } from './Card';
 import { Card, CardContent } from './Card';
 import { Button } from './Button';
 import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
+import { useCancellableTimeout } from '../hooks/useCancellableTimeout';
 
 
 interface KProfileCardProps {
 interface KProfileCardProps {
   profile: KProfile;
   profile: KProfile;
@@ -42,18 +49,26 @@ const truncateK = (value: string) => {
   return (Math.trunc(num * 1000) / 1000).toFixed(3);
   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")
-const getFlowTypeLabel = (nozzleId: string) => {
-  if (nozzleId.startsWith('HH')) return 'HF';  // High Flow
-  return 'S';  // Standard Flow (default)
-};
-
-// Extract nozzle type prefix from nozzle_id (e.g., "HH00-0.4" -> "HH00")
+// 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';
+
+// 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 getNozzleTypePrefix = (nozzleId: string) => {
   const match = nozzleId.match(/^([A-Z]{2}\d{2})/);
   const match = nozzleId.match(/^([A-Z]{2}\d{2})/);
-  return match ? match[1] : 'HH00';
+  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")
 // Extract filament name from profile name (e.g., "High Flow_Devil Design PLA Basic" -> "Devil Design PLA Basic")
 const extractFilamentName = (profileName: string) => {
 const extractFilamentName = (profileName: string) => {
   // Profile names are formatted as "{Flow Type}_{Filament Name}" or "{Flow Type} {Filament Name}"
   // Profile names are formatted as "{Flow Type}_{Filament Name}" or "{Flow Type} {Filament Name}"
@@ -149,9 +164,11 @@ interface KProfileModalProps {
   profile?: KProfile;
   profile?: KProfile;
   printerId: number;
   printerId: number;
   nozzleDiameter: string;
   nozzleDiameter: string;
-  existingProfiles?: KProfile[];  // Existing profiles for filament selection
+  existingProfiles?: KProfile[];  // Existing profiles, used for name resolution
   builtinFilaments?: { filament_id: string; name: string }[];  // Filament ID → name lookup
   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
   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
   initialNote?: string;  // Initial note value for the profile
   initialNoteKey?: string | null;  // Key the note was stored under (for clearing)
   initialNoteKey?: string | null;  // Key the note was stored under (for clearing)
   onClose: () => void;
   onClose: () => void;
@@ -166,7 +183,9 @@ function KProfileModal({
   nozzleDiameter,
   nozzleDiameter,
   existingProfiles = [],
   existingProfiles = [],
   builtinFilaments = [],
   builtinFilaments = [],
+  filamentPresets = [],
   isDualNozzle = false,
   isDualNozzle = false,
+  supportsFlowType = true,
   initialNote = '',
   initialNote = '',
   initialNoteKey = null,
   initialNoteKey = null,
   onClose,
   onClose,
@@ -181,10 +200,19 @@ function KProfileModal({
   const [kValue, setKValue] = useState(
   const [kValue, setKValue] = useState(
     profile?.k_value ? truncateK(profile.k_value) : '0.020'
     profile?.k_value ? truncateK(profile.k_value) : '0.020'
   );
   );
-  const [filamentId, setFilamentId] = useState(profile?.filament_id || '');
+  // What the Filament select is bound to. When editing, the printer's own
+  // filament_id (the select is read-only). For a new profile, the *preset
+  // handle* from the tiered list — a local row id, an Orca UUID, a Bambu Cloud
+  // setting_id or a builtin filament id — which is resolved to a real
+  // filament_id on submit, since only some tiers carry one directly.
+  const [filamentChoice, setFilamentChoice] = useState(profile?.filament_id || '');
   // Split nozzle into type and diameter
   // Split nozzle into type and diameter
+  // Both selects are read-only while editing: they report what the printer
+  // holds, they don't set it. '' means the printer reported no nozzle_id, which
+  // single-nozzle models never do (#1748) — showing "High Flow" there was the
+  // UI inventing a value the printer never sent.
   const [nozzleType, setNozzleType] = useState(
   const [nozzleType, setNozzleType] = useState(
-    profile?.nozzle_id ? getNozzleTypePrefix(profile.nozzle_id) : 'HH00'
+    profile ? getNozzleTypePrefix(profile.nozzle_id) : STANDARD_FLOW
   );
   );
   const [modalDiameter, setModalDiameter] = useState(
   const [modalDiameter, setModalDiameter] = useState(
     profile?.nozzle_diameter || nozzleDiameter
     profile?.nozzle_diameter || nozzleDiameter
@@ -197,40 +225,49 @@ function KProfileModal({
   const [isSyncing, setIsSyncing] = useState(false);
   const [isSyncing, setIsSyncing] = useState(false);
   const [savingProgress, setSavingProgress] = useState({ current: 0, total: 0 });
   const [savingProgress, setSavingProgress] = useState({ current: 0, total: 0 });
   const [note, setNote] = useState(initialNote);
   const [note, setNote] = useState(initialNote);
-
-  // Extract unique filaments from existing K-profiles on the printer
-  // Use builtin filament table for accurate name resolution (filament_id → name)
-  // Falls back to extracting from profile name for custom/unknown presets
-  const knownFilaments = React.useMemo(() => {
-    // Build lookup map from builtin filament names (includes cloud presets from parent)
-    const builtinMap = new Map<string, string>();
-    for (const bf of builtinFilaments) {
-      builtinMap.set(bf.filament_id, bf.name);
-    }
-
-    const filamentMap = new Map<string, { id: string; name: string }>();
-    for (const p of existingProfiles) {
-      if (p.filament_id && !filamentMap.has(p.filament_id)) {
-        // Prefer builtin name (accurate), fall back to extracting from profile name
-        const builtinName = builtinMap.get(p.filament_id);
-        const filamentName = builtinName || extractFilamentName(p.name || '');
-        filamentMap.set(p.filament_id, {
-          id: p.filament_id,
-          name: filamentName || p.filament_id,
-        });
-      }
-    }
-    return Array.from(filamentMap.values()).sort((a, b) =>
-      a.name.localeCompare(b.name)
-    );
-  }, [existingProfiles, builtinFilaments]);
+  const [filamentQuery, setFilamentQuery] = useState('');
+  // The modal defers its own close so the printer has time to process the
+  // command; that timer must not outlive the modal.
+  const { schedule: scheduleClose } = useCancellableTimeout();
+
+  // Name for the filament an existing profile is bound to. The builtin table
+  // (which the parent has already merged with the user's cloud presets) is
+  // authoritative; a profile whose filament_id is in neither falls back to the
+  // name the printer stored for it.
+  const editedFilamentName = React.useMemo(() => {
+    if (!profile?.filament_id) return '';
+    const builtinName = builtinFilaments.find(bf => bf.filament_id === profile.filament_id)?.name;
+    if (builtinName) return builtinName;
+    const fromProfile = existingProfiles.find(p => p.filament_id === profile.filament_id);
+    return extractFilamentName(fromProfile?.name || profile.name || '') || profile.filament_id;
+  }, [profile, existingProfiles, builtinFilaments]);
+
+  // The tiered list, grouped for rendering. Order is fixed app-wide —
+  // imported, then Orca Cloud, then Bambu Cloud, then the hardcoded table —
+  // and buildFilamentPresetOptions has already sorted by it, so grouping is
+  // just a partition that preserves that order.
+  const presetGroups = React.useMemo(() => {
+    const labels: [FilamentPresetSource, string][] = [
+      ['local', t('kProfiles.modal.source.local')],
+      ['orca_cloud', t('kProfiles.modal.source.orcaCloud')],
+      ['cloud', t('kProfiles.modal.source.bambuCloud')],
+      ['builtin', t('kProfiles.modal.source.builtin')],
+    ];
+    const query = filamentQuery.trim().toLowerCase();
+    const matches = query
+      ? filamentPresets.filter(p => p.name.toLowerCase().includes(query))
+      : filamentPresets;
+    return labels
+      .map(([source, label]) => ({ source, label, items: matches.filter(p => p.source === source) }))
+      .filter(g => g.items.length > 0);
+  }, [filamentPresets, filamentQuery, t]);
 
 
   const saveMutation = useMutation({
   const saveMutation = useMutation({
     mutationFn: (data: KProfileCreate) => {
     mutationFn: (data: KProfileCreate) => {
       console.log('[KProfile] Calling API...');
       console.log('[KProfile] Calling API...');
       return api.setKProfile(printerId, data);
       return api.setKProfile(printerId, data);
     },
     },
-    onSuccess: (result) => {
+    onSuccess: (result, variables) => {
       console.log('[KProfile] Save success:', result);
       console.log('[KProfile] Save success:', result);
       showToast(t('kProfiles.toast.profileSaved'));
       showToast(t('kProfiles.toast.profileSaved'));
       // Save note if it changed (including clearing it)
       // Save note if it changed (including clearing it)
@@ -243,8 +280,10 @@ function KProfileModal({
           // Editing: use setting_id if available, or composite key with slot_id
           // Editing: use setting_id if available, or composite key with slot_id
           profileKey = profile.setting_id || `slot_${profile.slot_id}_${profile.filament_id}_${profile.extruder_id}`;
           profileKey = profile.setting_id || `slot_${profile.slot_id}_${profile.filament_id}_${profile.extruder_id}`;
         } else {
         } else {
-          // New profile: use name as key (will be matched when profile is loaded)
-          profileKey = `name_${name}_${filamentId}`;
+          // New profile: use name as key (matched against the reloaded profile,
+          // so it has to be the resolved filament_id that was sent — not the
+          // preset handle the user picked).
+          profileKey = `name_${name}_${variables.filament_id}`;
         }
         }
         onSaveNote(profileKey, note);
         onSaveNote(profileKey, note);
       }
       }
@@ -252,7 +291,7 @@ function KProfileModal({
       setIsSyncing(true);
       setIsSyncing(true);
       // Add delay before closing to give printer time to process the save
       // Add delay before closing to give printer time to process the save
       // onSave will trigger refetch in the parent component
       // onSave will trigger refetch in the parent component
-      setTimeout(() => {
+      scheduleClose(() => {
         setIsSyncing(false);
         setIsSyncing(false);
         onSave();
         onSave();
       }, 2500);
       }, 2500);
@@ -276,7 +315,7 @@ function KProfileModal({
       setIsSyncing(true);
       setIsSyncing(true);
       // Add longer delay for delete - printer needs more time to process
       // Add longer delay for delete - printer needs more time to process
       // before it can return the updated profile list
       // before it can return the updated profile list
-      setTimeout(() => {
+      scheduleClose(() => {
         setIsSyncing(false);
         setIsSyncing(false);
         onClose();
         onClose();
       }, 4000);
       }, 4000);
@@ -316,14 +355,46 @@ function KProfileModal({
     // Combine nozzle type and diameter into nozzle_id (e.g., "HH00-0.4")
     // Combine nozzle type and diameter into nozzle_id (e.g., "HH00-0.4")
     const nozzleId = `${nozzleType}-${modalDiameter}`;
     const nozzleId = `${nozzleType}-${modalDiameter}`;
 
 
+    // An edit is delete + re-add on single-nozzle printers, so the nozzle
+    // 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
+    // the user picked has to be reduced to one before anything is sent. Only
+    // the builtin tier and Bambu's official cloud presets carry one outright;
+    // a cloud *user* preset needs its detail fetched, and imported / Orca
+    // presets have no Bambu id at all and map to the generic for their
+    // material. Refuse rather than guess when nothing resolves — a profile
+    // filed under the wrong filament is invisible to the slot that needs it.
+    let resolvedFilamentId = profile?.filament_id || '';
+    if (!profile) {
+      const picked = filamentPresets.find(p => p.id === filamentChoice);
+      if (!picked) {
+        showToast(t('kProfiles.toast.selectFilament'), 'error');
+        return;
+      }
+      resolvedFilamentId = await resolveFilamentId(picked, api.getCloudSettingDetail);
+      if (!resolvedFilamentId) {
+        showToast(t('kProfiles.toast.filamentNotResolvable', { name: picked.name }), 'error');
+        return;
+      }
+    }
+
     // For editing or single extruder: just save one profile
     // For editing or single extruder: just save one profile
     if (profile || selectedExtruders.length === 1) {
     if (profile || selectedExtruders.length === 1) {
       const payload = {
       const payload = {
         name: name,
         name: name,
         k_value: formattedKValue,
         k_value: formattedKValue,
-        filament_id: filamentId,
-        nozzle_id: nozzleId,
-        nozzle_diameter: modalDiameter,
+        filament_id: resolvedFilamentId,
+        nozzle_id: editNozzleId,
+        nozzle_diameter: editDiameter,
         extruder_id: profile ? profile.extruder_id : selectedExtruders[0],
         extruder_id: profile ? profile.extruder_id : selectedExtruders[0],
         setting_id: profile?.setting_id,
         setting_id: profile?.setting_id,
         slot_id: profile?.slot_id ?? 0,
         slot_id: profile?.slot_id ?? 0,
@@ -341,7 +412,7 @@ function KProfileModal({
     const batchPayload = selectedExtruders.map(extruderId => ({
     const batchPayload = selectedExtruders.map(extruderId => ({
       name: name,
       name: name,
       k_value: formattedKValue,
       k_value: formattedKValue,
-      filament_id: filamentId,
+      filament_id: resolvedFilamentId,
       nozzle_id: nozzleId,
       nozzle_id: nozzleId,
       nozzle_diameter: modalDiameter,
       nozzle_diameter: modalDiameter,
       extruder_id: extruderId,
       extruder_id: extruderId,
@@ -356,7 +427,7 @@ function KProfileModal({
       showToast(t('kProfiles.toast.profilesSaved', { count: selectedExtruders.length }));
       showToast(t('kProfiles.toast.profilesSaved', { count: selectedExtruders.length }));
       // Save note for new batch profiles
       // Save note for new batch profiles
       if (onSaveNote && note) {
       if (onSaveNote && note) {
-        const profileKey = `name_${name}_${filamentId}`;
+        const profileKey = `name_${name}_${resolvedFilamentId}`;
         onSaveNote(profileKey, note);
         onSaveNote(profileKey, note);
       }
       }
     } catch (error) {
     } catch (error) {
@@ -370,7 +441,7 @@ function KProfileModal({
     setSavingProgress({ current: selectedExtruders.length, total: selectedExtruders.length });
     setSavingProgress({ current: selectedExtruders.length, total: selectedExtruders.length });
     // Wait for final sync before closing
     // Wait for final sync before closing
     // onSave will trigger refetch in the parent component
     // onSave will trigger refetch in the parent component
-    setTimeout(() => {
+    scheduleClose(() => {
       setIsSyncing(false);
       setIsSyncing(false);
       setSavingProgress({ current: 0, total: 0 });
       setSavingProgress({ current: 0, total: 0 });
       onSave();
       onSave();
@@ -454,49 +525,77 @@ function KProfileModal({
             {/* Filament - read-only when editing */}
             {/* Filament - read-only when editing */}
             <div>
             <div>
               <label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.filament')}</label>
               <label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.filament')}</label>
-              <select
-                value={filamentId}
-                onChange={(e) => {
-                  const newFilamentId = e.target.value;
-                  setFilamentId(newFilamentId);
-                  // Auto-generate profile name when filament is selected (for new profiles)
-                  // Only auto-generate if name is empty - don't overwrite user input
-                  if (!profile && newFilamentId && !name) {
-                    const selectedFilament = knownFilaments.find(f => f.id === newFilamentId);
-                    if (selectedFilament) {
-                      const flowLabel = nozzleType === 'HH00' ? 'HF' : 'S';
-                      setName(`${flowLabel} ${selectedFilament.name}`);
-                    }
-                  }
-                }}
-                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' : ''}`}
-                required={!profile}
-              >
-                <option value="">{t('kProfiles.modal.selectFilament')}</option>
-                {/* Show current filament when editing - look up from knownFilaments */}
-                {profile?.filament_id && (
-                  <option key={profile.filament_id} value={profile.filament_id}>
-                    {knownFilaments.find(f => f.id === profile.filament_id)?.name || profile.filament_id}
-                  </option>
-                )}
-                {/* Show known filaments from existing K-profiles (for new profiles) */}
-                {!profile && knownFilaments.map((f) => (
-                  <option key={f.id} value={f.id}>
-                    {f.name}
-                  </option>
-                ))}
-              </select>
-              {!profile && knownFilaments.length === 0 && (
-                <p className="text-xs text-bambu-gray mt-1">
-                  {t('kProfiles.modal.noFilamentsHelp')}
-                </p>
+              {profile ? (
+                // Editing or copying: the filament is fixed, so this is a
+                // readout rather than a control.
+                <div className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white opacity-60">
+                  {editedFilamentName || profile.filament_id}
+                </div>
+              ) : (
+                // A real list rather than a <select>: Chrome ignores almost
+                // every CSS property on <optgroup>, so a source heading inside
+                // a native dropdown can't be made to stand out.
+                <div className="border border-bambu-dark-tertiary rounded-lg overflow-hidden">
+                  <div className="relative border-b border-bambu-dark-tertiary">
+                    <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
+                    <input
+                      type="text"
+                      value={filamentQuery}
+                      onChange={(e) => setFilamentQuery(e.target.value)}
+                      placeholder={t('kProfiles.modal.searchFilaments')}
+                      className="w-full pl-10 pr-3 py-2 bg-bambu-dark text-white placeholder-bambu-gray focus:outline-none"
+                    />
+                  </div>
+                  <div className="max-h-56 overflow-y-auto bg-bambu-dark">
+                    {presetGroups.length === 0 ? (
+                      <p className="px-3 py-3 text-xs text-bambu-gray">
+                        {filamentPresets.length === 0
+                          ? t('kProfiles.modal.noFilamentsHelp')
+                          : t('kProfiles.modal.noFilamentMatches')}
+                      </p>
+                    ) : presetGroups.map((group) => (
+                      <div key={group.source}>
+                        <div className="sticky top-0 z-10 flex items-center gap-2 px-3 py-1.5 bg-bambu-dark-secondary border-y border-bambu-dark-tertiary">
+                          <span className="text-xs font-bold uppercase tracking-wider text-bambu-green">
+                            {group.label}
+                          </span>
+                          <span className="text-[10px] text-bambu-gray">{group.items.length}</span>
+                        </div>
+                        {group.items.map((f) => (
+                          <button
+                            key={f.id}
+                            type="button"
+                            onClick={() => {
+                              setFilamentChoice(f.id);
+                              // Auto-generate the profile name, but never over
+                              // an entry the user typed.
+                              if (!name) {
+                                const flowLabel = nozzleType === HIGH_FLOW ? 'HF' : 'S';
+                                setName(`${flowLabel} ${f.name}`);
+                              }
+                            }}
+                            className={`w-full text-left px-3 py-1.5 text-sm transition-colors ${
+                              filamentChoice === f.id
+                                ? 'bg-bambu-green/20 text-white'
+                                : 'text-white hover:bg-bambu-dark-tertiary'
+                            }`}
+                          >
+                            {f.name}
+                          </button>
+                        ))}
+                      </div>
+                    ))}
+                  </div>
+                </div>
               )}
               )}
             </div>
             </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>
                 <label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.flowType')}</label>
                 <select
                 <select
                   value={nozzleType}
                   value={nozzleType}
@@ -505,10 +604,10 @@ function KProfileModal({
                     setNozzleType(newNozzleType);
                     setNozzleType(newNozzleType);
                     // Update profile name when flow type changes (for new profiles)
                     // Update profile name when flow type changes (for new profiles)
                     // Only auto-generate if name is empty - don't overwrite user input
                     // Only auto-generate if name is empty - don't overwrite user input
-                    if (!profile && filamentId && !name) {
-                      const selectedFilament = knownFilaments.find(f => f.id === filamentId);
+                    if (!profile && filamentChoice && !name) {
+                      const selectedFilament = filamentPresets.find(f => f.id === filamentChoice);
                       if (selectedFilament) {
                       if (selectedFilament) {
-                        const flowLabel = newNozzleType === 'HS00' ? 'HF' : 'S';
+                        const flowLabel = newNozzleType === HIGH_FLOW ? 'HF' : 'S';
                         setName(`${flowLabel} ${selectedFilament.name}`);
                         setName(`${flowLabel} ${selectedFilament.name}`);
                       }
                       }
                     }
                     }
@@ -516,8 +615,8 @@ function KProfileModal({
                   disabled={!!profile}
                   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' : ''}`}
                   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' : ''}`}
                 >
                 >
-                  <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>
                 </select>
               </div>
               </div>
               <div>
               <div>
@@ -772,13 +871,12 @@ export function KProfilesView() {
     refetchOnMount: 'always',  // Always refetch when component mounts
     refetchOnMount: 'always',  // Always refetch when component mounts
   });
   });
 
 
-  // Also fetch 0.4mm profiles for the filament dropdown (most filaments are calibrated for 0.4mm)
-  const { data: allProfiles } = useQuery({
-    queryKey: ['kprofiles', selectedPrinter, '0.4'],
-    queryFn: () => api.getKProfiles(selectedPrinter!, '0.4'),
-    enabled: !!selectedPrinter,
-    staleTime: 60000,  // Cache for 1 minute
-  });
+  // A second fetch for 0.4mm profiles used to seed the Add-Profile filament
+  // dropdown. The dropdown is built from the filament preset tiers now
+  // (#2719), so the round trip bought nothing — and it fired concurrently
+  // with the fetch above whenever a different nozzle was selected, which is
+  // exactly the two-requests-in-flight case that made K-profile fetches time
+  // out (#1748).
 
 
   // Fetch builtin filament names for accurate filament_id → name resolution
   // Fetch builtin filament names for accurate filament_id → name resolution
   const { data: builtinFilaments } = useQuery({
   const { data: builtinFilaments } = useQuery({
@@ -794,6 +892,28 @@ export function KProfilesView() {
     staleTime: 300000,  // Cache for 5 minutes
     staleTime: 300000,  // Cache for 5 minutes
   });
   });
 
 
+  // The other three filament tiers, so a printer with no K-profiles yet can
+  // still be given its first one (#2719). Each query stands alone and fails
+  // quietly: not being signed in to a cloud should thin the list, not break
+  // the page, and the builtin tier above guarantees it is never empty.
+  const { data: localPresets } = useQuery({
+    queryKey: ['localPresets'],
+    queryFn: () => api.getLocalPresets(),
+    retry: false,
+  });
+
+  const { data: orcaCloudList } = useQuery({
+    queryKey: ['orcaCloudProfilesForKProfiles'],
+    queryFn: () => api.orcaCloudListProfiles(),
+    retry: false,
+  });
+
+  const { data: cloudSettings } = useQuery({
+    queryKey: ['cloudSettings'],
+    queryFn: () => api.getCloudSettings(),
+    retry: false,
+  });
+
   // Fetch K-profile notes (stored locally)
   // Fetch K-profile notes (stored locally)
   const {
   const {
     data: notesData,
     data: notesData,
@@ -860,6 +980,19 @@ export function KProfilesView() {
     }));
     }));
   }, [builtinFilamentMap]);
   }, [builtinFilamentMap]);
 
 
+  // Every filament this install knows about, ranked in the app-wide order:
+  // imported presets, then Orca Cloud, then Bambu Cloud, then the hardcoded
+  // built-in table as the floor.
+  const filamentPresets = React.useMemo(
+    () => buildFilamentPresetOptions({
+      localPresets: localPresets?.filament,
+      orcaProfiles: orcaCloudList?.filament,
+      cloudSettings: cloudSettings?.filament,
+      builtinFilaments,
+    }),
+    [localPresets?.filament, orcaCloudList?.filament, cloudSettings?.filament, builtinFilaments]
+  );
+
   // Resolve filament name: builtin table first, then extract from profile name
   // Resolve filament name: builtin table first, then extract from profile name
   const resolveFilamentName = React.useCallback((profile: KProfile) => {
   const resolveFilamentName = React.useCallback((profile: KProfile) => {
     return builtinFilamentMap.get(profile.filament_id) || extractFilamentName(profile.name);
     return builtinFilamentMap.get(profile.filament_id) || extractFilamentName(profile.name);
@@ -911,6 +1044,18 @@ export function KProfilesView() {
   const selectedPrinterData = printers?.find((p) => p.id === selectedPrinter);
   const selectedPrinterData = printers?.find((p) => p.id === selectedPrinter);
   const isDualNozzle = selectedPrinterData?.nozzle_count === 2;
   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
   // Keyboard shortcuts
   useEffect(() => {
   useEffect(() => {
     const handleKeyDown = (e: KeyboardEvent) => {
     const handleKeyDown = (e: KeyboardEvent) => {
@@ -1002,7 +1147,10 @@ export function KProfilesView() {
               name: p.name,
               name: p.name,
               k_value: parseFloat(p.k_value).toFixed(6),
               k_value: parseFloat(p.k_value).toFixed(6),
               filament_id: p.filament_id,
               filament_id: p.filament_id,
-              nozzle_id: p.nozzle_id || `HH00-${nozzleDiameter}`,
+              // 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,
               nozzle_diameter: p.nozzle_diameter || nozzleDiameter,
               extruder_id: p.extruder_id ?? 0,
               extruder_id: p.extruder_id ?? 0,
               slot_id: 0, // Always create new
               slot_id: 0, // Always create new
@@ -1248,17 +1396,19 @@ export function KProfilesView() {
             </select>
             </select>
           </div>
           </div>
         )}
         )}
-        <div className="w-32">
-          <select
-            value={flowTypeFilter}
-            onChange={(e) => setFlowTypeFilter(e.target.value as FlowTypeFilter)}
-            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"
-          >
-            <option value="all">{t('kProfiles.allFlow')}</option>
-            <option value="hf">{t('kProfiles.hfOnly')}</option>
-            <option value="s">{t('kProfiles.sOnly')}</option>
-          </select>
-        </div>
+        {supportsFlowType && (
+          <div className="w-32">
+            <select
+              value={flowTypeFilter}
+              onChange={(e) => setFlowTypeFilter(e.target.value as FlowTypeFilter)}
+              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"
+            >
+              <option value="all">{t('kProfiles.allFlow')}</option>
+              <option value="hf">{t('kProfiles.hfOnly')}</option>
+              <option value="s">{t('kProfiles.sOnly')}</option>
+            </select>
+          </div>
+        )}
         <div className="w-32">
         <div className="w-32">
           <select
           <select
             value={sortOption}
             value={sortOption}
@@ -1451,9 +1601,11 @@ export function KProfilesView() {
             profile={editingProfile}
             profile={editingProfile}
             printerId={selectedPrinter}
             printerId={selectedPrinter}
             nozzleDiameter={nozzleDiameter}
             nozzleDiameter={nozzleDiameter}
-            existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
+            existingProfiles={kprofiles?.profiles}
             builtinFilaments={enrichedBuiltinFilaments}
             builtinFilaments={enrichedBuiltinFilaments}
+            filamentPresets={filamentPresets}
             isDualNozzle={isDualNozzle}
             isDualNozzle={isDualNozzle}
+            supportsFlowType={supportsFlowType}
             initialNote={note}
             initialNote={note}
             initialNoteKey={key}
             initialNoteKey={key}
             onSaveNote={handleSaveNote}
             onSaveNote={handleSaveNote}
@@ -1476,9 +1628,11 @@ export function KProfilesView() {
         <KProfileModal
         <KProfileModal
           printerId={selectedPrinter}
           printerId={selectedPrinter}
           nozzleDiameter={nozzleDiameter}
           nozzleDiameter={nozzleDiameter}
-          existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
+          existingProfiles={kprofiles?.profiles}
           builtinFilaments={enrichedBuiltinFilaments}
           builtinFilaments={enrichedBuiltinFilaments}
+          filamentPresets={filamentPresets}
           isDualNozzle={isDualNozzle}
           isDualNozzle={isDualNozzle}
+          supportsFlowType={supportsFlowType}
           onSaveNote={handleSaveNote}
           onSaveNote={handleSaveNote}
           hasPermission={hasPermission}
           hasPermission={hasPermission}
           onClose={() => {
           onClose={() => {
@@ -1497,9 +1651,11 @@ export function KProfilesView() {
         <KProfileModal
         <KProfileModal
           printerId={selectedPrinter}
           printerId={selectedPrinter}
           nozzleDiameter={nozzleDiameter}
           nozzleDiameter={nozzleDiameter}
-          existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
+          existingProfiles={kprofiles?.profiles}
           builtinFilaments={enrichedBuiltinFilaments}
           builtinFilaments={enrichedBuiltinFilaments}
+          filamentPresets={filamentPresets}
           isDualNozzle={isDualNozzle}
           isDualNozzle={isDualNozzle}
+          supportsFlowType={supportsFlowType}
           onSaveNote={handleSaveNote}
           onSaveNote={handleSaveNote}
           hasPermission={hasPermission}
           hasPermission={hasPermission}
           // Pass profile data but without slot_id to create a new profile
           // Pass profile data but without slot_id to create a new profile

+ 37 - 0
frontend/src/hooks/useCancellableTimeout.ts

@@ -0,0 +1,37 @@
+import { useCallback, useEffect, useRef } from 'react';
+
+/**
+ * setTimeout that cannot outlive the component that scheduled it.
+ *
+ * Modals here defer their own close by a second or more so the printer has
+ * time to process the command that was just sent. A plain setTimeout for that
+ * keeps a reference to setState and to the parent's onClose, and fires whether
+ * or not the modal is still mounted — closing an already-dismissed dialog, or
+ * throwing outright once the surrounding environment is gone ("window is not
+ * defined" when a test's DOM is torn down before the timer fires).
+ *
+ * Returns a schedule function. Scheduling again replaces any pending timer, and
+ * unmounting cancels it.
+ */
+export function useCancellableTimeout() {
+  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
+
+  const cancel = useCallback(() => {
+    if (timer.current !== null) {
+      clearTimeout(timer.current);
+      timer.current = null;
+    }
+  }, []);
+
+  const schedule = useCallback((fn: () => void, ms: number) => {
+    cancel();
+    timer.current = setTimeout(() => {
+      timer.current = null;
+      fn();
+    }, ms);
+  }, [cancel]);
+
+  useEffect(() => cancel, [cancel]);
+
+  return { schedule, cancel };
+}

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

@@ -5034,7 +5034,15 @@ export default {
       kValueHelp: 'Typischer Bereich: 0,01 - 0,06 für PLA, 0,02 - 0,10 für PETG',
       kValueHelp: 'Typischer Bereich: 0,01 - 0,06 für PLA, 0,02 - 0,10 für PETG',
       filament: 'Filament',
       filament: 'Filament',
       selectFilament: 'Filament auswählen...',
       selectFilament: 'Filament auswählen...',
-      noFilamentsHelp: 'Keine Filamente gefunden. Erstellen Sie zuerst ein K-Profil in Bambu Studio.',
+      source: {
+        local: 'Importiert',
+        orcaCloud: 'Orca Cloud',
+        bambuCloud: 'Bambu Cloud',
+        builtin: 'Integriert',
+      },
+      noFilamentsHelp: 'Keine Filamente verfügbar. Melde dich bei Bambu Cloud an oder importiere Presets unter Profile → Lokale Profile.',
+      searchFilaments: 'Filamente durchsuchen...',
+      noFilamentMatches: 'Kein Filament passt zu dieser Suche',
       flowType: 'Flusstyp',
       flowType: 'Flusstyp',
       highFlow: 'Hoher Durchfluss',
       highFlow: 'Hoher Durchfluss',
       standard: 'Standard',
       standard: 'Standard',
@@ -5067,6 +5075,8 @@ export default {
       profileSaved: 'K-Profil gespeichert',
       profileSaved: 'K-Profil gespeichert',
       profilesSaved: 'K-Profil auf {{count}} Extrudern gespeichert',
       profilesSaved: 'K-Profil auf {{count}} Extrudern gespeichert',
       selectAtLeastOneExtruder: 'Bitte wählen Sie mindestens einen Extruder aus',
       selectAtLeastOneExtruder: 'Bitte wählen Sie mindestens einen Extruder aus',
+      selectFilament: 'Bitte zuerst ein Filament auswählen',
+      filamentNotResolvable: 'Keine Bambu-Filament-ID für {{name}} — der Drucker kann dafür kein Profil speichern',
       profileDeleted: 'K-Profil gelöscht',
       profileDeleted: 'K-Profil gelöscht',
       profilesDeleted: '{{count}} Profile gelöscht',
       profilesDeleted: '{{count}} Profile gelöscht',
       exportedProfiles: '{{count}} Profile exportiert',
       exportedProfiles: '{{count}} Profile exportiert',

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

@@ -5078,7 +5078,15 @@ export default {
       kValueHelp: 'Typical range: 0.01 - 0.06 for PLA, 0.02 - 0.10 for PETG',
       kValueHelp: 'Typical range: 0.01 - 0.06 for PLA, 0.02 - 0.10 for PETG',
       filament: 'Filament',
       filament: 'Filament',
       selectFilament: 'Select filament...',
       selectFilament: 'Select filament...',
-      noFilamentsHelp: 'No filaments found. Create a K-profile in Bambu Studio first.',
+      source: {
+        local: 'Imported',
+        orcaCloud: 'Orca Cloud',
+        bambuCloud: 'Bambu Cloud',
+        builtin: 'Built-in',
+      },
+      noFilamentsHelp: 'No filaments available. Sign in to Bambu Cloud, or import presets under Profiles → Local Profiles.',
+      searchFilaments: 'Search filaments...',
+      noFilamentMatches: 'No filament matches that search',
       flowType: 'Flow Type',
       flowType: 'Flow Type',
       highFlow: 'High Flow',
       highFlow: 'High Flow',
       standard: 'Standard',
       standard: 'Standard',
@@ -5111,6 +5119,8 @@ export default {
       profileSaved: 'K-profile saved',
       profileSaved: 'K-profile saved',
       profilesSaved: 'K-profile saved to {{count}} extruders',
       profilesSaved: 'K-profile saved to {{count}} extruders',
       selectAtLeastOneExtruder: 'Please select at least one extruder',
       selectAtLeastOneExtruder: 'Please select at least one extruder',
+      selectFilament: 'Select a filament first',
+      filamentNotResolvable: 'No Bambu filament ID for {{name}} — the printer cannot store a profile for it',
       profileDeleted: 'K-profile deleted',
       profileDeleted: 'K-profile deleted',
       profilesDeleted: 'Deleted {{count}} profiles',
       profilesDeleted: 'Deleted {{count}} profiles',
       exportedProfiles: 'Exported {{count}} profiles',
       exportedProfiles: 'Exported {{count}} profiles',

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

@@ -5043,7 +5043,15 @@ export default {
       kValueHelp: 'Rango típico: 0,01 - 0,06 para PLA, 0,02 - 0,10 para PETG',
       kValueHelp: 'Rango típico: 0,01 - 0,06 para PLA, 0,02 - 0,10 para PETG',
       filament: 'Filamento',
       filament: 'Filamento',
       selectFilament: 'Seleccionar filamento...',
       selectFilament: 'Seleccionar filamento...',
-      noFilamentsHelp: 'No se encontraron filamentos. Cree primero un perfil K en Bambu Studio.',
+      source: {
+        local: 'Importado',
+        orcaCloud: 'Orca Cloud',
+        bambuCloud: 'Bambu Cloud',
+        builtin: 'Integrado',
+      },
+      noFilamentsHelp: 'No hay filamentos disponibles. Inicia sesión en Bambu Cloud o importa ajustes en Perfiles → Perfiles locales.',
+      searchFilaments: 'Buscar filamentos...',
+      noFilamentMatches: 'Ningún filamento coincide con esa búsqueda',
       flowType: 'Tipo de flujo',
       flowType: 'Tipo de flujo',
       highFlow: 'Flujo alto',
       highFlow: 'Flujo alto',
       standard: 'Estándar',
       standard: 'Estándar',
@@ -5076,6 +5084,8 @@ export default {
       profileSaved: 'Perfil K guardado',
       profileSaved: 'Perfil K guardado',
       profilesSaved: 'Perfil K guardado en {{count}} extrusores',
       profilesSaved: 'Perfil K guardado en {{count}} extrusores',
       selectAtLeastOneExtruder: 'Seleccione al menos un extrusor',
       selectAtLeastOneExtruder: 'Seleccione al menos un extrusor',
+      selectFilament: 'Selecciona primero un filamento',
+      filamentNotResolvable: 'No hay ID de filamento Bambu para {{name}}: la impresora no puede guardar un perfil',
       profileDeleted: 'Perfil K eliminado',
       profileDeleted: 'Perfil K eliminado',
       profilesDeleted: 'Se eliminaron {{count}} perfiles',
       profilesDeleted: 'Se eliminaron {{count}} perfiles',
       exportedProfiles: 'Se exportaron {{count}} perfiles',
       exportedProfiles: 'Se exportaron {{count}} perfiles',

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

@@ -5024,7 +5024,15 @@ export default {
       kValueHelp: 'Plage type : 0.01-0.06 (PLA), 0.02-0.10 (PETG)',
       kValueHelp: 'Plage type : 0.01-0.06 (PLA), 0.02-0.10 (PETG)',
       filament: 'Filament',
       filament: 'Filament',
       selectFilament: 'Choisir filament...',
       selectFilament: 'Choisir filament...',
-      noFilamentsHelp: 'Créez d\'abord un profil dans Bambu Studio.',
+      source: {
+        local: 'Importé',
+        orcaCloud: 'Orca Cloud',
+        bambuCloud: 'Bambu Cloud',
+        builtin: 'Inclus',
+      },
+      noFilamentsHelp: 'Aucun filament disponible. Connectez-vous à Bambu Cloud ou importez des préréglages dans Profils → Profils locaux.',
+      searchFilaments: 'Rechercher des filaments...',
+      noFilamentMatches: 'Aucun filament ne correspond à cette recherche',
       flowType: 'Type de débit',
       flowType: 'Type de débit',
       highFlow: 'Haut Débit (HF)',
       highFlow: 'Haut Débit (HF)',
       standard: 'Standard',
       standard: 'Standard',
@@ -5057,6 +5065,8 @@ export default {
       profileSaved: 'Profil K enregistré',
       profileSaved: 'Profil K enregistré',
       profilesSaved: 'Profil K enregistré sur {{count}} extrudeur(s)',
       profilesSaved: 'Profil K enregistré sur {{count}} extrudeur(s)',
       selectAtLeastOneExtruder: 'Sélectionnez un extrudeur',
       selectAtLeastOneExtruder: 'Sélectionnez un extrudeur',
+      selectFilament: 'Sélectionnez d’abord un filament',
+      filamentNotResolvable: 'Aucun identifiant de filament Bambu pour {{name}} — l’imprimante ne peut pas enregistrer de profil',
       profileDeleted: 'Profil K supprimé',
       profileDeleted: 'Profil K supprimé',
       profilesDeleted: '{{count}} profils supprimés',
       profilesDeleted: '{{count}} profils supprimés',
       exportedProfiles: '{{count}} profils exportés',
       exportedProfiles: '{{count}} profils exportés',

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

@@ -5023,7 +5023,15 @@ export default {
       kValueHelp: 'Intervallo tipico: 0.01 - 0.06 per PLA, 0.02 - 0.10 per PETG',
       kValueHelp: 'Intervallo tipico: 0.01 - 0.06 per PLA, 0.02 - 0.10 per PETG',
       filament: 'Filamento',
       filament: 'Filamento',
       selectFilament: 'Seleziona filamento...',
       selectFilament: 'Seleziona filamento...',
-      noFilamentsHelp: 'Nessun filamento trovato. Crea prima un K-profile in Bambu Studio.',
+      source: {
+        local: 'Importato',
+        orcaCloud: 'Orca Cloud',
+        bambuCloud: 'Bambu Cloud',
+        builtin: 'Integrato',
+      },
+      noFilamentsHelp: 'Nessun filamento disponibile. Accedi a Bambu Cloud o importa i preset da Profili → Profili locali.',
+      searchFilaments: 'Cerca filamenti...',
+      noFilamentMatches: 'Nessun filamento corrisponde alla ricerca',
       flowType: 'Tipo flow',
       flowType: 'Tipo flow',
       highFlow: 'Alto flusso',
       highFlow: 'Alto flusso',
       standard: 'Standard',
       standard: 'Standard',
@@ -5056,6 +5064,8 @@ export default {
       profileSaved: 'K-profile salvato',
       profileSaved: 'K-profile salvato',
       profilesSaved: 'K-profile salvato su {{count}} estrusori',
       profilesSaved: 'K-profile salvato su {{count}} estrusori',
       selectAtLeastOneExtruder: 'Seleziona almeno un estrusore',
       selectAtLeastOneExtruder: 'Seleziona almeno un estrusore',
+      selectFilament: 'Seleziona prima un filamento',
+      filamentNotResolvable: 'Nessun ID filamento Bambu per {{name}}: la stampante non può salvare un profilo',
       profileDeleted: 'K-profile eliminato',
       profileDeleted: 'K-profile eliminato',
       profilesDeleted: 'Eliminati {{count}} profili',
       profilesDeleted: 'Eliminati {{count}} profili',
       exportedProfiles: 'Esportati {{count}} profili',
       exportedProfiles: 'Esportati {{count}} profili',

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

@@ -5035,7 +5035,15 @@ export default {
       kValueHelp: '一般的な範囲: PLA 0.01〜0.06、PETG 0.02〜0.10',
       kValueHelp: '一般的な範囲: PLA 0.01〜0.06、PETG 0.02〜0.10',
       filament: 'フィラメント',
       filament: 'フィラメント',
       selectFilament: 'フィラメントを選択...',
       selectFilament: 'フィラメントを選択...',
-      noFilamentsHelp: 'フィラメントが見つかりません。Bambu Studioでまずプロファイルを作成してください。',
+      source: {
+        local: 'インポート済み',
+        orcaCloud: 'Orca Cloud',
+        bambuCloud: 'Bambu Cloud',
+        builtin: '内蔵',
+      },
+      noFilamentsHelp: '利用できるフィラメントがありません。Bambu Cloud にログインするか、プロファイル → ローカルプロファイル でプリセットをインポートしてください。',
+      searchFilaments: 'フィラメントを検索...',
+      noFilamentMatches: '検索に一致するフィラメントはありません',
       flowType: 'フロータイプ',
       flowType: 'フロータイプ',
       highFlow: 'ハイフロー',
       highFlow: 'ハイフロー',
       standard: 'スタンダード',
       standard: 'スタンダード',
@@ -5068,6 +5076,8 @@ export default {
       profileSaved: 'Kプロファイルを保存しました',
       profileSaved: 'Kプロファイルを保存しました',
       profilesSaved: 'Kプロファイルを{{count}}台のエクストルーダーに保存しました',
       profilesSaved: 'Kプロファイルを{{count}}台のエクストルーダーに保存しました',
       selectAtLeastOneExtruder: 'エクストルーダーを1つ以上選択してください',
       selectAtLeastOneExtruder: 'エクストルーダーを1つ以上選択してください',
+      selectFilament: '先にフィラメントを選択してください',
+      filamentNotResolvable: '{{name}} に対応する Bambu フィラメント ID がないため、プリンターはプロファイルを保存できません',
       profileDeleted: 'Kプロファイルを削除しました',
       profileDeleted: 'Kプロファイルを削除しました',
       profilesDeleted: '{{count}}件のプロファイルを削除しました',
       profilesDeleted: '{{count}}件のプロファイルを削除しました',
       exportedProfiles: '{{count}}件のプロファイルをエクスポートしました',
       exportedProfiles: '{{count}}件のプロファイルをエクスポートしました',

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

@@ -4778,7 +4778,15 @@ export default {
       kValueHelp: '일반 범위: PLA 0.01~0.06, PETG 0.02~0.10',
       kValueHelp: '일반 범위: PLA 0.01~0.06, PETG 0.02~0.10',
       filament: '필라멘트',
       filament: '필라멘트',
       selectFilament: '필라멘트 선택...',
       selectFilament: '필라멘트 선택...',
-      noFilamentsHelp: '필라멘트를 찾을 수 없습니다. 먼저 Bambu Studio에서 K-프로필을 만드세요.',
+      source: {
+        local: '가져온 것',
+        orcaCloud: 'Orca Cloud',
+        bambuCloud: 'Bambu Cloud',
+        builtin: '기본 제공',
+      },
+      noFilamentsHelp: '사용할 수 있는 필라먼트가 없습니다. Bambu Cloud에 로그인하거나 프로파일 → 로컬 프로파일에서 프리셋을 가져오세요.',
+      searchFilaments: '필라먼트 검색...',
+      noFilamentMatches: '검색과 일치하는 필라먼트가 없습니다',
       flowType: '유량 유형',
       flowType: '유량 유형',
       highFlow: '고유량',
       highFlow: '고유량',
       standard: '표준',
       standard: '표준',
@@ -4808,6 +4816,8 @@ export default {
       profileSaved: 'K-프로필 저장됨',
       profileSaved: 'K-프로필 저장됨',
       profilesSaved: '{{count}}개 압출기에 K-프로필 저장됨',
       profilesSaved: '{{count}}개 압출기에 K-프로필 저장됨',
       selectAtLeastOneExtruder: '적어도 하나의 압출기를 선택해 주세요',
       selectAtLeastOneExtruder: '적어도 하나의 압출기를 선택해 주세요',
+      selectFilament: '먼저 필라먼트를 선택하세요',
+      filamentNotResolvable: '{{name}}에 해당하는 Bambu 필라먼트 ID가 없어 프린터가 프로파일을 저장할 수 없습니다',
       profileDeleted: 'K-프로필 삭제됨',
       profileDeleted: 'K-프로필 삭제됨',
       profilesDeleted: '{{count}}개 프로필 삭제됨',
       profilesDeleted: '{{count}}개 프로필 삭제됨',
       exportedProfiles: '{{count}}개 프로필 내보냄',
       exportedProfiles: '{{count}}개 프로필 내보냄',

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

@@ -5023,7 +5023,15 @@ export default {
       kValueHelp: 'Faixa típica: 0.01 - 0.06 para PLA, 0.02 - 0.10 para PETG',
       kValueHelp: 'Faixa típica: 0.01 - 0.06 para PLA, 0.02 - 0.10 para PETG',
       filament: 'Filamento',
       filament: 'Filamento',
       selectFilament: 'Selecionar filamento...',
       selectFilament: 'Selecionar filamento...',
-      noFilamentsHelp: 'Nenhum filamento encontrado. Crie um K-profile no Bambu Studio primeiro.',
+      source: {
+        local: 'Importado',
+        orcaCloud: 'Orca Cloud',
+        bambuCloud: 'Bambu Cloud',
+        builtin: 'Integrado',
+      },
+      noFilamentsHelp: 'Nenhum filamento disponível. Entre na Bambu Cloud ou importe predefinições em Perfis → Perfis Locais.',
+      searchFilaments: 'Buscar filamentos...',
+      noFilamentMatches: 'Nenhum filamento corresponde a essa busca',
       flowType: 'Tipo de Fluxo',
       flowType: 'Tipo de Fluxo',
       highFlow: 'Alto Fluxo',
       highFlow: 'Alto Fluxo',
       standard: 'Padrão',
       standard: 'Padrão',
@@ -5056,6 +5064,8 @@ export default {
       profileSaved: 'K-profile salvo',
       profileSaved: 'K-profile salvo',
       profilesSaved: 'K-profile salvo em {{count}} extrusores',
       profilesSaved: 'K-profile salvo em {{count}} extrusores',
       selectAtLeastOneExtruder: 'Por favor, selecione pelo menos um extrusor',
       selectAtLeastOneExtruder: 'Por favor, selecione pelo menos um extrusor',
+      selectFilament: 'Selecione um filamento primeiro',
+      filamentNotResolvable: 'Sem ID de filamento Bambu para {{name}} — a impressora não consegue armazenar um perfil',
       profileDeleted: 'K-profile excluído',
       profileDeleted: 'K-profile excluído',
       profilesDeleted: '{{count}} perfis excluídos',
       profilesDeleted: '{{count}} perfis excluídos',
       exportedProfiles: '{{count}} perfis exportados',
       exportedProfiles: '{{count}} perfis exportados',

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

@@ -4766,7 +4766,15 @@ export default {
       kValueHelp: "Типичный диапазон: 0,01–0,06 для PLA, 0,02–0,10 для PETG",
       kValueHelp: "Типичный диапазон: 0,01–0,06 для PLA, 0,02–0,10 для PETG",
       filament: "Филамент",
       filament: "Филамент",
       selectFilament: "Выберите филамент...",
       selectFilament: "Выберите филамент...",
-      noFilamentsHelp: "Филаменты не найдены. Сначала создайте K-профиль в Bambu Studio.",
+      source: {
+        local: "Импортированные",
+        orcaCloud: "Orca Cloud",
+        bambuCloud: "Bambu Cloud",
+        builtin: "Встроенный",
+      },
+      noFilamentsHelp: "Нет доступных филаментов. Войдите в Bambu Cloud или импортируйте пресеты в разделе Профили → Локальные профили.",
+      searchFilaments: "Поиск филаментов...",
+      noFilamentMatches: "Нет филаментов, соответствующих запросу",
       flowType: "Тип потока",
       flowType: "Тип потока",
       highFlow: "Высокопоточный",
       highFlow: "Высокопоточный",
       standard: "Стандартный",
       standard: "Стандартный",
@@ -4796,6 +4804,8 @@ export default {
       profileSaved: "K-профиль сохранён",
       profileSaved: "K-профиль сохранён",
       profilesSaved: "K-профиль сохранён для {{count}} экструдеров",
       profilesSaved: "K-профиль сохранён для {{count}} экструдеров",
       selectAtLeastOneExtruder: "Выберите хотя бы один экструдер",
       selectAtLeastOneExtruder: "Выберите хотя бы один экструдер",
+      selectFilament: "Сначала выберите филамент",
+      filamentNotResolvable: "Нет идентификатора филамента Bambu для {{name}} — принтер не сможет сохранить профиль",
       profileDeleted: "K-профиль удалён",
       profileDeleted: "K-профиль удалён",
       profilesDeleted: "Удалено профилей: {{count}}",
       profilesDeleted: "Удалено профилей: {{count}}",
       exportedProfiles: "Экспортировано профилей: {{count}}",
       exportedProfiles: "Экспортировано профилей: {{count}}",

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

@@ -5003,7 +5003,15 @@ export default {
       kValueHelp: 'Tipik aralık: PLA için 0.01 - 0.06, PETG için 0.02 - 0.10',
       kValueHelp: 'Tipik aralık: PLA için 0.01 - 0.06, PETG için 0.02 - 0.10',
       filament: 'Filament',
       filament: 'Filament',
       selectFilament: 'Filament seç...',
       selectFilament: 'Filament seç...',
-      noFilamentsHelp: 'Filament bulunamadı. Önce Bambu Studio\'da bir K-profili oluşturun.',
+      source: {
+        local: 'İçe aktarılmış',
+        orcaCloud: 'Orca Cloud',
+        bambuCloud: 'Bambu Cloud',
+        builtin: 'Yerleşik',
+      },
+      noFilamentsHelp: 'Kullanılabilir filament yok. Bambu Cloud’a giriş yapın veya Profiller → Yerel Profiller altından hızır ayarları içe aktarın.',
+      searchFilaments: 'Filament ara...',
+      noFilamentMatches: 'Bu aramayla eşleşen filament yok',
       flowType: 'Akış Türü',
       flowType: 'Akış Türü',
       highFlow: 'Yüksek Akış',
       highFlow: 'Yüksek Akış',
       standard: 'Standart',
       standard: 'Standart',
@@ -5033,6 +5041,8 @@ export default {
       profileSaved: 'K-profili kaydedildi',
       profileSaved: 'K-profili kaydedildi',
       profilesSaved: '{{count}} ekstrüdere K-profili kaydedildi',
       profilesSaved: '{{count}} ekstrüdere K-profili kaydedildi',
       selectAtLeastOneExtruder: 'Lütfen en az bir ekstrüder seçin',
       selectAtLeastOneExtruder: 'Lütfen en az bir ekstrüder seçin',
+      selectFilament: 'Önce bir filament seçin',
+      filamentNotResolvable: '{{name}} için Bambu filament kimliği yok — yazıcı bunun için profil saklayamaz',
       profileDeleted: 'K-profili silindi',
       profileDeleted: 'K-profili silindi',
       profilesDeleted: '{{count}} profil silindi',
       profilesDeleted: '{{count}} profil silindi',
       exportedProfiles: '{{count}} profil dışa aktarıldı',
       exportedProfiles: '{{count}} profil dışa aktarıldı',

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

@@ -5078,7 +5078,15 @@ export default {
       kValueHelp: "Типовий діапазон: 0,01–0,06 для PLA, 0,02–0,10 для PETG",
       kValueHelp: "Типовий діапазон: 0,01–0,06 для PLA, 0,02–0,10 для PETG",
       filament: "Філамент",
       filament: "Філамент",
       selectFilament: "Виберіть філамент...",
       selectFilament: "Виберіть філамент...",
-      noFilamentsHelp: "Філаменти не знайдено. Спочатку створіть K-профіль у Bambu Studio.",
+      source: {
+        local: "Імпортовані",
+        orcaCloud: "Orca Cloud",
+        bambuCloud: "Bambu Cloud",
+        builtin: "Вбудований",
+      },
+      noFilamentsHelp: "Немає доступних філаментів. Увійдіть у Bambu Cloud або імпортуйте пресети в розділі Профілі → Локальні профілі.",
+      searchFilaments: "Пошук філаментів...",
+      noFilamentMatches: "Немає філаментів, що відповідають запиту",
       flowType: "Тип потоку",
       flowType: "Тип потоку",
       highFlow: "Сопло з високим потоком",
       highFlow: "Сопло з високим потоком",
       standard: "Стандартний",
       standard: "Стандартний",
@@ -5111,6 +5119,8 @@ export default {
       profileSaved: "K-профіль збережено",
       profileSaved: "K-профіль збережено",
       profilesSaved: "K-профіль збережено в екструдери {{count}}.",
       profilesSaved: "K-профіль збережено в екструдери {{count}}.",
       selectAtLeastOneExtruder: "Виберіть принаймні один екструдер",
       selectAtLeastOneExtruder: "Виберіть принаймні один екструдер",
+      selectFilament: "Спочатку виберіть філамент",
+      filamentNotResolvable: "Немає ідентифікатора філаменту Bambu для {{name}} — принтер не зможе зберегти профіль",
       profileDeleted: "K-профіль видалено",
       profileDeleted: "K-профіль видалено",
       profilesDeleted: "Видалені профілі {{count}}.",
       profilesDeleted: "Видалені профілі {{count}}.",
       exportedProfiles: "Експортовані профілі {{count}}.",
       exportedProfiles: "Експортовані профілі {{count}}.",

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

@@ -5023,7 +5023,15 @@ export default {
       kValueHelp: '典型范围:PLA 0.01 - 0.06,PETG 0.02 - 0.10',
       kValueHelp: '典型范围:PLA 0.01 - 0.06,PETG 0.02 - 0.10',
       filament: '耗材',
       filament: '耗材',
       selectFilament: '选择耗材...',
       selectFilament: '选择耗材...',
-      noFilamentsHelp: '未找到耗材。请先在 Bambu Studio 中创建 K 值配置。',
+      source: {
+        local: '已导入',
+        orcaCloud: 'Orca Cloud',
+        bambuCloud: 'Bambu Cloud',
+        builtin: '内置',
+      },
+      noFilamentsHelp: '没有可用的耗材。请登录 Bambu Cloud,或在“配置 → 本地配置”中导入预设。',
+      searchFilaments: '搜索耗材...',
+      noFilamentMatches: '没有符合搜索条件的耗材',
       flowType: '流量类型',
       flowType: '流量类型',
       highFlow: '高流量',
       highFlow: '高流量',
       standard: '标准',
       standard: '标准',
@@ -5056,6 +5064,8 @@ export default {
       profileSaved: 'K 值配置已保存',
       profileSaved: 'K 值配置已保存',
       profilesSaved: 'K 值配置已保存到 {{count}} 个挤出机',
       profilesSaved: 'K 值配置已保存到 {{count}} 个挤出机',
       selectAtLeastOneExtruder: '请至少选择一个挤出机',
       selectAtLeastOneExtruder: '请至少选择一个挤出机',
+      selectFilament: '请先选择耗材',
+      filamentNotResolvable: '没有与 {{name}} 对应的 Bambu 耗材 ID,打印机无法保存该配置',
       profileDeleted: 'K 值配置已删除',
       profileDeleted: 'K 值配置已删除',
       profilesDeleted: '已删除 {{count}} 个配置',
       profilesDeleted: '已删除 {{count}} 个配置',
       exportedProfiles: '已导出 {{count}} 个配置',
       exportedProfiles: '已导出 {{count}} 个配置',

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

@@ -5023,7 +5023,15 @@ export default {
       kValueHelp: '典型範圍:PLA 0.01 - 0.06,PETG 0.02 - 0.10',
       kValueHelp: '典型範圍:PLA 0.01 - 0.06,PETG 0.02 - 0.10',
       filament: '耗材',
       filament: '耗材',
       selectFilament: '選擇耗材...',
       selectFilament: '選擇耗材...',
-      noFilamentsHelp: '未找到耗材。請先在 Bambu Studio 中建立 K 值設定。',
+      source: {
+        local: '已匯入',
+        orcaCloud: 'Orca Cloud',
+        bambuCloud: 'Bambu Cloud',
+        builtin: '內建',
+      },
+      noFilamentsHelp: '沒有可用的耗材。請登入 Bambu Cloud,或在「設定檔 → 本地設定檔」中匯入預設。',
+      searchFilaments: '搜尋耗材...',
+      noFilamentMatches: '沒有符合搜尋條件的耗材',
       flowType: '流量類型',
       flowType: '流量類型',
       highFlow: '高流量',
       highFlow: '高流量',
       standard: '標準',
       standard: '標準',
@@ -5056,6 +5064,8 @@ export default {
       profileSaved: 'K 值設定已儲存',
       profileSaved: 'K 值設定已儲存',
       profilesSaved: 'K 值設定已儲存到 {{count}} 個擠出機',
       profilesSaved: 'K 值設定已儲存到 {{count}} 個擠出機',
       selectAtLeastOneExtruder: '請至少選擇一個擠出機',
       selectAtLeastOneExtruder: '請至少選擇一個擠出機',
+      selectFilament: '請先選擇耗材',
+      filamentNotResolvable: '沒有與 {{name}} 對應的 Bambu 耗材 ID,印表機無法儲存該設定檔',
       profileDeleted: 'K 值設定已刪除',
       profileDeleted: 'K 值設定已刪除',
       profilesDeleted: '已刪除 {{count}} 個設定',
       profilesDeleted: '已刪除 {{count}} 個設定',
       exportedProfiles: '已匯出 {{count}} 個設定',
       exportedProfiles: '已匯出 {{count}} 個設定',

+ 120 - 78
frontend/src/pages/SettingsPage.tsx

@@ -882,6 +882,16 @@ export function SettingsPage() {
   const pendingGcodeSnippetsRef = useRef<string | null>(null);
   const pendingGcodeSnippetsRef = useRef<string | null>(null);
   const isSavingRef = useRef(false);
   const isSavingRef = useRef(false);
   const isInitialLoadRef = useRef(true);
   const isInitialLoadRef = useRef(true);
+  // #2716: the last server snapshot this page reconciled with. It is what
+  // makes "the user edited this field" a well-defined question: a field where
+  // localSettings still equals the baseline has not been touched since that
+  // reconcile, so a newer server value can be taken instead of the page's stale
+  // copy being written back over it. Before this the debounced save diffed
+  // against the live ['settings'] cache, which made a value changed on the
+  // server -- another tab, another user, a backup restore, a refetch driven by
+  // any of the ~30 other observers of the key -- indistinguishable from an edit,
+  // and reverted it a few hundred ms later with no user interaction at all.
+  const serverBaselineRef = useRef<AppSettings | null>(null);
 
 
   // Sync local state when settings load
   // Sync local state when settings load
   useEffect(() => {
   useEffect(() => {
@@ -891,6 +901,9 @@ export function SettingsPage() {
         ...settings,
         ...settings,
         external_url: settings.external_url || window.location.origin,
         external_url: settings.external_url || window.location.origin,
       };
       };
+      // The baseline is the raw server row, not this adjusted copy: a detected
+      // external_url has to read as a local change so it still gets persisted.
+      serverBaselineRef.current = settings;
       setLocalSettings(settingsWithExternalUrl);
       setLocalSettings(settingsWithExternalUrl);
       // Mark initial load complete after a short delay
       // Mark initial load complete after a short delay
       setTimeout(() => {
       setTimeout(() => {
@@ -899,9 +912,37 @@ export function SettingsPage() {
     }
     }
   }, [settings, localSettings]);
   }, [settings, localSettings]);
 
 
+  // #2716: reconcile a moved server snapshot into the local copy. A field the
+  // user has not touched since the last reconcile takes the server's value; a
+  // field they have edited keeps theirs and is saved over it by the debounced
+  // effect below, so the newer of the two writes wins either way. Declared
+  // before that effect so the baseline has already moved by the time it
+  // computes its diff in the same commit.
+  useEffect(() => {
+    const baseline = serverBaselineRef.current;
+    if (!settings || !localSettings || !baseline || settings === baseline) {
+      return;
+    }
+    const adopted: Record<string, unknown> = {};
+    for (const key of Object.keys(settings) as (keyof AppSettings)[]) {
+      if (settings[key] !== baseline[key] && localSettings[key] === baseline[key]) {
+        adopted[key] = settings[key];
+      }
+    }
+    serverBaselineRef.current = settings;
+    if (Object.keys(adopted).length > 0) {
+      setLocalSettings(prev => (prev ? { ...prev, ...(adopted as Partial<AppSettings>) } : prev));
+    }
+  }, [settings, localSettings]);
+
   const updateMutation = useMutation({
   const updateMutation = useMutation({
     mutationFn: api.updateSettings,
     mutationFn: api.updateSettings,
     onSuccess: (data) => {
     onSuccess: (data) => {
+      // #2716: the row we just saved becomes the snapshot to diff against.
+      // The setQueryData below would normally get the effect above to do this,
+      // but only if react-query hands back a new object; setting it here means
+      // the baseline never lags behind a save regardless.
+      serverBaselineRef.current = data;
       queryClient.setQueryData(['settings'], data);
       queryClient.setQueryData(['settings'], data);
       // Don't call setLocalSettings(data) here — it would overwrite in-progress
       // Don't call setLocalSettings(data) here — it would overwrite in-progress
       // user input (e.g. typing a hostname) with the stale saved snapshot,
       // user input (e.g. typing a hostname) with the stale saved snapshot,
@@ -942,7 +983,8 @@ export function SettingsPage() {
   // Debounced auto-save when localSettings change
   // Debounced auto-save when localSettings change
   useEffect(() => {
   useEffect(() => {
     // Skip if initial load or no settings
     // Skip if initial load or no settings
-    if (isInitialLoadRef.current || !localSettings || !settings) {
+    const baseline = serverBaselineRef.current;
+    if (isInitialLoadRef.current || !localSettings || !settings || !baseline) {
       return;
       return;
     }
     }
 
 
@@ -956,83 +998,83 @@ export function SettingsPage() {
 
 
     // Check if there are actual changes
     // Check if there are actual changes
     const hasChanges =
     const hasChanges =
-      settings.auto_archive !== localSettings.auto_archive ||
-      settings.save_thumbnails !== localSettings.save_thumbnails ||
-      settings.capture_finish_photo !== localSettings.capture_finish_photo ||
-      (settings.finish_photo_restore_plate ?? true) !== (localSettings.finish_photo_restore_plate ?? true) ||
-      settings.default_filament_cost !== localSettings.default_filament_cost ||
-      settings.currency !== localSettings.currency ||
-      settings.energy_cost_per_kwh !== localSettings.energy_cost_per_kwh ||
-      settings.energy_tracking_mode !== localSettings.energy_tracking_mode ||
-      settings.check_updates !== localSettings.check_updates ||
-      (settings.check_printer_firmware ?? true) !== (localSettings.check_printer_firmware ?? true) ||
-      (settings.include_beta_updates ?? false) !== (localSettings.include_beta_updates ?? false) ||
-      (settings.local_login_enabled ?? true) !== (localSettings.local_login_enabled ?? true) ||
-      settings.notification_language !== localSettings.notification_language ||
-      (settings.bed_cooled_threshold ?? 35) !== (localSettings.bed_cooled_threshold ?? 35) ||
-      settings.ams_humidity_good !== localSettings.ams_humidity_good ||
-      settings.ams_humidity_fair !== localSettings.ams_humidity_fair ||
-      settings.ams_temp_good !== localSettings.ams_temp_good ||
-      settings.ams_temp_fair !== localSettings.ams_temp_fair ||
-      settings.ams_history_retention_days !== localSettings.ams_history_retention_days ||
-      settings.disable_filament_warnings !== localSettings.disable_filament_warnings ||
-      settings.prefer_lowest_filament !== localSettings.prefer_lowest_filament ||
-      (settings.queue_drying_enabled ?? false) !== (localSettings.queue_drying_enabled ?? false) ||
-      (settings.queue_drying_block ?? false) !== (localSettings.queue_drying_block ?? false) ||
-      (settings.ambient_drying_enabled ?? false) !== (localSettings.ambient_drying_enabled ?? false) ||
-      (settings.print_drying_enabled ?? false) !== (localSettings.print_drying_enabled ?? false) ||
-      (settings.drying_presets ?? '') !== (localSettings.drying_presets ?? '') ||
-      (settings.ams_humidity_thresholds ?? '') !== (localSettings.ams_humidity_thresholds ?? '') ||
-      settings.per_printer_mapping_expanded !== localSettings.per_printer_mapping_expanded ||
-      settings.date_format !== localSettings.date_format ||
-      settings.time_format !== localSettings.time_format ||
-      settings.default_printer_id !== localSettings.default_printer_id ||
-      settings.ftp_retry_enabled !== localSettings.ftp_retry_enabled ||
-      settings.ftp_retry_count !== localSettings.ftp_retry_count ||
-      settings.ftp_retry_delay !== localSettings.ftp_retry_delay ||
-      settings.ftp_timeout !== localSettings.ftp_timeout ||
-      settings.mqtt_enabled !== localSettings.mqtt_enabled ||
-      settings.mqtt_broker !== localSettings.mqtt_broker ||
-      settings.mqtt_port !== localSettings.mqtt_port ||
-      settings.mqtt_username !== localSettings.mqtt_username ||
-      settings.mqtt_password !== localSettings.mqtt_password ||
-      settings.mqtt_topic_prefix !== localSettings.mqtt_topic_prefix ||
-      settings.mqtt_use_tls !== localSettings.mqtt_use_tls ||
-      settings.external_url !== localSettings.external_url ||
-      settings.ha_enabled !== localSettings.ha_enabled ||
-      settings.ha_url !== localSettings.ha_url ||
-      settings.ha_token !== localSettings.ha_token ||
-      (settings.library_archive_mode ?? 'ask') !== (localSettings.library_archive_mode ?? 'ask') ||
-      Number(settings.library_disk_warning_gb ?? 5) !== Number(localSettings.library_disk_warning_gb ?? 5) ||
-      (settings.camera_view_mode ?? 'window') !== (localSettings.camera_view_mode ?? 'window') ||
-      (settings.preferred_slicer ?? 'bambu_studio') !== (localSettings.preferred_slicer ?? 'bambu_studio') ||
-      (settings.open_in_slicer ?? null) !== (localSettings.open_in_slicer ?? null) ||
-      (settings.use_slicer_api ?? false) !== (localSettings.use_slicer_api ?? false) ||
-      (settings.orcaslicer_api_url ?? '') !== (localSettings.orcaslicer_api_url ?? '') ||
-      (settings.slicer_stall_timeout_minutes ?? 15) !== (localSettings.slicer_stall_timeout_minutes ?? 15) ||
-      (settings.bambu_studio_api_url ?? '') !== (localSettings.bambu_studio_api_url ?? '') ||
-      settings.prometheus_enabled !== localSettings.prometheus_enabled ||
-      settings.prometheus_token !== localSettings.prometheus_token ||
-      (settings.user_notifications_enabled ?? true) !== (localSettings.user_notifications_enabled ?? true) ||
-      (settings.default_bed_levelling ?? 'auto') !== (localSettings.default_bed_levelling ?? 'auto') ||
-      (settings.default_flow_cali ?? 'auto') !== (localSettings.default_flow_cali ?? 'auto') ||
-      (settings.default_vibration_cali ?? true) !== (localSettings.default_vibration_cali ?? true) ||
-      (settings.default_layer_inspect ?? false) !== (localSettings.default_layer_inspect ?? false) ||
-      (settings.default_timelapse ?? false) !== (localSettings.default_timelapse ?? false) ||
-      (settings.default_nozzle_offset_cali ?? 'auto') !== (localSettings.default_nozzle_offset_cali ?? 'auto') ||
-      (settings.stagger_group_size ?? 2) !== (localSettings.stagger_group_size ?? 2) ||
-      (settings.stagger_interval_minutes ?? 5) !== (localSettings.stagger_interval_minutes ?? 5) ||
-      (settings.require_plate_clear ?? false) !== (localSettings.require_plate_clear ?? false) ||
-      (settings.queue_max_concurrent_uploads ?? 4) !== (localSettings.queue_max_concurrent_uploads ?? 4) ||
-      (settings.preheat_enabled ?? false) !== (localSettings.preheat_enabled ?? false) ||
-      (settings.preheat_filament_targets ?? '') !== (localSettings.preheat_filament_targets ?? '') ||
-      (settings.preheat_max_wait_seconds ?? 900) !== (localSettings.preheat_max_wait_seconds ?? 900) ||
-      (settings.preheat_soak_seconds ?? 300) !== (localSettings.preheat_soak_seconds ?? 300) ||
-      (settings.nozzle_temp_presets ?? '') !== (localSettings.nozzle_temp_presets ?? '') ||
-      (settings.bed_temp_presets ?? '') !== (localSettings.bed_temp_presets ?? '') ||
-      (settings.chamber_temp_presets ?? '') !== (localSettings.chamber_temp_presets ?? '') ||
-      (settings.fan_speed_presets ?? '') !== (localSettings.fan_speed_presets ?? '') ||
-      (settings.session_max_hours ?? 24) !== (localSettings.session_max_hours ?? 24);
+      baseline.auto_archive !== localSettings.auto_archive ||
+      baseline.save_thumbnails !== localSettings.save_thumbnails ||
+      baseline.capture_finish_photo !== localSettings.capture_finish_photo ||
+      (baseline.finish_photo_restore_plate ?? true) !== (localSettings.finish_photo_restore_plate ?? true) ||
+      baseline.default_filament_cost !== localSettings.default_filament_cost ||
+      baseline.currency !== localSettings.currency ||
+      baseline.energy_cost_per_kwh !== localSettings.energy_cost_per_kwh ||
+      baseline.energy_tracking_mode !== localSettings.energy_tracking_mode ||
+      baseline.check_updates !== localSettings.check_updates ||
+      (baseline.check_printer_firmware ?? true) !== (localSettings.check_printer_firmware ?? true) ||
+      (baseline.include_beta_updates ?? false) !== (localSettings.include_beta_updates ?? false) ||
+      (baseline.local_login_enabled ?? true) !== (localSettings.local_login_enabled ?? true) ||
+      baseline.notification_language !== localSettings.notification_language ||
+      (baseline.bed_cooled_threshold ?? 35) !== (localSettings.bed_cooled_threshold ?? 35) ||
+      baseline.ams_humidity_good !== localSettings.ams_humidity_good ||
+      baseline.ams_humidity_fair !== localSettings.ams_humidity_fair ||
+      baseline.ams_temp_good !== localSettings.ams_temp_good ||
+      baseline.ams_temp_fair !== localSettings.ams_temp_fair ||
+      baseline.ams_history_retention_days !== localSettings.ams_history_retention_days ||
+      baseline.disable_filament_warnings !== localSettings.disable_filament_warnings ||
+      baseline.prefer_lowest_filament !== localSettings.prefer_lowest_filament ||
+      (baseline.queue_drying_enabled ?? false) !== (localSettings.queue_drying_enabled ?? false) ||
+      (baseline.queue_drying_block ?? false) !== (localSettings.queue_drying_block ?? false) ||
+      (baseline.ambient_drying_enabled ?? false) !== (localSettings.ambient_drying_enabled ?? false) ||
+      (baseline.print_drying_enabled ?? false) !== (localSettings.print_drying_enabled ?? false) ||
+      (baseline.drying_presets ?? '') !== (localSettings.drying_presets ?? '') ||
+      (baseline.ams_humidity_thresholds ?? '') !== (localSettings.ams_humidity_thresholds ?? '') ||
+      baseline.per_printer_mapping_expanded !== localSettings.per_printer_mapping_expanded ||
+      baseline.date_format !== localSettings.date_format ||
+      baseline.time_format !== localSettings.time_format ||
+      baseline.default_printer_id !== localSettings.default_printer_id ||
+      baseline.ftp_retry_enabled !== localSettings.ftp_retry_enabled ||
+      baseline.ftp_retry_count !== localSettings.ftp_retry_count ||
+      baseline.ftp_retry_delay !== localSettings.ftp_retry_delay ||
+      baseline.ftp_timeout !== localSettings.ftp_timeout ||
+      baseline.mqtt_enabled !== localSettings.mqtt_enabled ||
+      baseline.mqtt_broker !== localSettings.mqtt_broker ||
+      baseline.mqtt_port !== localSettings.mqtt_port ||
+      baseline.mqtt_username !== localSettings.mqtt_username ||
+      baseline.mqtt_password !== localSettings.mqtt_password ||
+      baseline.mqtt_topic_prefix !== localSettings.mqtt_topic_prefix ||
+      baseline.mqtt_use_tls !== localSettings.mqtt_use_tls ||
+      baseline.external_url !== localSettings.external_url ||
+      baseline.ha_enabled !== localSettings.ha_enabled ||
+      baseline.ha_url !== localSettings.ha_url ||
+      baseline.ha_token !== localSettings.ha_token ||
+      (baseline.library_archive_mode ?? 'ask') !== (localSettings.library_archive_mode ?? 'ask') ||
+      Number(baseline.library_disk_warning_gb ?? 5) !== Number(localSettings.library_disk_warning_gb ?? 5) ||
+      (baseline.camera_view_mode ?? 'window') !== (localSettings.camera_view_mode ?? 'window') ||
+      (baseline.preferred_slicer ?? 'bambu_studio') !== (localSettings.preferred_slicer ?? 'bambu_studio') ||
+      (baseline.open_in_slicer ?? null) !== (localSettings.open_in_slicer ?? null) ||
+      (baseline.use_slicer_api ?? false) !== (localSettings.use_slicer_api ?? false) ||
+      (baseline.orcaslicer_api_url ?? '') !== (localSettings.orcaslicer_api_url ?? '') ||
+      (baseline.slicer_stall_timeout_minutes ?? 15) !== (localSettings.slicer_stall_timeout_minutes ?? 15) ||
+      (baseline.bambu_studio_api_url ?? '') !== (localSettings.bambu_studio_api_url ?? '') ||
+      baseline.prometheus_enabled !== localSettings.prometheus_enabled ||
+      baseline.prometheus_token !== localSettings.prometheus_token ||
+      (baseline.user_notifications_enabled ?? true) !== (localSettings.user_notifications_enabled ?? true) ||
+      (baseline.default_bed_levelling ?? 'auto') !== (localSettings.default_bed_levelling ?? 'auto') ||
+      (baseline.default_flow_cali ?? 'auto') !== (localSettings.default_flow_cali ?? 'auto') ||
+      (baseline.default_vibration_cali ?? true) !== (localSettings.default_vibration_cali ?? true) ||
+      (baseline.default_layer_inspect ?? false) !== (localSettings.default_layer_inspect ?? false) ||
+      (baseline.default_timelapse ?? false) !== (localSettings.default_timelapse ?? false) ||
+      (baseline.default_nozzle_offset_cali ?? 'auto') !== (localSettings.default_nozzle_offset_cali ?? 'auto') ||
+      (baseline.stagger_group_size ?? 2) !== (localSettings.stagger_group_size ?? 2) ||
+      (baseline.stagger_interval_minutes ?? 5) !== (localSettings.stagger_interval_minutes ?? 5) ||
+      (baseline.require_plate_clear ?? false) !== (localSettings.require_plate_clear ?? false) ||
+      (baseline.queue_max_concurrent_uploads ?? 4) !== (localSettings.queue_max_concurrent_uploads ?? 4) ||
+      (baseline.preheat_enabled ?? false) !== (localSettings.preheat_enabled ?? false) ||
+      (baseline.preheat_filament_targets ?? '') !== (localSettings.preheat_filament_targets ?? '') ||
+      (baseline.preheat_max_wait_seconds ?? 900) !== (localSettings.preheat_max_wait_seconds ?? 900) ||
+      (baseline.preheat_soak_seconds ?? 300) !== (localSettings.preheat_soak_seconds ?? 300) ||
+      (baseline.nozzle_temp_presets ?? '') !== (localSettings.nozzle_temp_presets ?? '') ||
+      (baseline.bed_temp_presets ?? '') !== (localSettings.bed_temp_presets ?? '') ||
+      (baseline.chamber_temp_presets ?? '') !== (localSettings.chamber_temp_presets ?? '') ||
+      (baseline.fan_speed_presets ?? '') !== (localSettings.fan_speed_presets ?? '') ||
+      (baseline.session_max_hours ?? 24) !== (localSettings.session_max_hours ?? 24);
 
 
     if (!hasChanges) {
     if (!hasChanges) {
       return;
       return;

+ 247 - 0
frontend/src/utils/filamentPresets.ts

@@ -0,0 +1,247 @@
+// Tiered filament-preset list, shared by every picker that has to offer "all
+// the filaments this install knows about".
+//
+// Lookup order is fixed across the app: local imported > Orca Cloud > Bambu
+// Cloud > hardcoded built-in table. It mirrors ConfigureAmsSlotModal's picker
+// and SliceModal's tier groups, so a filament the user sees in one place is
+// named and ranked the same way in the others.
+//
+// The built-in table is the floor, not an equal source: it is a static list
+// compiled into the backend, so it is the only tier that can never be empty
+// and the only one that works with no cloud account and nothing imported.
+
+import type { BuiltinFilament, LocalPreset, OrcaProfileMeta, SlicerSetting } from '../api/client';
+import { parsePresetName, toFilamentId } from '../components/spool-form/utils';
+
+export type FilamentPresetSource = 'local' | 'orca_cloud' | 'cloud' | 'builtin';
+
+export interface FilamentPresetOption {
+  /** Opaque, source-prefixed handle: ``local_12`` / ``orca_<uuid>`` / a Bambu
+   *  cloud setting_id / ``builtin_GFA00``. Prefixes match the convention
+   *  ConfigureAmsSlotModal already uses so the two can share resolvers. */
+  id: string;
+  name: string;
+  source: FilamentPresetSource;
+  /** The Bambu filament id this preset resolves to, when it is derivable
+   *  without a network round trip. Empty for Bambu Cloud *user* presets, whose
+   *  real filament_id only exists in the cloud detail — see
+   *  resolveFilamentId. */
+  filamentId: string;
+  /** Material as the preset itself declares it, used to derive a generic
+   *  filament id for tiers that carry no Bambu id of their own. */
+  filamentType: string;
+}
+
+export interface FilamentPresetSources {
+  localPresets?: LocalPreset[];
+  orcaProfiles?: OrcaProfileMeta[];
+  cloudSettings?: SlicerSetting[];
+  builtinFilaments?: BuiltinFilament[];
+}
+
+/** Generic Bambu filament ids by material. Local and Orca Cloud presets carry
+ *  no Bambu filament id, but the printer's calibration table is indexed by
+ *  one, so the closest generic is what a calibration for such a preset has to
+ *  be filed under. Same table and same fallback chain as the AMS slot
+ *  configure flow — the two must agree or a profile created here won't match
+ *  the slot configured there. */
+const GENERIC_FILAMENT_IDS: Record<string, string> = {
+  'PLA': 'GFL99', 'PLA-CF': 'GFL98', 'PLA SILK': 'GFL96', 'PLA HIGH SPEED': 'GFL95',
+  'PETG': 'GFG99', 'PETG HF': 'GFG96', 'PETG-CF': 'GFG98', 'PCTG': 'GFG97',
+  'ABS': 'GFB99', 'ASA': 'GFB98',
+  'PC': 'GFC99',
+  'PA': 'GFN99', 'PA-CF': 'GFN98', 'NYLON': 'GFN99',
+  'TPU': 'GFU99',
+  'PVA': 'GFS99', 'HIPS': 'GFS98',
+  'PE': 'GFP99', 'PP': 'GFP97',
+};
+
+/** Resolve a material string to a generic Bambu filament id, trying the exact
+ *  spelling before progressively stripping the suffixes slicer presets add
+ *  ("-CF", "+", " HF"). Returns '' when nothing matches, which callers must
+ *  treat as "not calibratable" rather than substituting a default — filing a
+ *  calibration under the wrong material is worse than refusing. */
+export function genericFilamentIdForMaterial(material: string | null | undefined): string {
+  const m = (material || '').toUpperCase().trim();
+  if (!m) return '';
+  return GENERIC_FILAMENT_IDS[m]
+    || GENERIC_FILAMENT_IDS[m.replace(/[-\s]?CF$/, '')]
+    || GENERIC_FILAMENT_IDS[m.replace(/\+$/, '')]
+    || GENERIC_FILAMENT_IDS[m.split(/[-\s]/)[0]]
+    || '';
+}
+
+/** Strip the printer/nozzle suffix and the "# " custom-preset marker a preset
+ *  name may carry, e.g. "Elegoo PLA+ @BBL X1C 0.4 nozzle" → "Elegoo PLA+". */
+export function presetDisplayName(name: string): string {
+  const withoutSuffix = name.replace(/@.+$/, '').trim();
+  return withoutSuffix.startsWith('# ') ? withoutSuffix.slice(2).trim() : withoutSuffix;
+}
+
+const SOURCE_ORDER: Record<FilamentPresetSource, number> = {
+  local: 0,
+  orca_cloud: 1,
+  cloud: 2,
+  builtin: 3,
+};
+
+/**
+ * Merge every filament source into one ranked list.
+ *
+ * Deduplication is deliberately asymmetric, because "the same name in two
+ * tiers" means different things depending on which tiers:
+ *
+ *  - *Within* a tier, by resolved filament id or display name. This is what
+ *    collapses the per-printer-model copies a cloud account carries —
+ *    "Bambu PLA Basic @BBL X1C", "@BBL P1S", "@BBL A1" are one name once the
+ *    suffix is stripped — and repeated imports of one filament for several
+ *    printers.
+ *
+ *  - *Across* tiers, by id only. Two entries carrying the same id really are
+ *    one record reached by two routes; two entries merely sharing a name are
+ *    not. Imported presets and an Orca Cloud library overlap heavily by name
+ *    (they are usually the same profiles, synced), and suppressing one for the
+ *    other empties a tier the user curated on purpose. The heading says where
+ *    each came from, which is the point of having tiers at all.
+ *
+ *  - *Into the built-in tier*, by name as well as by id. That tier is a static
+ *    table of the same Bambu catalogue every other source also ships, so
+ *    without a name check it echoes back everything above it. It exists to
+ *    guarantee the list is never empty, not to be a fourth copy.
+ */
+export function buildFilamentPresetOptions(sources: FilamentPresetSources): FilamentPresetOption[] {
+  const { localPresets, orcaProfiles, cloudSettings, builtinFilaments } = sources;
+  const options: FilamentPresetOption[] = [];
+
+  const nameKey = (name: string) => name.trim().toLowerCase();
+
+  // Ids seen anywhere: a cloud setting_id, an Orca profile id, a resolved
+  // filament id. Shared across tiers — an id collision is true identity.
+  const claimedIds = new Set<string>();
+  // Names seen, scoped to one tier, so two tiers can each list "Elegoo PLA+".
+  const namesInTier = new Set<string>();
+  // Every name any real source offered, consulted only by the built-in tier.
+  const namesOffered = new Set<string>();
+
+  const take = (source: FilamentPresetSource, name: string, ...ids: (string | undefined)[]): boolean => {
+    const usableIds = ids.filter((k): k is string => !!k);
+    if (usableIds.some(k => claimedIds.has(k))) return false;
+    const scoped = `${source}|${nameKey(name)}`;
+    if (namesInTier.has(scoped)) return false;
+    usableIds.forEach(k => claimedIds.add(k));
+    namesInTier.add(scoped);
+    namesOffered.add(nameKey(name));
+    return true;
+  };
+
+  // 1. Local imported presets. filament_id lives in the preset's setting JSON,
+  // which the list endpoint doesn't return, so the generic material id is what
+  // we can offer without a per-preset detail fetch.
+  for (const lp of localPresets ?? []) {
+    const name = presetDisplayName(lp.name);
+    const material = lp.filament_type || parsePresetName(name).material;
+    // No id is claimed here: the generic id an import maps to is shared by
+    // every filament of that material, so claiming it would let the first
+    // imported PLA swallow every other PLA in the list.
+    if (!take('local', name)) continue;
+    options.push({
+      id: `local_${lp.id}`,
+      name,
+      source: 'local',
+      filamentId: genericFilamentIdForMaterial(material),
+      filamentType: material || '',
+    });
+  }
+
+  // 2. Orca Cloud. setting_ids are UUIDs a Bambu printer can't resolve, so
+  // these also fall back to the generic id for their material.
+  for (const op of orcaProfiles ?? []) {
+    const name = presetDisplayName(op.name);
+    const material = parsePresetName(name).material;
+    // Same reasoning as the local tier for the generic id. The Orca profile id
+    // is claimed, so a Bambu Cloud row carrying that same id is recognised as
+    // the same record — a shared *name* is not, since an Orca library and an
+    // imported bundle are usually the same profiles reached two ways and both
+    // are worth showing under their own heading.
+    if (!take('orca_cloud', name, op.setting_id)) continue;
+    options.push({
+      id: `orca_${op.setting_id}`,
+      name,
+      source: 'orca_cloud',
+      filamentId: genericFilamentIdForMaterial(material),
+      filamentType: material,
+    });
+  }
+
+  // 3. Bambu Cloud. Official presets (GFS…) carry their filament id in the
+  // setting_id itself; user presets (PFUS… / PFCN…) do not, and toFilamentId
+  // would hand back the raw cloud id, which the printer rejects. Leave those
+  // empty here and let resolveFilamentId fetch the detail on selection.
+  for (const cp of cloudSettings ?? []) {
+    const name = presetDisplayName(cp.name);
+    // Cloud setting_ids carry a variant suffix ("GFSA00_01"); claim the bare
+    // filament id as well, or the built-in tier won't recognise the filament
+    // as covered and will list it again under its own heading.
+    const filamentId = cp.setting_id.startsWith('GFS') ? toFilamentId(cp.setting_id) : '';
+    if (!take('cloud', name, cp.setting_id, filamentId || undefined)) continue;
+    options.push({
+      id: cp.setting_id,
+      name,
+      source: 'cloud',
+      filamentId,
+      filamentType: parsePresetName(name).material,
+    });
+  }
+
+  // 4. Hardcoded fallback. Always present, so the picker is never empty even
+  // with no cloud account and nothing imported — but only for filaments none
+  // of the tiers above already offered.
+  for (const bf of builtinFilaments ?? []) {
+    // Cloud setting_ids insert an "S" after "GF" ("GFA00" → "GFSA00"); check
+    // both spellings so a filament a cloud tier already offered isn't listed
+    // a second time under a slightly different id.
+    const asSettingId = bf.filament_id.startsWith('GF') ? `GFS${bf.filament_id.slice(2)}` : bf.filament_id;
+    // Unlike the tiers above, a name match is enough to skip: this table is a
+    // static copy of the same catalogue, not a library of its own.
+    if (namesOffered.has(nameKey(bf.name))) continue;
+    if (!take('builtin', bf.name, bf.filament_id, asSettingId)) continue;
+    options.push({
+      id: `builtin_${bf.filament_id}`,
+      name: bf.name,
+      source: 'builtin',
+      filamentId: bf.filament_id,
+      filamentType: parsePresetName(bf.name).material,
+    });
+  }
+
+  return options.sort((a, b) => {
+    if (a.source !== b.source) return SOURCE_ORDER[a.source] - SOURCE_ORDER[b.source];
+    return a.name.localeCompare(b.name);
+  });
+}
+
+/**
+ * The Bambu filament id to file a calibration under for a chosen preset.
+ *
+ * Everything except a Bambu Cloud *user* preset is already resolved by
+ * buildFilamentPresetOptions; those need the cloud detail, because the
+ * PFUS/PFCN setting_id is not a filament id and the printer's calibration
+ * table is indexed by filament id. ``fetchDetail`` is injected so the pure
+ * cases stay testable without a network stub.
+ */
+export async function resolveFilamentId(
+  option: FilamentPresetOption,
+  fetchDetail?: (settingId: string) => Promise<{ filament_id?: string | null }>,
+): Promise<string> {
+  if (option.filamentId) return option.filamentId;
+  if (option.source !== 'cloud' || !fetchDetail) return '';
+  try {
+    const detail = await fetchDetail(option.id);
+    // Never fall back to the preset's base_id: that collapses a custom preset
+    // onto the generic it inherits from, and the printer then resolves the
+    // calibration to "Generic …" instead of the user's filament (#1053).
+    return detail.filament_id || '';
+  } catch {
+    return '';
+  }
+}

Разница между файлами не показана из-за своего большого размера
+ 1 - 0
static/assets/index-C_6BSgrK.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-CbDmTKuP.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 1
static/assets/index-oReXTzKG.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CxAiFpme.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-oReXTzKG.css">
+    <script type="module" crossorigin src="/assets/index-CbDmTKuP.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-C_6BSgrK.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Некоторые файлы не были показаны из-за большого количества измененных файлов