Browse Source

fix(printers): a Stop on an idle printer no longer marks it as user-stopped, so a later failed print is not reported as cancelled; Configure Slot warns when a manual K value could not be sent; Skip Objects and the connection diagnostic release their DB connection before talking to the printer

maziggy 2 days ago
parent
commit
ab5ccc1082

+ 3 - 0
CHANGELOG.md

@@ -164,6 +164,9 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Configure Slot: a manual K value that could not be sent was reported as applied** — With no K-profile picked, a typed K value goes to the printer on its own command; when that send failed, the slot still read as configured. A warning now says the filament was set but the K value was not sent.
+- **A stray Stop on an idle printer could relabel a later failed print as cancelled** — Stop on a printer with no print loaded still marked it as stopped by the user. If the next print was first seen mid-way (after Bambuddy reconnected), that print's real failure was then reported as the user's cancel. The mark is now only set while a print is loaded (or the printer's state is unknown).
+- **Skip Objects and the printer connection diagnostic held a database connection through their printer I/O** — For a print started outside Bambuddy, the object list pulls the file over FTP, which on a printer mid-print can take a while; the diagnostic probes the printer and waits up to 10 seconds for a report. The connection is now released first.
 - **Camera light: the plate check could switch the light off under a live view** — With the camera light setting on, a live view opened while the print-start plate check had the chamber light on found it already lit, so it never took it, and the check then switched it off: the view went dark. The plate check now holds the light like every other camera use (and still lights the chamber whatever the camera light setting), so it goes off only once nothing uses the camera, after the usual short grace.
 - **External MJPEG cameras: timelapse frames, finish photos and Obico checks failed while someone watched** — A single-client camera (an ESP32 stream, for example) refused the second connection these opened while a live view was running. They now take the live view's frame, as they already did for the printer's own camera. A camera with a separate snapshot URL keeps using that URL.
 - **X1C live view could stop instead of reconnecting** — The printer ends each camera session about once a minute and Bambuddy redials; a background cleanup that ran at that moment could make the live view end instead.

+ 23 - 16
backend/app/api/routes/printers.py

@@ -1073,7 +1073,6 @@ async def diagnose_connection(
 async def diagnose_printer(
     printer_id: int,
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
-    db: AsyncSession = Depends(get_db),
 ):
     """Run connection diagnostics for an existing saved printer.
 
@@ -1085,8 +1084,9 @@ async def diagnose_printer(
     """
     from backend.app.services.printer_diagnostic import PUBLISH_WAIT_DEFAULT
 
-    result = await db.execute(select(Printer).where(Printer.id == printer_id))
-    printer = result.scalar_one_or_none()
+    # A short session, released before the probes and the wait (as RC-C9).
+    async with database.async_session() as db:
+        printer = (await db.execute(select(Printer).where(Printer.id == printer_id))).scalar_one_or_none()
     if not printer:
         raise HTTPException(404, "Printer not found")
     return await run_connection_diagnostic(
@@ -3174,6 +3174,7 @@ async def configure_ams_slot(
     # the existing profile's metadata (extruder_id, nozzle_id, name, setting_id),
     # corrupting it — e.g., overwriting a High Flow extruder 1 profile with
     # hardcoded extruder_id=0 and nozzle_id=HS00.
+    k_value_unsent = False
     if k_value > 0 and cali_idx < 0:
         # Calculate global tray ID for extrusion_cali_set
         if ams_id <= 3:
@@ -3183,7 +3184,7 @@ async def configure_ams_slot(
         else:
             global_tray_id = tray_id
 
-        client.extrusion_cali_set(
+        k_value_unsent = not client.extrusion_cali_set(
             tray_id=global_tray_id,
             k_value=k_value,
             nozzle_diameter=nozzle_diameter,
@@ -3378,6 +3379,10 @@ async def configure_ams_slot(
         # "lookup_failed" or "no_permission". Cleared when a K-profile realigned
         # the slot to its own specific filament after all.
         "orca_fallback_reason": "" if realigned_to_kprofile else orca_fallback_reason,
+        # The filament setting went out but a manual K value did not (FW-C5).
+        # Only the send is known: the printer's answer to it is not trusted --
+        # an X1C answers "fail" to a K value it applied.
+        "k_value_unsent": k_value_unsent,
     }
 
 
@@ -4199,7 +4204,6 @@ async def get_printable_objects(
     printer_id: int,
     reload: bool = False,
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
-    db: AsyncSession = Depends(get_db),
 ):
     """Get the list of printable objects for the current print.
 
@@ -4209,8 +4213,10 @@ async def get_printable_objects(
     Args:
         reload: If True, reload objects from the archive file (useful after restart)
     """
-    result = await db.execute(select(Printer).where(Printer.id == printer_id))
-    printer = result.scalar_one_or_none()
+    # Short sessions, released before the FTP download below (RC-C9): it can
+    # take a while from a printer that is mid-print.
+    async with database.async_session() as db:
+        printer = (await db.execute(select(Printer).where(Printer.id == printer_id))).scalar_one_or_none()
     if not printer:
         raise HTTPException(404, "Printer not found")
 
@@ -4233,16 +4239,17 @@ async def get_printable_objects(
 
         subtask_id = str(getattr(client.state, "subtask_id", "") or "").strip()
         if subtask_id not in ("", "0"):
-            archive = await db.scalar(
-                select(PrintArchive)
-                .where(
-                    PrintArchive.printer_id == printer_id,
-                    PrintArchive.status == "printing",
-                    PrintArchive.subtask_id == subtask_id,
+            async with database.async_session() as db:
+                archive = await db.scalar(
+                    select(PrintArchive)
+                    .where(
+                        PrintArchive.printer_id == printer_id,
+                        PrintArchive.status == "printing",
+                        PrintArchive.subtask_id == subtask_id,
+                    )
+                    .order_by(PrintArchive.created_at.desc())
+                    .limit(1)
                 )
-                .order_by(PrintArchive.created_at.desc())
-                .limit(1)
-            )
             if archive is not None:
                 objects, bbox_all = extract_printable_objects_from_archive(
                     settings.base_dir / archive.file_path,

+ 21 - 3
backend/app/services/print_control.py

@@ -29,8 +29,9 @@ def mark_stopped_by_user(printer_id: int) -> None:
 def stop_print_by_user(printer_id: int, *, mark_when_unsent: bool = False) -> bool:
     """Send the stop command; returns whether it was sent.
 
-    The printer is marked as stopped by the user when the command was sent,
-    or always with ``mark_when_unsent`` (the queue, which closes the job even
+    The printer is marked as stopped by the user when the command was sent
+    and the printer is not known to be idle (nothing to stop), or always with
+    ``mark_when_unsent`` (the queue, which closes the job even
     when the printer is offline, so a "failed" it reports later still reads
     as the user's cancel). Errors sending the command propagate to the caller.
     """
@@ -40,6 +41,23 @@ def stop_print_by_user(printer_id: int, *, mark_when_unsent: bool = False) -> bo
         if mark_when_unsent:
             mark_stopped_by_user(printer_id)
         raise
-    if sent or mark_when_unsent:
+    if mark_when_unsent or (sent and not _known_idle(printer_id)):
         mark_stopped_by_user(printer_id)
     return sent
+
+
+def _known_idle(printer_id: int) -> bool:
+    """The printer reports a state with no print loaded.
+
+    A stop sent then stops nothing, and its mark would linger until the next
+    print's start clears it -- which a new MQTT client that first sees that
+    print mid-way never runs, so the print's later failure read as the user's
+    cancel (USP/I4). An unknown state is not idle: the mark is the safe side.
+    That includes "unknown", the state before the first report after a
+    (re)connect.
+    """
+    status = printer_manager.get_status(printer_id)
+    state = getattr(status, "state", None)
+    if not state or state == "unknown":
+        return False
+    return state not in printer_manager.ACTIVE_PRINT_STATES

+ 40 - 0
backend/tests/integration/test_printers_api.py

@@ -3535,6 +3535,46 @@ class TestConfigureAmsSlotPersistsKProfile:
         assert kp.nozzle_diameter == "0.4"
         assert kp.name == "Bambu PLA Metal"
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_manual_k_value_that_could_not_be_sent_is_reported(
+        self, async_client: AsyncClient, printer_factory
+    ):
+        """FW-C5: with no profile picked, the K value goes out on its own
+        command; when that send failed the route still said "Configured"."""
+        printer = await printer_factory(model="X1C")
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+        mock_client.extrusion_cali_set.return_value = False
+        mock_client.request_status_update.return_value = True
+        mock_state = MagicMock()
+        mock_state.ams_extruder_map = {"0": 0}
+        mock_state.raw_data = {"ams": {"ams": []}}
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = mock_state
+            response = await async_client.post(
+                f"/api/v1/printers/{printer.id}/slots/0/1/configure",
+                params={
+                    "tray_info_idx": "GFL05",
+                    "tray_type": "PLA",
+                    "tray_sub_brands": "Bambu PLA Basic",
+                    "tray_color": "FF8800FF",
+                    "nozzle_temp_min": 190,
+                    "nozzle_temp_max": 230,
+                    "cali_idx": -1,
+                    "nozzle_diameter": "0.4",
+                    "k_value": 0.025,
+                },
+            )
+
+        mock_client.extrusion_cali_set.assert_called_once()
+        # The filament itself was set, so the dialog still saves the slot preset.
+        assert response.status_code == 200
+        assert response.json()["k_value_unsent"] is True
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_writes_spool_kprofile_when_local_assigned(

+ 46 - 0
backend/tests/unit/test_print_control.py

@@ -59,6 +59,52 @@ def test_a_failing_send_raises_and_marks_only_when_asked(marks):
     assert 4 in marks
 
 
+@pytest.mark.parametrize(
+    ("state", "marked"),
+    [
+        ("RUNNING", True),
+        ("PAUSE", True),
+        ("PREPARE", True),
+        ("IDLE", False),
+        ("FINISH", False),
+        # Before the first report after a (re)connect, and after a power-off.
+        ("unknown", True),
+        ("", True),
+        (None, True),
+    ],
+)
+def test_a_stop_on_an_idle_printer_leaves_no_mark(marks, state, marked):
+    """USP/I4: the mark is cleared only by the next print's start, which is
+    missed when a new MQTT client first sees that print mid-way; its later
+    failure was then reported as the user's cancel. A printer in an unknown
+    state is still marked."""
+    from types import SimpleNamespace
+
+    status = SimpleNamespace(state=state) if state is not None else None
+    with (
+        patch("backend.app.services.print_control.printer_manager.stop_print", return_value=True) as send,
+        patch("backend.app.services.print_control.printer_manager.get_status", return_value=status),
+    ):
+        assert stop_print_by_user(4) is True
+    send.assert_called_once_with(4)
+    assert (4 in marks) is marked
+
+
+def test_the_queue_marks_an_idle_printer_too(marks):
+    """The queue stops a job it knows it started; a printer that reads idle
+    there may simply not have reported yet."""
+    from types import SimpleNamespace
+
+    with (
+        patch("backend.app.services.print_control.printer_manager.stop_print", return_value=True),
+        patch(
+            "backend.app.services.print_control.printer_manager.get_status", return_value=SimpleNamespace(state="IDLE")
+        ),
+    ):
+        stop_print_by_user(4, mark_when_unsent=True)
+    assert 4 in marks
+
+
 # Files allowed to send a stop command themselves: the shared function, the
 # printer manager and MQTT client that implement it, and main.py's kill switch,
 # which stops unauthorised prints automatically (not a user stop, no mark).

+ 22 - 0
backend/tests/unit/test_printable_objects_releases_db.py

@@ -0,0 +1,22 @@
+"""Skip Objects does not hold a database connection across its FTP download (RC-C9).
+
+For a print started outside Bambuddy the route pulls the 3MF from the
+printer -- up to 6 names in 3 folders, from a printer that is mid-print.
+A ``Depends(get_db)`` session stayed open idle-in-transaction for all of it.
+"""
+
+import inspect
+
+import pytest
+
+from backend.app.api.routes import printers
+
+
+# The connection diagnostic is the same pattern: port, TLS and FTP probes and
+# a wait of up to 10 s for the printer to report.
+@pytest.mark.parametrize("route", ["get_printable_objects", "diagnose_printer"])
+def test_the_route_takes_no_request_long_session(route):
+    for name, param in inspect.signature(getattr(printers, route)).parameters.items():
+        assert getattr(param.default, "dependency", None) is not printers.get_db, (
+            f"{route} holds a get_db session via {name!r} (RC-C9)"
+        )

+ 26 - 0
frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx

@@ -321,6 +321,32 @@ describe('ConfigureAmsSlotModal', () => {
     ).toBeInTheDocument();
   });
 
+  it('warns when a manual K value could not be sent (FW-C5)', async () => {
+    // The route used to report the slot configured either way.
+    const ORCA_ID = '3f2a9c1e-4b7d-4a02-9f61-8c5e2d1a7b30';
+    (api.orcaCloudListProfiles as ReturnType<typeof vi.fn>).mockResolvedValue({
+      filament: [{ setting_id: ORCA_ID, name: 'Homebrew PLA @Orca', type: 'filament', is_custom: true }],
+      printer: [],
+      process: [],
+    });
+    (api.configureAmsSlot as ReturnType<typeof vi.fn>).mockResolvedValue({
+      success: true, tray_info_idx: 'P56e1be0', orca_fallback_reason: '', k_value_unsent: true,
+    });
+
+    const slotInfo = { ...defaultProps.slotInfo, savedPresetId: ORCA_ID };
+    render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slotInfo} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Homebrew PLA @Orca')).toBeInTheDocument();
+    });
+
+    fireEvent.click(screen.getByRole('button', { name: /Configure Slot/i }));
+
+    expect(
+      await screen.findByText('The filament was set, but the K value could not be sent to the printer.'),
+    ).toBeInTheDocument();
+  });
+
   it('uses the filament_id nested under setting when the envelope has none (#3003)', async () => {
     // Bambu Cloud returns a custom preset's own filament_id on the envelope for
     // some presets and inside the preset JSON for others. Only the envelope was

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

@@ -6790,6 +6790,8 @@ export const api = {
       tray_info_idx?: string;
       // Why an Orca profile went out as the generic for its material, or "".
       orca_fallback_reason?: '' | 'no_filament_id' | 'lookup_failed' | 'no_permission';
+      /** A manual K value could not be sent; the filament setting was. */
+      k_value_unsent?: boolean;
     }>(
       `/printers/${printerId}/slots/${amsId}/${trayId}/configure?${params}`,
       { method: 'POST' }

+ 4 - 0
frontend/src/components/ConfigureAmsSlotModal.tsx

@@ -627,6 +627,10 @@ export function ConfigureAmsSlotModal({
         orca_profile_id: isOrca ? orcaSettingId : undefined,
       });
 
+      if (result.k_value_unsent) {
+        showToast(t('configureAmsSlot.kValueUnsent'), 'warning');
+      }
+
       if (isOrca && result.orca_fallback_reason) {
         showToast(
           t(`configureAmsSlot.orcaFallback.${result.orca_fallback_reason}`, { material: trayType }),

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

@@ -7175,6 +7175,7 @@ export default {
     cancel: 'Abbrechen',
     configuring: 'Wird konfiguriert...',
     configureSlot: 'Slot konfigurieren',
+    kValueUnsent: 'Das Filament wurde eingestellt, aber der K-Wert konnte nicht an den Drucker gesendet werden.',
     // Why an Orca profile went into the slot as the generic for its material (#3216)
     orcaFallback: {
       no_filament_id: 'Dieses Orca-Profil hat keine eigene Filament-ID, daher sieht OrcaSlicer diesen Slot als Generic {{material}}.',

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

@@ -7228,6 +7228,7 @@ export default {
     cancel: 'Cancel',
     configuring: 'Configuring...',
     configureSlot: 'Configure Slot',
+    kValueUnsent: 'The filament was set, but the K value could not be sent to the printer.',
     // Why an Orca profile went into the slot as the generic for its material (#3216)
     orcaFallback: {
       no_filament_id: 'This Orca profile has no filament ID of its own, so OrcaSlicer will see this slot as Generic {{material}}.',

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

@@ -7183,6 +7183,7 @@ export default {
     cancel: 'Cancelar',
     configuring: 'Configurando...',
     configureSlot: 'Configurar ranura',
+    kValueUnsent: 'Se configuró el filamento, pero no se pudo enviar el valor K a la impresora.',
     // Why an Orca profile went into the slot as the generic for its material (#3216)
     orcaFallback: {
       no_filament_id: 'Este perfil de Orca no tiene un ID de filamento propio, así que OrcaSlicer verá esta ranura como Generic {{material}}.',

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

@@ -7163,6 +7163,7 @@ export default {
     cancel: 'Annuler',
     configuring: 'Configuration...',
     configureSlot: 'Configurer le slot',
+    kValueUnsent: "Le filament a été configuré, mais la valeur K n'a pas pu être envoyée à l'imprimante.",
     // Why an Orca profile went into the slot as the generic for its material (#3216)
     orcaFallback: {
       no_filament_id: 'Ce profil Orca n’a pas d’identifiant de filament propre : OrcaSlicer verra cet emplacement comme Generic {{material}}.',

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

@@ -7162,6 +7162,7 @@ export default {
     cancel: 'Annulla',
     configuring: 'Configurazione...',
     configureSlot: 'Configura slot',
+    kValueUnsent: 'Il filamento è stato impostato, ma non è stato possibile inviare il valore K alla stampante.',
     // Why an Orca profile went into the slot as the generic for its material (#3216)
     orcaFallback: {
       no_filament_id: 'Questo profilo Orca non ha un ID filamento proprio, quindi OrcaSlicer vedrà questo slot come Generic {{material}}.',

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

@@ -7176,6 +7176,7 @@ export default {
     cancel: 'キャンセル',
     configuring: '設定中...',
     configureSlot: 'スロットを設定',
+    kValueUnsent: 'フィラメントは設定されましたが、K 値をプリンターに送信できませんでした。',
     // Why an Orca profile went into the slot as the generic for its material (#3216)
     orcaFallback: {
       no_filament_id: 'このOrcaプロファイルには独自のフィラメントIDがないため、OrcaSlicerではこのスロットがGeneric {{material}}として表示されます。',

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

@@ -6863,6 +6863,7 @@ export default {
     cancel: '취소',
     configuring: '구성 중...',
     configureSlot: '슬롯 구성',
+    kValueUnsent: '필라멘트는 설정되었지만 K 값을 프린터로 보내지 못했습니다.',
     // Why an Orca profile went into the slot as the generic for its material (#3216)
     orcaFallback: {
       no_filament_id: '이 Orca 프로필에는 자체 필라멘트 ID가 없어 OrcaSlicer에서 이 슬롯이 Generic {{material}}(으)로 표시됩니다.',

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

@@ -7226,6 +7226,7 @@ export default {
     cancel: 'Annuleren',
     configuring: 'Configureren...',
     configureSlot: 'Sleuf configureren',
+    kValueUnsent: 'Het filament is ingesteld, maar de K-waarde kon niet naar de printer worden verzonden.',
     // Why an Orca profile went into the slot as the generic for its material (#3216)
     orcaFallback: {
       no_filament_id: 'Dit Orca-profiel heeft geen eigen filament-ID, dus OrcaSlicer ziet deze sleuf als Generic {{material}}.',

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

@@ -7162,6 +7162,7 @@ export default {
     cancel: 'Cancelar',
     configuring: 'Configurando...',
     configureSlot: 'Configurar Slot',
+    kValueUnsent: 'O filamento foi definido, mas não foi possível enviar o valor K para a impressora.',
     // Why an Orca profile went into the slot as the generic for its material (#3216)
     orcaFallback: {
       no_filament_id: 'Este perfil do Orca não tem um ID de filamento próprio, então o OrcaSlicer verá este slot como Generic {{material}}.',

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

@@ -6850,6 +6850,7 @@ export default {
     cancel: "Отмена",
     configuring: "Настройка...",
     configureSlot: "Настроить слот",
+    kValueUnsent: 'Филамент установлен, но значение K не удалось отправить на принтер.',
     // Why an Orca profile went into the slot as the generic for its material (#3216)
     orcaFallback: {
       no_filament_id: 'У этого профиля Orca нет собственного ID филамента, поэтому OrcaSlicer увидит этот слот как Generic {{material}}.',

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

@@ -7224,6 +7224,7 @@ errors: {
     cancel: 'Avbryt',
     configuring: 'Konfigurerar...',
     configureSlot: 'Konfigurera fack',
+    kValueUnsent: 'Filamentet ställdes in, men K-värdet kunde inte skickas till skrivaren.',
     // Why an Orca profile went into the slot as the generic for its material (#3216)
     orcaFallback: {
       no_filament_id: 'Den här Orca-profilen har inget eget filament-ID, så OrcaSlicer kommer att se den här platsen som Generic {{material}}.',

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

@@ -7118,6 +7118,7 @@ export default {
     cancel: 'İptal',
     configuring: 'Yapılandırılıyor...',
     configureSlot: 'Yuvayı Yapılandır',
+    kValueUnsent: 'Filament ayarlandı, ancak K değeri yazıcıya gönderilemedi.',
     // Why an Orca profile went into the slot as the generic for its material (#3216)
     orcaFallback: {
       no_filament_id: 'Bu Orca profilinin kendine ait bir filament kimliği yok, bu yüzden OrcaSlicer bu yuvayı Generic {{material}} olarak görecek.',

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

@@ -7216,6 +7216,7 @@ export default {
     cancel: "Скасувати",
     configuring: "Налаштування...",
     configureSlot: "Налаштувати слот",
+    kValueUnsent: 'Філамент встановлено, але значення K не вдалося надіслати на принтер.',
     // Why an Orca profile went into the slot as the generic for its material (#3216)
     orcaFallback: {
       no_filament_id: 'Цей профіль Orca не має власного ID філаменту, тому OrcaSlicer бачитиме цей слот як Generic {{material}}.',

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

@@ -7160,6 +7160,7 @@ export default {
     cancel: '取消',
     configuring: '配置中...',
     configureSlot: '配置槽位',
+    kValueUnsent: '耗材已设置,但无法将 K 值发送到打印机。',
     // Why an Orca profile went into the slot as the generic for its material (#3216)
     orcaFallback: {
       no_filament_id: '此 Orca 配置文件没有自己的耗材 ID,因此 OrcaSlicer 会将此槽位识别为 Generic {{material}}。',

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

@@ -7160,6 +7160,7 @@ export default {
     cancel: '取消',
     configuring: '設定中...',
     configureSlot: '設定槽位',
+    kValueUnsent: '耗材已設定,但無法將 K 值傳送到印表機。',
     // Why an Orca profile went into the slot as the generic for its material (#3216)
     orcaFallback: {
       no_filament_id: '此 Orca 設定檔沒有自己的線材 ID,因此 OrcaSlicer 會將此槽位識別為 Generic {{material}}。',