Procházet zdrojové kódy

fix(mqtt): report why a printer refused the connection instead of looping silently

A printer with a wrong access code gave no explanation anywhere. The connect
callback's failure branch was a bare `state.connected = False`, discarding the
CONNACK reason code the printer had just sent, so the only trace was paho's
follow-up disconnect -- logged every 30 seconds as "rc=Unspecified error",
which is exactly what a powered-off printer produces. In the report behind this
fix one of three printers had been in that loop for the whole capture, and
neither the log nor the support bundle could say why.

Bambu speaks MQTT 3.1.1, whose CONNACK return codes 4 and 5 paho maps onto
reason codes 134 and 135. Both are now logged with the printer's own reason
string and, for those two, the remedy: the access code is regenerated whenever
LAN Only or Developer Mode is toggled, so it has to be re-read from the screen.
The access code itself is never logged -- it would land in every bundle.

The reason is kept on the client as a stable slug and plumbed through
test_connection into the connection diagnostic, which now distinguishes two
cases it previously conflated. "The printer refused our credentials" is
asserted only when the printer said so; when all Bambuddy knows is that there
is no session, the text hedges and names the alternatives (rebooting, or
already at its limit of simultaneous connections). The old wording claimed the
access code was most likely wrong in both cases.

Frontend needed no change -- ConnectionDiagnostic already renders
`<status>_<reason>` variants with fallback to the plain per-status text, so an
unrecognised slug degrades to today's wording rather than a missing key.
maziggy před 1 měsícem
rodič
revize
91269f14fe

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1 - 0
CHANGELOG.md


+ 80 - 1
backend/app/services/bambu_mqtt.py

@@ -40,6 +40,20 @@ _AMS_MODULE_PREFIXES = ("ams/", "n3f/", "n3s/")
 # printer_manager.ACTIVE_PRINT_STATES and print_scheduler._ACTIVE_PRINT_STATES.
 # printer_manager.ACTIVE_PRINT_STATES and print_scheduler._ACTIVE_PRINT_STATES.
 _ACTIVE_PRINT_STATES = frozenset({"PREPARE", "SLICING", "RUNNING", "PAUSE"})
 _ACTIVE_PRINT_STATES = frozenset({"PREPARE", "SLICING", "RUNNING", "PAUSE"})
 
 
+# CONNACK reason codes that mean the printer actively refused our credentials,
+# as opposed to being unreachable or busy. Bambu speaks MQTT 3.1.1, whose
+# single-byte CONNACK return codes paho maps onto the v5 reason-code space:
+# return code 4 ("bad user name or password") -> 134, and 5 ("not authorized")
+# -> 135. Both mean the same thing in practice for a Bambu printer: the access
+# code (or, on some firmware, the serial used as the username) is wrong.
+_CONNACK_AUTH_REJECTED = frozenset({134, 135})
+
+# Short, stable slugs recorded on the client and surfaced to the connection
+# diagnostic as a `params.reason` variant. Deliberately not free text — the
+# frontend picks a localized message key off these.
+CONNECT_ERROR_AUTH_REJECTED = "auth_rejected"
+CONNECT_ERROR_REFUSED = "refused"
+
 
 
 def parse_ams_filament_backup_from_cfg(cfg_raw: object) -> bool | None:
 def parse_ams_filament_backup_from_cfg(cfg_raw: object) -> bool | None:
     """Extract AMS Filament Backup state from a Bambu push_status ``print.cfg`` value.
     """Extract AMS Filament Backup state from a Bambu push_status ``print.cfg`` value.
@@ -722,6 +736,18 @@ class BambuMQTTClient:
         # See normalize_am_unit_id / a2l_lite_wire_ids and memory a2l-am-unit-16.
         # See normalize_am_unit_id / a2l_lite_wire_ids and memory a2l-am-unit-16.
         self._has_a2l_am_unit: bool = False
         self._has_a2l_am_unit: bool = False
 
 
+        # Why the last connection attempt was refused by the printer, or None
+        # when we have never seen a CONNACK failure since the last success.
+        # Without this a rejected access code was completely invisible: paho
+        # reports the follow-up disconnect as the generic "Unspecified error"
+        # and `_on_connect`'s failure branch used to log nothing at all, so a
+        # printer stuck in a reconnect loop looked identical whether it was
+        # powered off, on the wrong IP, or refusing our credentials (#2698).
+        # One of the CONNECT_ERROR_* slugs; the paired name is the paho reason
+        # string, kept for the log line only.
+        self.last_connect_error: str | None = None
+        self.last_connect_error_name: str | None = None
+
         # Request topic subscription tracking
         # Request topic subscription tracking
         # Some printer MQTT brokers (e.g. P1S, A1) reject subscriptions to the request
         # Some printer MQTT brokers (e.g. P1S, A1) reject subscriptions to the request
         # topic by killing the TCP connection. We detect this and gracefully degrade.
         # topic by killing the TCP connection. We detect this and gracefully degrade.
@@ -972,6 +998,8 @@ class BambuMQTTClient:
     def _on_connect(self, client, userdata, flags, rc, properties=None):
     def _on_connect(self, client, userdata, flags, rc, properties=None):
         if rc == 0:
         if rc == 0:
             self.state.connected = True
             self.state.connected = True
+            self.last_connect_error = None
+            self.last_connect_error_name = None
             self._stale_reconnecting = False  # Clear stale-reconnect flag on successful connect
             self._stale_reconnecting = False  # Clear stale-reconnect flag on successful connect
             # A dropped-and-restored MQTT session means the presumed power-off was
             # A dropped-and-restored MQTT session means the presumed power-off was
             # real (or at least that the printer restarted): there is nothing
             # real (or at least that the printer restarted): there is nothing
