mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 23:45:40 +02:00
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.
This commit is contained in:
@@ -19,6 +19,7 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **The size-S printer card now shows remaining time, ETA and layer progress (#2674, reporter @jakestatefarm1101-alt)** — Size S existed for exactly one job: watching a whole fleet on one screen. But it rendered only the printer name, a status pip and a progress bar — every other block on the card is gated behind the expanded view — so it could not answer the question that view is for, "which printer finishes first". Dropping to S to fit more printers meant losing the information you dropped down to compare. The compact card now carries one line of metrics under the progress bar while a print is running: **remaining time**, **ETA** in your configured 12/24-hour format, and **layer progress** — the same values the Medium card already shows, using the same formatters and the same ETA styling so the two read alike. Each value is omitted individually when the printer doesn't report it, and the row holds its height when nothing is printing so cards don't shift as prints start and finish. Card dimensions and grid density are otherwise unchanged. Frontend-only. Wiki updated. Covered by tests.
|
||||
|
||||
### Fixed
|
||||
- **A printer that refuses Bambuddy's access code now says so, instead of reconnecting silently forever (#2698, reporter @djepsylon)** — A printer whose access code or serial was wrong produced no explanation anywhere: the connection attempt was refused, and the only trace was a warning every 30 seconds reading `MQTT disconnected: rc=Unspecified error` — the same line you get from a printer that is simply switched off. The failure branch of the MQTT connect callback set "not connected" and discarded the reason code the printer had just sent, so the one piece of evidence that would have named the cause never reached the log, the support bundle, or the UI. In the report behind this fix, one of three printers had been in that loop for the entire capture and nothing said why. **Fix.** A refused connection is now logged with the printer's own reason ("Not authorized", "Bad user name or password") and, for those two, the remedy — the access code is regenerated every time LAN Only or Developer Mode is toggled, so it has to be re-read from the printer's screen. The reason is kept on the connection, so the **Connection Diagnostic**'s *Printer credentials* check now states plainly that the printer refused the credentials when that is what happened, and falls back to hedged wording when all Bambuddy knows is that there is no session — previously it asserted "the access code is most likely wrong" even for a printer that was merely rebooting or already at its connection limit. The same reason is returned by the pre-add connection test. The access code itself is never written to the log. Translated in all locales; wiki updated. Covered by tests for both refusal codes, the clearing of the reason on a successful reconnect, the diagnostic's reason plumbing, and the two UI variants.
|
||||
- **A2L AMS filament showed as "?" in Bambu Studio through the Virtual Printer, and manual filament picks reverted (#2697, reporter @qoatzelcoat)** — Every slot of the A2L's AMS Lite rendered as an empty question mark in the slicer's Device tab while Bambuddy's own AMS card showed type, colour and spool correctly; setting a filament by hand in Studio held for a second and then snapped back to "?". **Root cause.** The A2L reports its AMS Lite as physical unit id 16, but packs the slots' presence bits at bit base 24 — so Bambuddy normalises the id to 6 at the MQTT ingest boundary and every internal reader gets the right bits. The Virtual Printer's bridge, however, parses the printer's raw payload itself (by design — the slicer-facing cache has to keep the physical ids, since Bambu Studio addresses the Lite as 16) and so still held id 16 when it ran the shared empty-slot cleanup. That cleanup read bits 64-67, where nothing is ever set, concluded all four slots were empty and wiped `tray_type`, `tray_color`, `tray_info_idx` and the RFID fields from the copy sent to the slicer — once per second, which is also why a manual pick could not survive. **Fix.** The presence-bit helper now folds the physical id 16 onto the same bit base as the normalised 6, so it computes bits 24-27 whichever id reaches it; the cached ids the slicer sees are left untouched. Only the A2L was affected — every other AMS type already reached the helper with an id whose bit base was correct, and Bambuddy's own printer card was correct throughout. Confirmed against the reporter's debug log, which shows the cleanup clearing slots at bits 64-67. Covered by tests pinning the bit base for both ids and a bridge-level regression test built from the reporter's capture.
|
||||
- **Bambu Cloud sign-in with a TOTP (authenticator app) account always failed with "Invalid code" (#2696, reporter @cmerkle)** — Every TOTP verification was rejected regardless of the code. Bambu Lab added double-submit CSRF protection to the `bambulab.com` web origin, which is where — and only where — Bambuddy posts the two-factor code; the endpoint refused the request with `403 CSRF error: missing_cookie` **before evaluating the code at all**, and Bambuddy surfaced that as "Invalid code". Reproduced against the live endpoint with a deliberately invalid key: a bare POST returns `missing_cookie`, `GET /api/csrf` mints a `bbl_csrf_token` cookie, a POST carrying only that cookie returns `missing_header`, and a POST carrying the cookie plus an `x-bbl-csrf-token` header reaches application logic. Bambuddy now performs that handshake before submitting the code. Note that landing on the sign-in page first — the intuitive fix — does **not** work: that page sets only Cloudflare's `__cf_bm`. **Also fixed:** a CSRF refusal no longer masquerades as a wrong code; it now says the code was never checked, so nobody else loses an evening to clock drift and leading-zero theories. Only TOTP sign-ins were affected — every other cloud call, including the email-code two-factor path, goes to `api.bambulab.com`, which is not gated, and existing stored tokens kept working throughout. Covered by tests that pin the exact header name and the origin used per region.
|
||||
- **Spools added as stock could not be edited or duplicated without assigning a slicer preset, and that preset then overwrote the spool's own manufacturer (#1905, reporter @rocstat1979)** — A spool created via **Quick Add**, a CSV import or an RFID scan has no slicer preset, brand or subtype. Reopening it in **Edit Spool** demanded all three before anything could be saved — so changing the storage location, the cost or the note was impossible — and **Copy Spool** had the same gate with no Quick Add toggle to waive it. Worse, the preset you were forced to pick auto-filled material, brand and subtype from the preset name, silently rewriting a hand-entered manufacturer ("Elegoo" → "Generic") so the spool no longer appeared where it had been filed. **Fix.** Editing and copying now require only what the backend requires — the material; the preset, brand and subtype fields stay fully visible and editable (nothing is hidden the way Quick Add hides it), and the required-field markers no longer advertise a rule that isn't enforced. Selecting a preset now fills only fields that are still empty or that a previously selected preset had filled, so values you (or the saved spool) provided survive; switching between presets still replaces what the earlier one contributed. **Also fixed:** the brand and material dropdowns no longer *filter* themselves down to the brand/material pairs known to the color catalog and slicer presets — a real combination like Elegoo ASA looked impossible to enter because Elegoo was catalogued only for PLA. Both lists now always offer everything known, with the catalog-paired entries ranked first under a **Suggested** heading and the rest under **All**, and a spool's own custom brand or material is always present in its dropdown. The same change applies to the SpoolBuddy write-tag form, which shares these fields. Lastly, the Quick Add layout no longer leaks out of create mode: quick-adding a spool and then opening Edit used to strand the edit form in the reduced layout with no toggle to leave it. Frontend-only. Translated in all locales; wiki updated. Covered by validation and form-interaction tests.
|
||||
|
||||
@@ -40,6 +40,20 @@ _AMS_MODULE_PREFIXES = ("ams/", "n3f/", "n3s/")
|
||||
# printer_manager.ACTIVE_PRINT_STATES and print_scheduler._ACTIVE_PRINT_STATES.
|
||||
_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:
|
||||
"""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.
|
||||
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
|
||||
# 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.
|
||||
@@ -972,6 +998,8 @@ class BambuMQTTClient:
|
||||
def _on_connect(self, client, userdata, flags, rc, properties=None):
|
||||
if rc == 0:
|
||||
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
|
||||
# A dropped-and-restored MQTT session means the presumed power-off was
|
||||
# real (or at least that the printer restarted): there is nothing
|
||||
@@ -1029,6 +1057,43 @@ class BambuMQTTClient:
|
||||
self.on_state_change(self.state)
|
||||
else:
|
||||
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):
|
||||
"""Handle SUBACK responses to detect request topic subscription rejection."""
|
||||
@@ -1085,7 +1150,21 @@ class BambuMQTTClient:
|
||||
)
|
||||
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.
|
||||
# If we just subscribed and got disconnected before any SUBACK confirmation,
|
||||
|
||||
@@ -16,6 +16,7 @@ import socket
|
||||
|
||||
from backend.app.models.printer import Printer
|
||||
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.discovery import is_running_in_docker
|
||||
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
|
||||
|
||||
|
||||
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]:
|
||||
"""Return the model-specific camera diagnostic port and display protocol."""
|
||||
if not printer:
|
||||
@@ -249,14 +265,30 @@ async def run_connection_diagnostic(
|
||||
serial_number=serial_number,
|
||||
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:
|
||||
logger.debug("test_connection failed during diagnostic", exc_info=True)
|
||||
checks.append(DiagnosticCheck(id="mqtt_auth", status="fail"))
|
||||
elif state is not None:
|
||||
# Existing printer: trust the live MQTT state rather than opening a
|
||||
# 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:
|
||||
checks.append(DiagnosticCheck(id="mqtt_auth", status="skip"))
|
||||
|
||||
|
||||
@@ -951,6 +951,11 @@ class PrinterManager:
|
||||
"success": client.state.connected,
|
||||
"state": client.state.state if client.state.connected else None,
|
||||
"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:
|
||||
# Off-loop teardown — see docstring. paho's loop_stop() joins the
|
||||
|
||||
@@ -5,6 +5,7 @@ These tests focus on timelapse tracking during prints.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
import pytest
|
||||
@@ -6653,3 +6654,87 @@ class TestKProfileResponseDoesNotClobberNozzle:
|
||||
mqtt_client.state.nozzles[0].nozzle_diameter = "0.8"
|
||||
mqtt_client._process_message({"print": {"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
|
||||
|
||||
@@ -51,6 +51,7 @@ class _Env:
|
||||
state=None,
|
||||
test_connection_success=True,
|
||||
report_messages_since_connect: int | None = 5,
|
||||
connect_error: str | None = None,
|
||||
):
|
||||
self.ports = ports or _port_probe()
|
||||
self.in_docker = in_docker
|
||||
@@ -61,17 +62,26 @@ class _Env:
|
||||
# ``None`` means get_client returns None (e.g. pre-add flow); an int
|
||||
# means there's a client with that counter value.
|
||||
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()
|
||||
|
||||
def __enter__(self):
|
||||
manager = MagicMock()
|
||||
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:
|
||||
manager.get_client.return_value = None
|
||||
else:
|
||||
client = MagicMock()
|
||||
client.report_messages_since_connect = self.report_messages_since_connect
|
||||
client.last_connect_error = self.connect_error
|
||||
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}.is_running_in_docker", return_value=self.in_docker))
|
||||
@@ -248,6 +258,52 @@ class TestExistingPrinter:
|
||||
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:
|
||||
async def test_bad_credentials_fail_mqtt_auth(self):
|
||||
with _Env(test_connection_success=False):
|
||||
|
||||
@@ -143,6 +143,38 @@ describe('ConnectionDiagnosticModal', () => {
|
||||
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 () => {
|
||||
const spy = vi.spyOn(api, 'diagnosePrinter').mockResolvedValue({
|
||||
...PROBLEM_RESULT,
|
||||
|
||||
@@ -6353,7 +6353,8 @@ export default {
|
||||
mqtt_auth: {
|
||||
title: 'Drucker-Zugangsdaten',
|
||||
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.',
|
||||
},
|
||||
developer_mode: {
|
||||
|
||||
@@ -6397,7 +6397,8 @@ export default {
|
||||
mqtt_auth: {
|
||||
title: 'Printer credentials',
|
||||
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.',
|
||||
},
|
||||
developer_mode: {
|
||||
|
||||
@@ -6362,7 +6362,8 @@ export default {
|
||||
mqtt_auth: {
|
||||
title: 'Credenciales de la impresora',
|
||||
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.',
|
||||
},
|
||||
developer_mode: {
|
||||
|
||||
@@ -6343,7 +6343,8 @@ export default {
|
||||
mqtt_auth: {
|
||||
title: 'Identifiants de l\'imprimante',
|
||||
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.',
|
||||
},
|
||||
developer_mode: {
|
||||
|
||||
@@ -6342,7 +6342,8 @@ export default {
|
||||
mqtt_auth: {
|
||||
title: 'Credenziali stampante',
|
||||
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.',
|
||||
},
|
||||
developer_mode: {
|
||||
|
||||
@@ -6354,7 +6354,8 @@ export default {
|
||||
mqtt_auth: {
|
||||
title: 'プリンター認証情報',
|
||||
pass: 'プリンターが接続を受け入れました。',
|
||||
fail: 'プリンターには到達できますが、接続を拒否されました。アクセスコードまたはシリアル番号が間違っている可能性が高いです。アクセスコードは開発者モードを切り替えるたびに変わります — プリンター画面から再度コピーしてください。',
|
||||
fail: 'プリンターには到達できますが、Bambuddy は接続されていません。アクセスコードまたはシリアル番号が間違っている可能性が高いです — アクセスコードは LAN のみモードや開発者モードを切り替えるたびに変わるため、プリンター画面から再度コピーしてください。再起動中のプリンターや、同時接続数の上限に達しているプリンターでも同じ表示になります。',
|
||||
fail_auth_rejected: 'プリンターが Bambuddy の認証情報を拒否しました。アクセスコードまたはシリアル番号が間違っています — アクセスコードは LAN のみモードや開発者モードを切り替えるたびに変わります。プリンター画面から再度コピーし、プリンター設定に保存してください。',
|
||||
skip: '未確認 — プリンターに到達できませんでした。',
|
||||
},
|
||||
developer_mode: {
|
||||
|
||||
@@ -6423,7 +6423,8 @@ export default {
|
||||
mqtt_auth: {
|
||||
title: '프린터 자격증명',
|
||||
pass: '프린터가 연결을 수락했습니다.',
|
||||
fail: '프린터에 연결됐지만 연결을 거부했습니다. 액세스 코드 또는 시리얼 번호가 잘못됐을 가능성이 높습니다. 개발자 모드를 토글할 때마다 액세스 코드가 변경됩니다 — 프린터 화면에서 다시 복사하세요.',
|
||||
fail: '프린터에 도달할 수 있지만 Bambuddy가 연결되지 않았습니다. 액세스 코드 또는 시리얼 번호가 잘못됐을 가능성이 높습니다 — LAN 전용 모드나 개발자 모드를 토글할 때마다 액세스 코드가 변경되므로 프린터 화면에서 다시 복사하세요. 재부팅 중이거나 이미 동시 연결 한도에 도달한 프린터도 똑같이 보입니다.',
|
||||
fail_auth_rejected: '프린터가 Bambuddy의 자격증명을 거부했습니다. 액세스 코드 또는 시리얼 번호가 잘못됐습니다 — LAN 전용 모드나 개발자 모드를 토글할 때마다 액세스 코드가 변경됩니다. 프린터 화면에서 다시 복사한 뒤 프린터 설정에 저장하세요.',
|
||||
skip: '확인하지 않음 — 프린터에 연결할 수 없었습니다.'
|
||||
},
|
||||
developer_mode: {
|
||||
|
||||
@@ -6342,7 +6342,8 @@ export default {
|
||||
mqtt_auth: {
|
||||
title: 'Credenciais da impressora',
|
||||
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.',
|
||||
},
|
||||
developer_mode: {
|
||||
|
||||
@@ -5984,7 +5984,8 @@ export default {
|
||||
mqtt_auth: {
|
||||
title: "Учётные данные принтера",
|
||||
pass: "Принтер принял подключение.",
|
||||
fail: "Принтер доступен, но отклонил подключение. Скорее всего, неверны код доступа или серийный номер. Код доступа изменяется при каждом переключении режима разработчика — снова скопируйте его с экрана принтера.",
|
||||
fail: "Принтер доступен, но Bambuddy к нему не подключён. Скорее всего, неверны код доступа или серийный номер — код доступа изменяется при каждом переключении режима «Только LAN» или режима разработчика, поэтому снова скопируйте его с экрана принтера. Так же выглядит принтер, который перезагружается или уже исчерпал лимит одновременных подключений.",
|
||||
fail_auth_rejected: "Принтер отклонил учётные данные Bambuddy. Код доступа или серийный номер неверны — код доступа изменяется при каждом переключении режима «Только LAN» или режима разработчика. Снова скопируйте его с экрана принтера и сохраните в настройках принтера.",
|
||||
skip: "Не проверено — принтер недоступен.",
|
||||
},
|
||||
developer_mode: {
|
||||
|
||||
@@ -6293,7 +6293,8 @@ export default {
|
||||
mqtt_auth: {
|
||||
title: 'Yazıcı kimlik bilgileri',
|
||||
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.',
|
||||
},
|
||||
developer_mode: {
|
||||
|
||||
@@ -6397,7 +6397,8 @@ export default {
|
||||
mqtt_auth: {
|
||||
title: "Облікові дані принтера",
|
||||
pass: "Принтер прийняв підключення.",
|
||||
fail: "Принтер доступний, але відхилив з’єднання. Найімовірніше, указано неправильний код доступу або серійний номер. Код доступу змінюється після кожного перемикання режиму розробника — знову скопіюйте його з екрана принтера.",
|
||||
fail: "Принтер доступний, але Bambuddy до нього не під’єднано. Найімовірніше, указано неправильний код доступу або серійний номер — код доступу змінюється після кожного перемикання режиму «Лише LAN» або режиму розробника, тож знову скопіюйте його з екрана принтера. Так само виглядає принтер, який перезавантажується або вже вичерпав ліміт одночасних з’єднань.",
|
||||
fail_auth_rejected: "Принтер відхилив облікові дані Bambuddy. Код доступу або серійний номер неправильний — код доступу змінюється після кожного перемикання режиму «Лише LAN» або режиму розробника. Знову скопіюйте його з екрана принтера та збережіть у налаштуваннях принтера.",
|
||||
skip: "Не позначено — принтер недоступний.",
|
||||
},
|
||||
developer_mode: {
|
||||
|
||||
@@ -6341,7 +6341,8 @@ export default {
|
||||
mqtt_auth: {
|
||||
title: '打印机凭据',
|
||||
pass: '打印机已接受连接。',
|
||||
fail: '打印机可达,但拒绝了连接。访问代码或序列号很可能有误。每次切换开发者模式时访问代码都会更改 — 请从打印机屏幕重新复制。',
|
||||
fail: '打印机可达,但 Bambuddy 未与其建立连接。访问代码或序列号很可能有误 — 每次切换仅局域网模式或开发者模式时访问代码都会更改,请从打印机屏幕重新复制。正在重启或已达到同时连接数上限的打印机看起来也是这样。',
|
||||
fail_auth_rejected: '打印机拒绝了 Bambuddy 的凭据。访问代码或序列号有误 — 每次切换仅局域网模式或开发者模式时访问代码都会更改。请从打印机屏幕重新复制,并保存到打印机设置中。',
|
||||
skip: '未检查 — 无法连接到打印机。',
|
||||
},
|
||||
developer_mode: {
|
||||
|
||||
@@ -6341,7 +6341,8 @@ export default {
|
||||
mqtt_auth: {
|
||||
title: '印表機認證資訊',
|
||||
pass: '印表機已接受連線。',
|
||||
fail: '印表機可達,但拒絕了連線。存取碼或序號很可能有誤。每次切換開發者模式時存取碼都會變更 — 請從印表機螢幕重新複製。',
|
||||
fail: '印表機可達,但 Bambuddy 未與其建立連線。存取碼或序號很可能有誤 — 每次切換僅區域網路模式或開發者模式時存取碼都會變更,請從印表機螢幕重新複製。正在重新啟動或已達到同時連線數上限的印表機看起來也是這樣。',
|
||||
fail_auth_rejected: '印表機拒絕了 Bambuddy 的認證資訊。存取碼或序號有誤 — 每次切換僅區域網路模式或開發者模式時存取碼都會變更。請從印表機螢幕重新複製,並儲存到印表機設定中。',
|
||||
skip: '未檢查 — 無法連線到印表機。',
|
||||
},
|
||||
developer_mode: {
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -26,7 +26,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-B4oB3n1m.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-xPJs-OAQ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D4bpNaiw.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user