@@ -1029,6 +1057,43 @@ class BambuMQTTClient:
                 self.on_state_change(self.state)
                 self.on_state_change(self.state)
         else:
         else:
             self.state.connected = False
             self.state.connected = False
+            self._record_connect_refusal(rc)
+
+    def _record_connect_refusal(self, rc) -> None:
+        """Log and remember why the printer refused the MQTT connection.
+
+        The failure branch of ``_on_connect`` used to be a bare
+        ``connected = False``, which threw away the only signal that says
+        *why* a printer never comes online. The user-visible result was a
+        30-second reconnect loop logging nothing but paho's generic
+        ``MQTT disconnected: rc=Unspecified error`` — indistinguishable from a
+        powered-off printer, so "my printer won't print" reports could not be
+        triaged without a round trip (#2698).
+
+        Never logs the access code itself; the code is the likely culprit but
+        printing it would put a credential in every support bundle.
+        """
+        code = getattr(rc, "value", rc)
+        name = rc.getName() if hasattr(rc, "getName") else str(rc)
+        self.last_connect_error_name = name
+        if isinstance(code, int) and code in _CONNACK_AUTH_REJECTED:
+            self.last_connect_error = CONNECT_ERROR_AUTH_REJECTED
+            logger.warning(
+                "[%s] MQTT connection refused by the printer: %s (code %s). The access code "
+                "or serial number is wrong — the access code changes every time LAN Only or "
+                "Developer Mode is toggled, so re-read it from the printer's screen.",
+                self.serial_number,
+                name,
+                code,
+            )
+        else:
+            self.last_connect_error = CONNECT_ERROR_REFUSED
+            logger.warning(
+                "[%s] MQTT connection refused by the printer: %s (code %s).",
+                self.serial_number,
+                name,
+                code,
+            )
 
 
     def _on_subscribe(self, client, userdata, mid, reason_code_list, properties=None):
     def _on_subscribe(self, client, userdata, mid, reason_code_list, properties=None):
         """Handle SUBACK responses to detect request topic subscription rejection."""
         """Handle SUBACK responses to detect request topic subscription rejection."""
@@ -1085,7 +1150,21 @@ class BambuMQTTClient:
             )
             )
             return
             return
 
 
-        logger.warning("[%s] MQTT disconnected: rc=%s, flags=%s", self.serial_number, rc, disconnect_flags)
+        # Carry the last CONNACK refusal into the disconnect line. paho reports
+        # the drop that follows a refused CONNACK as "Unspecified error", so on
+        # its own this line says nothing useful about a printer that is looping
+        # on bad credentials — and this is the line that fills a support bundle
+        # (#2698).
+        if self.last_connect_error:
+            logger.warning(
+                "[%s] MQTT disconnected: rc=%s, flags=%s (last connection attempt was refused: %s)",
+                self.serial_number,
+                rc,
+                disconnect_flags,
+                self.last_connect_error_name,
+            )
+        else:
+            logger.warning("[%s] MQTT disconnected: rc=%s, flags=%s", self.serial_number, rc, disconnect_flags)
 
 
         # Detect if request topic subscription caused the disconnect.
         # Detect if request topic subscription caused the disconnect.
         # If we just subscribed and got disconnected before any SUBACK confirmation,
         # If we just subscribed and got disconnected before any SUBACK confirmation,

+ 34 - 2
backend/app/services/printer_diagnostic.py

@@ -16,6 +16,7 @@ import socket
 
 
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.schemas.printer import DiagnosticCheck, PrinterDiagnosticResult
 from backend.app.schemas.printer import DiagnosticCheck, PrinterDiagnosticResult
+from backend.app.services.bambu_mqtt import CONNECT_ERROR_AUTH_REJECTED
 from backend.app.services.camera import get_camera_port
 from backend.app.services.camera import get_camera_port
 from backend.app.services.discovery import is_running_in_docker
 from backend.app.services.discovery import is_running_in_docker
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.printer_manager import printer_manager
@@ -56,6 +57,21 @@ async def _check_port(ip: str, port: int, timeout: float = _PORT_PROBE_TIMEOUT)
         return False
         return False
 
 
 
 
+def _auth_reason_params(reason: str | None) -> dict:
+    """Map a client's CONNACK-refusal slug onto the check's `params.reason`.
+
+    The frontend renders `diagnostic.check.<id>.<status>_<reason>` when a reason
+    is present and falls back to the plain per-status text otherwise, so an
+    unknown or absent slug degrades to today's generic wording rather than a
+    missing string. Only `auth_rejected` currently carries its own message:
+    that is the one case where the printer positively told us the credentials
+    were wrong, as opposed to us merely observing that we are not connected.
+    """
+    if reason == CONNECT_ERROR_AUTH_REJECTED:
+        return {"reason": CONNECT_ERROR_AUTH_REJECTED}
+    return {}
+
+
 def _camera_port_for_printer(printer: Printer | None) -> tuple[int, str]:
 def _camera_port_for_printer(printer: Printer | None) -> tuple[int, str]:
     """Return the model-specific camera diagnostic port and display protocol."""
     """Return the model-specific camera diagnostic port and display protocol."""
     if not printer:
     if not printer:
@@ -249,14 +265,30 @@ async def run_connection_diagnostic(
                 serial_number=serial_number,
                 serial_number=serial_number,
                 access_code=access_code,
                 access_code=access_code,
             )
             )
-            checks.append(DiagnosticCheck(id="mqtt_auth", status="pass" if result.get("success") else "fail"))
+            checks.append(
+                DiagnosticCheck(
+                    id="mqtt_auth",
+                    status="pass" if result.get("success") else "fail",
+                    params=_auth_reason_params(result.get("reason")),
+                )
+            )
         except Exception:
         except Exception:
             logger.debug("test_connection failed during diagnostic", exc_info=True)
             logger.debug("test_connection failed during diagnostic", exc_info=True)
             checks.append(DiagnosticCheck(id="mqtt_auth", status="fail"))
             checks.append(DiagnosticCheck(id="mqtt_auth", status="fail"))
     elif state is not None:
     elif state is not None:
         # Existing printer: trust the live MQTT state rather than opening a
         # Existing printer: trust the live MQTT state rather than opening a
         # second connection (Bambu printers tolerate few concurrent sessions).
         # second connection (Bambu printers tolerate few concurrent sessions).
-        checks.append(DiagnosticCheck(id="mqtt_auth", status="pass" if state.connected else "fail"))
+        # `connected == False` alone does not say *why* — the live client keeps
+        # the last CONNACK refusal, so a rejected access code can be reported as
+        # such instead of as a generic failure the user has to guess at (#2698).
+        client = printer_manager.get_client(printer.id) if printer else None
+        checks.append(
+            DiagnosticCheck(
+                id="mqtt_auth",
+                status="pass" if state.connected else "fail",
+                params={} if state.connected else _auth_reason_params(getattr(client, "last_connect_error", None)),
+            )
+        )
     else:
     else:
         checks.append(DiagnosticCheck(id="mqtt_auth", status="skip"))
         checks.append(DiagnosticCheck(id="mqtt_auth", status="skip"))
 
 

+ 5 - 0
backend/app/services/printer_manager.py

@@ -951,6 +951,11 @@ class PrinterManager:
                 "success": client.state.connected,
                 "success": client.state.connected,
                 "state": client.state.state if client.state.connected else None,
                 "state": client.state.state if client.state.connected else None,
                 "model": client.state.raw_data.get("device_model"),
                 "model": client.state.raw_data.get("device_model"),
+                # Why the probe failed, when the printer told us: one of the
+                # CONNECT_ERROR_* slugs, else None. Lets the add-printer flow
+                # and the connection diagnostic say "the printer rejected the
+                # access code" instead of an unqualified failure (#2698).
+                "reason": None if client.state.connected else client.last_connect_error,
             }
             }
         finally:
         finally:
             # Off-loop teardown — see docstring. paho's loop_stop() joins the
             # Off-loop teardown — see docstring. paho's loop_stop() joins the

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

@@ -5,6 +5,7 @@ These tests focus on timelapse tracking during prints.
 """
 """
 
 
 import json
 import json
+import logging
 import time
 import time
 
 
 import pytest
 import pytest
@@ -6653,3 +6654,87 @@ class TestKProfileResponseDoesNotClobberNozzle:
         mqtt_client.state.nozzles[0].nozzle_diameter = "0.8"
         mqtt_client.state.nozzles[0].nozzle_diameter = "0.8"
         mqtt_client._process_message({"print": {"nozzle_diameter": "0.4"}})
         mqtt_client._process_message({"print": {"nozzle_diameter": "0.4"}})
         assert mqtt_client.state.nozzles[0].nozzle_diameter == "0.4"
         assert mqtt_client.state.nozzles[0].nozzle_diameter == "0.4"
+
+
+class TestConnectRefusalReporting:
+    """#2698: a refused CONNACK must leave a trace.
+
+    ``_on_connect``'s failure branch used to be a bare ``connected = False``.
+    A printer refusing our access code then looked exactly like one that was
+    powered off: paho reports the follow-up drop as the generic "Unspecified
+    error", so the support bundle from a 30-second reconnect loop carried no
+    hint of the real cause. Bambu speaks MQTT 3.1.1, whose CONNACK return codes
+    4 and 5 paho maps to reason codes 134 / 135.
+    """
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        return BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST123",
+            access_code="12345678",
+        )
+
+    @staticmethod
+    def _connack(v3_return_code):
+        from paho.mqtt.client import convert_connack_rc_to_reason_code
+
+        return convert_connack_rc_to_reason_code(v3_return_code)
+
+    def test_no_error_recorded_before_any_attempt(self, mqtt_client):
+        assert mqtt_client.last_connect_error is None
+        assert mqtt_client.last_connect_error_name is None
+
+    @pytest.mark.parametrize("v3_rc", [4, 5])
+    def test_credential_refusal_recorded(self, mqtt_client, v3_rc, caplog):
+        with caplog.at_level(logging.WARNING):
+            mqtt_client._on_connect(None, None, None, self._connack(v3_rc))
+
+        assert mqtt_client.state.connected is False
+        assert mqtt_client.last_connect_error == "auth_rejected"
+        assert "refused" in caplog.text.lower()
+        # The remedy has to be in the log — that line is what a maintainer
+        # reads out of a support bundle.
+        assert "access code" in caplog.text.lower()
+        # Never leak the credential itself into a bundle.
+        assert "12345678" not in caplog.text
+
+    def test_non_credential_refusal_recorded_separately(self, mqtt_client, caplog):
+        # CONNACK 3 = server unavailable: a real refusal, but not about creds.
+        with caplog.at_level(logging.WARNING):
+            mqtt_client._on_connect(None, None, None, self._connack(3))
+
+        assert mqtt_client.last_connect_error == "refused"
+        assert "access code" not in caplog.text.lower()
+
+    def test_successful_connect_clears_previous_error(self, mqtt_client):
+        mqtt_client._on_connect(None, None, None, self._connack(5))
+        assert mqtt_client.last_connect_error == "auth_rejected"
+
+        mock_client = type("MockClient", (), {"subscribe": lambda self, topic: (0, 1)})()
+        mqtt_client._on_connect(mock_client, None, None, 0)
+
+        assert mqtt_client.state.connected is True
+        assert mqtt_client.last_connect_error is None
+        assert mqtt_client.last_connect_error_name is None
+
+    def test_disconnect_line_carries_the_refusal(self, mqtt_client, caplog):
+        """The reconnect loop is what fills the log, so it must say why."""
+        mqtt_client._on_connect(None, None, None, self._connack(5))
+        caplog.clear()
+
+        with caplog.at_level(logging.WARNING):
+            mqtt_client._on_disconnect(None, None)
+
+        assert "MQTT disconnected" in caplog.text
+        assert "refused" in caplog.text
+        assert "Not authorized" in caplog.text
+
+    def test_disconnect_line_unchanged_without_a_refusal(self, mqtt_client, caplog):
+        with caplog.at_level(logging.WARNING):
+            mqtt_client._on_disconnect(None, None)
+
+        assert "MQTT disconnected" in caplog.text
+        assert "refused" not in caplog.text

+ 57 - 1
backend/tests/unit/services/test_printer_diagnostic.py

@@ -51,6 +51,7 @@ class _Env:
         state=None,
         state=None,
         test_connection_success=True,
         test_connection_success=True,
         report_messages_since_connect: int | None = 5,
         report_messages_since_connect: int | None = 5,
+        connect_error: str | None = None,
     ):
     ):
         self.ports = ports or _port_probe()
         self.ports = ports or _port_probe()
         self.in_docker = in_docker
         self.in_docker = in_docker
@@ -61,17 +62,26 @@ class _Env:
         # ``None`` means get_client returns None (e.g. pre-add flow); an int
         # ``None`` means get_client returns None (e.g. pre-add flow); an int
         # means there's a client with that counter value.
         # means there's a client with that counter value.
         self.report_messages_since_connect = report_messages_since_connect
         self.report_messages_since_connect = report_messages_since_connect
+        # CONNACK-refusal slug the live client reports, or None when the last
+        # connection attempt was never refused (#2698).
+        self.connect_error = connect_error
         self._stack = ExitStack()
         self._stack = ExitStack()
 
 
     def __enter__(self):
     def __enter__(self):
         manager = MagicMock()
         manager = MagicMock()
         manager.get_status.return_value = self.state
         manager.get_status.return_value = self.state
-        manager.test_connection = AsyncMock(return_value={"success": self.test_connection_success})
+        manager.test_connection = AsyncMock(
+            return_value={
+                "success": self.test_connection_success,
+                "reason": None if self.test_connection_success else self.connect_error,
+            }
+        )
         if self.report_messages_since_connect is None:
         if self.report_messages_since_connect is None:
             manager.get_client.return_value = None
             manager.get_client.return_value = None
         else:
         else:
             client = MagicMock()
             client = MagicMock()
             client.report_messages_since_connect = self.report_messages_since_connect
             client.report_messages_since_connect = self.report_messages_since_connect
+            client.last_connect_error = self.connect_error
             manager.get_client.return_value = client
             manager.get_client.return_value = client
         self._stack.enter_context(patch(f"{MOD}._check_port", new_callable=AsyncMock, side_effect=self.ports))
         self._stack.enter_context(patch(f"{MOD}._check_port", new_callable=AsyncMock, side_effect=self.ports))
         self._stack.enter_context(patch(f"{MOD}.is_running_in_docker", return_value=self.in_docker))
         self._stack.enter_context(patch(f"{MOD}.is_running_in_docker", return_value=self.in_docker))
@@ -248,6 +258,52 @@ class TestExistingPrinter:
         assert params == {}
         assert params == {}
 
 
 
 
+class TestAuthRejectedReason:
+    """#2698: "not connected" and "credentials refused" are different answers.
+
+    `state.connected == False` only says we have no session — the printer may
+    be rebooting, at its connection limit, or refusing the access code. When
+    the printer actually sent a CONNACK refusal the client records it, and the
+    check surfaces it as a `params.reason` variant so the UI can name the cause
+    instead of making the user guess. Without a recorded refusal the params
+    stay empty and the generic text is used.
+    """
+
+    def _params(self, result):
+        return next(c.params for c in result.checks if c.id == "mqtt_auth")
+
+    async def test_recorded_refusal_surfaces_reason(self):
+        with _Env(state=_state(connected=False), connect_error="auth_rejected"):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        assert _statuses(result)["mqtt_auth"] == "fail"
+        assert self._params(result) == {"reason": "auth_rejected"}
+
+    async def test_disconnected_without_refusal_stays_generic(self):
+        with _Env(state=_state(connected=False)):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        assert _statuses(result)["mqtt_auth"] == "fail"
+        assert self._params(result) == {}
+
+    async def test_unknown_slug_falls_back_to_generic(self):
+        # `refused` has no dedicated message — degrade to the plain fail text
+        # rather than asking the frontend for a key that doesn't exist.
+        with _Env(state=_state(connected=False), connect_error="refused"):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        assert self._params(result) == {}
+
+    async def test_connected_printer_carries_no_reason(self):
+        with _Env(state=_state(connected=True), connect_error="auth_rejected"):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        assert _statuses(result)["mqtt_auth"] == "pass"
+        assert self._params(result) == {}
+
+    async def test_pre_add_probe_surfaces_reason(self):
+        with _Env(test_connection_success=False, connect_error="auth_rejected"):
+            result = await run_connection_diagnostic("192.168.1.50", serial_number="01P", access_code="wrong")
+        assert _statuses(result)["mqtt_auth"] == "fail"
+        assert self._params(result) == {"reason": "auth_rejected"}
+
+
 class TestPreAddFlow:
 class TestPreAddFlow:
     async def test_bad_credentials_fail_mqtt_auth(self):
     async def test_bad_credentials_fail_mqtt_auth(self):
         with _Env(test_connection_success=False):
         with _Env(test_connection_success=False):

+ 32 - 0
frontend/src/__tests__/components/ConnectionDiagnosticModal.test.tsx

@@ -143,6 +143,38 @@ describe('ConnectionDiagnosticModal', () => {
     spy.mockRestore();
     spy.mockRestore();
   });
   });
 
 
+  it('names a refused access code when the printer said so (#2698)', async () => {
+    const spy = vi.spyOn(api, 'diagnosePrinter').mockResolvedValue({
+      ...PROBLEM_RESULT,
+      checks: [{ id: 'mqtt_auth', status: 'fail', params: { reason: 'auth_rejected' } }],
+    });
+
+    renderModal({ printerId: 1, printerName: 'Test A1', onClose: vi.fn() });
+
+    await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
+    // States the printer refused us, instead of the hedged "most likely wrong"
+    // text used when all we know is that there's no session.
+    expect(await screen.findByText(/refused Bambuddy's credentials/i)).toBeInTheDocument();
+    expect(screen.queryByText(/most likely wrong/i)).not.toBeInTheDocument();
+
+    spy.mockRestore();
+  });
+
+  it('hedges on the mqtt_auth failure when the printer gave no reason (#2698)', async () => {
+    const spy = vi.spyOn(api, 'diagnosePrinter').mockResolvedValue({
+      ...PROBLEM_RESULT,
+      checks: [{ id: 'mqtt_auth', status: 'fail', params: {} }],
+    });
+
+    renderModal({ printerId: 1, printerName: 'Test A1', onClose: vi.fn() });
+
+    await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
+    expect(await screen.findByText(/most likely wrong/i)).toBeInTheDocument();
+    expect(screen.queryByText(/refused Bambuddy's credentials/i)).not.toBeInTheDocument();
+
+    spy.mockRestore();
+  });
+
   it('falls back to the generic skip text when no reason is present', async () => {
   it('falls back to the generic skip text when no reason is present', async () => {
     const spy = vi.spyOn(api, 'diagnosePrinter').mockResolvedValue({
     const spy = vi.spyOn(api, 'diagnosePrinter').mockResolvedValue({
       ...PROBLEM_RESULT,
       ...PROBLEM_RESULT,

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

@@ -6353,7 +6353,8 @@ export default {
       mqtt_auth: {
       mqtt_auth: {
         title: 'Drucker-Zugangsdaten',
         title: 'Drucker-Zugangsdaten',
         pass: 'Der Drucker hat die Verbindung akzeptiert.',
         pass: 'Der Drucker hat die Verbindung akzeptiert.',
-        fail: 'Der Drucker ist erreichbar, hat die Verbindung aber abgelehnt. Der Zugangscode oder die Seriennummer ist höchstwahrscheinlich falsch. Der Zugangscode ändert sich bei jedem Umschalten des Entwicklermodus — kopieren Sie ihn erneut vom Druckerbildschirm.',
+        fail: 'Der Drucker ist erreichbar, aber Bambuddy ist nicht mit ihm verbunden. Höchstwahrscheinlich ist der Zugangscode oder die Seriennummer falsch — der Zugangscode ändert sich bei jedem Umschalten von „Nur LAN“ oder des Entwicklermodus, kopieren Sie ihn also erneut vom Druckerbildschirm. Ein Drucker, der gerade neu startet oder bereits die maximale Anzahl gleichzeitiger Verbindungen erreicht hat, sieht genauso aus.',
+        fail_auth_rejected: 'Der Drucker hat die Zugangsdaten von Bambuddy abgelehnt. Der Zugangscode oder die Seriennummer ist falsch — der Zugangscode ändert sich bei jedem Umschalten von „Nur LAN“ oder des Entwicklermodus. Kopieren Sie ihn erneut vom Druckerbildschirm und speichern Sie ihn in den Druckereinstellungen.',
         skip: 'Nicht geprüft — der Drucker konnte nicht erreicht werden.',
         skip: 'Nicht geprüft — der Drucker konnte nicht erreicht werden.',
       },
       },
       developer_mode: {
       developer_mode: {

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

@@ -6397,7 +6397,8 @@ export default {
       mqtt_auth: {
       mqtt_auth: {
         title: 'Printer credentials',
         title: 'Printer credentials',
         pass: 'The printer accepted the connection.',
         pass: 'The printer accepted the connection.',
-        fail: 'The printer is reachable but rejected the connection. The access code or serial number is most likely wrong. The access code changes every time Developer Mode is toggled — re-copy it from the printer screen.',
+        fail: 'The printer is reachable but Bambuddy is not connected to it. The access code or serial number is most likely wrong — the access code changes every time LAN Only or Developer Mode is toggled, so re-copy it from the printer screen. A printer that is rebooting, or already at its limit of simultaneous connections, can look the same.',
+        fail_auth_rejected: 'The printer refused Bambuddy\'s credentials. The access code or serial number is wrong — the access code changes every time LAN Only or Developer Mode is toggled, so re-copy it from the printer screen and save it in the printer settings.',
         skip: 'Not checked — the printer could not be reached.',
         skip: 'Not checked — the printer could not be reached.',
       },
       },
       developer_mode: {
       developer_mode: {

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

@@ -6362,7 +6362,8 @@ export default {
       mqtt_auth: {
       mqtt_auth: {
         title: 'Credenciales de la impresora',
         title: 'Credenciales de la impresora',
         pass: 'La impresora aceptó la conexión.',
         pass: 'La impresora aceptó la conexión.',
-        fail: 'La impresora es accesible pero rechazó la conexión. Lo más probable es que el código de acceso o el número de serie sean incorrectos. El código de acceso cambia cada vez que se conmuta el modo desarrollador — vuelva a copiarlo de la pantalla de la impresora.',
+        fail: 'La impresora es accesible pero Bambuddy no está conectado a ella. Lo más probable es que el código de acceso o el número de serie sean incorrectos — el código de acceso cambia cada vez que se conmuta el modo Solo LAN o el modo desarrollador, así que vuelva a copiarlo de la pantalla de la impresora. Una impresora que se está reiniciando, o que ya alcanzó su límite de conexiones simultáneas, se ve igual.',
+        fail_auth_rejected: 'La impresora rechazó las credenciales de Bambuddy. El código de acceso o el número de serie son incorrectos — el código de acceso cambia cada vez que se conmuta el modo Solo LAN o el modo desarrollador, así que vuelva a copiarlo de la pantalla de la impresora y guárdelo en la configuración de la impresora.',
         skip: 'No comprobado — no se pudo alcanzar la impresora.',
         skip: 'No comprobado — no se pudo alcanzar la impresora.',
       },
       },
       developer_mode: {
       developer_mode: {

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

@@ -6343,7 +6343,8 @@ export default {
       mqtt_auth: {
       mqtt_auth: {
         title: 'Identifiants de l\'imprimante',
         title: 'Identifiants de l\'imprimante',
         pass: 'L\'imprimante a accepté la connexion.',
         pass: 'L\'imprimante a accepté la connexion.',
-        fail: 'L\'imprimante est accessible mais a refusé la connexion. Le code d\'accès ou le numéro de série est très probablement incorrect. Le code d\'accès change chaque fois que le mode développeur est activé/désactivé — recopiez-le depuis l\'écran de l\'imprimante.',
+        fail: 'L\'imprimante est accessible mais Bambuddy n\'y est pas connecté. Le code d\'accès ou le numéro de série est très probablement incorrect — le code d\'accès change chaque fois que le mode LAN uniquement ou le mode développeur est activé/désactivé, recopiez-le donc depuis l\'écran de l\'imprimante. Une imprimante en cours de redémarrage, ou ayant déjà atteint sa limite de connexions simultanées, produit le même résultat.',
+        fail_auth_rejected: 'L\'imprimante a refusé les identifiants de Bambuddy. Le code d\'accès ou le numéro de série est incorrect — le code d\'accès change chaque fois que le mode LAN uniquement ou le mode développeur est activé/désactivé. Recopiez-le depuis l\'écran de l\'imprimante et enregistrez-le dans les paramètres de l\'imprimante.',
         skip: 'Non vérifié — l\'imprimante n\'a pas pu être jointe.',
         skip: 'Non vérifié — l\'imprimante n\'a pas pu être jointe.',
       },
       },
       developer_mode: {
       developer_mode: {

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

@@ -6342,7 +6342,8 @@ export default {
       mqtt_auth: {
       mqtt_auth: {
         title: 'Credenziali stampante',
         title: 'Credenziali stampante',
         pass: 'La stampante ha accettato la connessione.',
         pass: 'La stampante ha accettato la connessione.',
-        fail: 'La stampante è raggiungibile ma ha rifiutato la connessione. Il codice di accesso o il numero di serie è molto probabilmente errato. Il codice di accesso cambia ogni volta che la modalità sviluppatore viene attivata/disattivata — ricopialo dallo schermo della stampante.',
+        fail: 'La stampante è raggiungibile ma Bambuddy non è connesso ad essa. Il codice di accesso o il numero di serie è molto probabilmente errato — il codice di accesso cambia ogni volta che la modalità Solo LAN o la modalità sviluppatore viene attivata/disattivata, quindi ricopialo dallo schermo della stampante. Una stampante in fase di riavvio, o che ha già raggiunto il limite di connessioni simultanee, appare allo stesso modo.',
+        fail_auth_rejected: 'La stampante ha rifiutato le credenziali di Bambuddy. Il codice di accesso o il numero di serie è errato — il codice di accesso cambia ogni volta che la modalità Solo LAN o la modalità sviluppatore viene attivata/disattivata. Ricopialo dallo schermo della stampante e salvalo nelle impostazioni della stampante.',
         skip: 'Non verificato — impossibile raggiungere la stampante.',
         skip: 'Non verificato — impossibile raggiungere la stampante.',
       },
       },
       developer_mode: {
       developer_mode: {

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

@@ -6354,7 +6354,8 @@ export default {
       mqtt_auth: {
       mqtt_auth: {
         title: 'プリンター認証情報',
         title: 'プリンター認証情報',
         pass: 'プリンターが接続を受け入れました。',
         pass: 'プリンターが接続を受け入れました。',
-        fail: 'プリンターには到達できますが、接続を拒否されました。アクセスコードまたはシリアル番号が間違っている可能性が高いです。アクセスコードは開発者モードを切り替えるたびに変わります — プリンター画面から再度コピーしてください。',
+        fail: 'プリンターには到達できますが、Bambuddy は接続されていません。アクセスコードまたはシリアル番号が間違っている可能性が高いです — アクセスコードは LAN のみモードや開発者モードを切り替えるたびに変わるため、プリンター画面から再度コピーしてください。再起動中のプリンターや、同時接続数の上限に達しているプリンターでも同じ表示になります。',
+        fail_auth_rejected: 'プリンターが Bambuddy の認証情報を拒否しました。アクセスコードまたはシリアル番号が間違っています — アクセスコードは LAN のみモードや開発者モードを切り替えるたびに変わります。プリンター画面から再度コピーし、プリンター設定に保存してください。',
         skip: '未確認 — プリンターに到達できませんでした。',
         skip: '未確認 — プリンターに到達できませんでした。',
       },
       },
       developer_mode: {
       developer_mode: {

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

@@ -6423,7 +6423,8 @@ export default {
       mqtt_auth: {
       mqtt_auth: {
         title: '프린터 자격증명',
         title: '프린터 자격증명',
         pass: '프린터가 연결을 수락했습니다.',
         pass: '프린터가 연결을 수락했습니다.',
-        fail: '프린터에 연결됐지만 연결을 거부했습니다. 액세스 코드 또는 시리얼 번호가 잘못됐을 가능성이 높습니다. 개발자 모드를 토글할 때마다 액세스 코드가 변경됩니다 — 프린터 화면에서 다시 복사하세요.',
+        fail: '프린터에 도달할 수 있지만 Bambuddy가 연결되지 않았습니다. 액세스 코드 또는 시리얼 번호가 잘못됐을 가능성이 높습니다 — LAN 전용 모드나 개발자 모드를 토글할 때마다 액세스 코드가 변경되므로 프린터 화면에서 다시 복사하세요. 재부팅 중이거나 이미 동시 연결 한도에 도달한 프린터도 똑같이 보입니다.',
+        fail_auth_rejected: '프린터가 Bambuddy의 자격증명을 거부했습니다. 액세스 코드 또는 시리얼 번호가 잘못됐습니다 — LAN 전용 모드나 개발자 모드를 토글할 때마다 액세스 코드가 변경됩니다. 프린터 화면에서 다시 복사한 뒤 프린터 설정에 저장하세요.',
         skip: '확인하지 않음 — 프린터에 연결할 수 없었습니다.'
         skip: '확인하지 않음 — 프린터에 연결할 수 없었습니다.'
       },
       },
       developer_mode: {
       developer_mode: {

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

@@ -6342,7 +6342,8 @@ export default {
       mqtt_auth: {
       mqtt_auth: {
         title: 'Credenciais da impressora',
         title: 'Credenciais da impressora',
         pass: 'A impressora aceitou a conexão.',
         pass: 'A impressora aceitou a conexão.',
-        fail: 'A impressora está acessível mas recusou a conexão. O código de acesso ou o número de série provavelmente está incorreto. O código de acesso muda toda vez que o Modo Desenvolvedor é alternado — copie-o novamente da tela da impressora.',
+        fail: 'A impressora está acessível mas o Bambuddy não está conectado a ela. O código de acesso ou o número de série provavelmente está incorreto — o código de acesso muda toda vez que o modo Somente LAN ou o Modo Desenvolvedor é alternado, então copie-o novamente da tela da impressora. Uma impressora reiniciando, ou que já atingiu seu limite de conexões simultâneas, aparece do mesmo jeito.',
+        fail_auth_rejected: 'A impressora recusou as credenciais do Bambuddy. O código de acesso ou o número de série está incorreto — o código de acesso muda toda vez que o modo Somente LAN ou o Modo Desenvolvedor é alternado. Copie-o novamente da tela da impressora e salve-o nas configurações da impressora.',
         skip: 'Não verificado — não foi possível alcançar a impressora.',
         skip: 'Não verificado — não foi possível alcançar a impressora.',
       },
       },
       developer_mode: {
       developer_mode: {

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

@@ -5984,7 +5984,8 @@ export default {
       mqtt_auth: {
       mqtt_auth: {
         title: "Учётные данные принтера",
         title: "Учётные данные принтера",
         pass: "Принтер принял подключение.",
         pass: "Принтер принял подключение.",
-        fail: "Принтер доступен, но отклонил подключение. Скорее всего, неверны код доступа или серийный номер. Код доступа изменяется при каждом переключении режима разработчика — снова скопируйте его с экрана принтера.",
+        fail: "Принтер доступен, но Bambuddy к нему не подключён. Скорее всего, неверны код доступа или серийный номер — код доступа изменяется при каждом переключении режима «Только LAN» или режима разработчика, поэтому снова скопируйте его с экрана принтера. Так же выглядит принтер, который перезагружается или уже исчерпал лимит одновременных подключений.",
+        fail_auth_rejected: "Принтер отклонил учётные данные Bambuddy. Код доступа или серийный номер неверны — код доступа изменяется при каждом переключении режима «Только LAN» или режима разработчика. Снова скопируйте его с экрана принтера и сохраните в настройках принтера.",
         skip: "Не проверено — принтер недоступен.",
         skip: "Не проверено — принтер недоступен.",
       },
       },
       developer_mode: {
       developer_mode: {

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

@@ -6293,7 +6293,8 @@ export default {
       mqtt_auth: {
       mqtt_auth: {
         title: 'Yazıcı kimlik bilgileri',
         title: 'Yazıcı kimlik bilgileri',
         pass: 'Yazıcı bağlantıyı kabul etti.',
         pass: 'Yazıcı bağlantıyı kabul etti.',
-        fail: 'Yazıcı erişilebilir ancak bağlantıyı reddetti. Büyük olasılıkla erişim kodu veya seri numarası yanlış. Erişim kodu, Geliştirici Modu her açılıp kapatıldığında değişir — yazıcı ekranından yeniden kopyalayın.',
+        fail: 'Yazıcıya erişilebiliyor ancak Bambuddy ona bağlı değil. Büyük olasılıkla erişim kodu veya seri numarası yanlış — erişim kodu, Yalnızca LAN veya Geliştirici Modu her açılıp kapatıldığında değişir, bu yüzden yazıcı ekranından yeniden kopyalayın. Yeniden başlamakta olan veya eşzamanlı bağlantı sınırına ulaşmış bir yazıcı da aynı görünür.',
+        fail_auth_rejected: 'Yazıcı, Bambuddy\'nin kimlik bilgilerini reddetti. Erişim kodu veya seri numarası yanlış — erişim kodu, Yalnızca LAN veya Geliştirici Modu her açılıp kapatıldığında değişir. Yazıcı ekranından yeniden kopyalayın ve yazıcı ayarlarına kaydedin.',
         skip: 'Kontrol edilmedi — yazıcıya erişilemedi.',
         skip: 'Kontrol edilmedi — yazıcıya erişilemedi.',
       },
       },
       developer_mode: {
       developer_mode: {

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

@@ -6397,7 +6397,8 @@ export default {
       mqtt_auth: {
       mqtt_auth: {
         title: "Облікові дані принтера",
         title: "Облікові дані принтера",
         pass: "Принтер прийняв підключення.",
         pass: "Принтер прийняв підключення.",
-        fail: "Принтер доступний, але відхилив з’єднання. Найімовірніше, указано неправильний код доступу або серійний номер. Код доступу змінюється після кожного перемикання режиму розробника — знову скопіюйте його з екрана принтера.",
+        fail: "Принтер доступний, але Bambuddy до нього не під’єднано. Найімовірніше, указано неправильний код доступу або серійний номер — код доступу змінюється після кожного перемикання режиму «Лише LAN» або режиму розробника, тож знову скопіюйте його з екрана принтера. Так само виглядає принтер, який перезавантажується або вже вичерпав ліміт одночасних з’єднань.",
+        fail_auth_rejected: "Принтер відхилив облікові дані Bambuddy. Код доступу або серійний номер неправильний — код доступу змінюється після кожного перемикання режиму «Лише LAN» або режиму розробника. Знову скопіюйте його з екрана принтера та збережіть у налаштуваннях принтера.",
         skip: "Не позначено — принтер недоступний.",
         skip: "Не позначено — принтер недоступний.",
       },
       },
       developer_mode: {
       developer_mode: {

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

@@ -6341,7 +6341,8 @@ export default {
       mqtt_auth: {
       mqtt_auth: {
         title: '打印机凭据',
         title: '打印机凭据',
         pass: '打印机已接受连接。',
         pass: '打印机已接受连接。',
-        fail: '打印机可达,但拒绝了连接。访问代码或序列号很可能有误。每次切换开发者模式时访问代码都会更改 — 请从打印机屏幕重新复制。',
+        fail: '打印机可达,但 Bambuddy 未与其建立连接。访问代码或序列号很可能有误 — 每次切换仅局域网模式或开发者模式时访问代码都会更改,请从打印机屏幕重新复制。正在重启或已达到同时连接数上限的打印机看起来也是这样。',
+        fail_auth_rejected: '打印机拒绝了 Bambuddy 的凭据。访问代码或序列号有误 — 每次切换仅局域网模式或开发者模式时访问代码都会更改。请从打印机屏幕重新复制,并保存到打印机设置中。',
         skip: '未检查 — 无法连接到打印机。',
         skip: '未检查 — 无法连接到打印机。',
       },
       },
       developer_mode: {
       developer_mode: {

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

@@ -6341,7 +6341,8 @@ export default {
       mqtt_auth: {
       mqtt_auth: {
         title: '印表機認證資訊',
         title: '印表機認證資訊',
         pass: '印表機已接受連線。',
         pass: '印表機已接受連線。',
-        fail: '印表機可達,但拒絕了連線。存取碼或序號很可能有誤。每次切換開發者模式時存取碼都會變更 — 請從印表機螢幕重新複製。',
+        fail: '印表機可達,但 Bambuddy 未與其建立連線。存取碼或序號很可能有誤 — 每次切換僅區域網路模式或開發者模式時存取碼都會變更,請從印表機螢幕重新複製。正在重新啟動或已達到同時連線數上限的印表機看起來也是這樣。',
+        fail_auth_rejected: '印表機拒絕了 Bambuddy 的認證資訊。存取碼或序號有誤 — 每次切換僅區域網路模式或開發者模式時存取碼都會變更。請從印表機螢幕重新複製,並儲存到印表機設定中。',
         skip: '未檢查 — 無法連線到印表機。',
         skip: '未檢查 — 無法連線到印表機。',
       },
       },
       developer_mode: {
       developer_mode: {

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/index-xPJs-OAQ.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- 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-B4oB3n1m.js"></script>
+    <script type="module" crossorigin src="/assets/index-xPJs-OAQ.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-D4bpNaiw.css">
     <link rel="stylesheet" crossorigin href="/assets/index-D4bpNaiw.css">
   </head>
   </head>
   <body>
   <body>

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů