Sfoglia il codice sorgente

fix(printers): normalize serial numbers + diagnose connect-but-no-reports (#1465)

  Reporter's H2C connected over MQTT+TLS but every status field stayed
  unknown. Root cause was layer-8: the MQTT broker is the printer, it
  authenticates on the access code and SUBACKs any topic string, so a
  wrong or mis-cased serial connects fine and silently receives nothing
  (the report topic device/<serial>/report is case-sensitive; Bambu
  serials are uppercase). Bambuddy stored and used the serial verbatim.

  - schemas/printer.py: field_validator strip()+upper()s serial_number on
    create, rejects blank-after-strip. The subscribed topic now always
    matches the printer's correctly-cased one.
  - bambu_mqtt.py: count report-topic messages per connection; when a
    stale reconnect fires with zero reports received, log a one-shot
    hint pointing at the serial number instead of looping silently.
maziggy 1 settimana fa
parent
commit
01787eb1e6

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 19 - 1
backend/app/schemas/printer.py

@@ -1,11 +1,29 @@
 from datetime import datetime
 
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, field_validator
 
 
 class PrinterBase(BaseModel):
     name: str = Field(..., min_length=1, max_length=100)
     serial_number: str = Field(..., min_length=1, max_length=50)
+
+    @field_validator("serial_number")
+    @classmethod
+    def _normalize_serial_number(cls, v: str) -> str:
+        """Uppercase and trim the serial number.
+
+        Bambu serial numbers are uppercase alphanumeric, and the MQTT report
+        topic ``device/<serial>/report`` is case-sensitive. A serial entered
+        in the wrong case (or with stray whitespace) connects and subscribes
+        without error but never receives a message — the printer publishes to
+        the correctly-cased topic, so every status field stays unknown (#1465).
+        Normalising on input makes the subscribed topic always match.
+        """
+        normalized = v.strip().upper()
+        if not normalized:
+            raise ValueError("serial_number must not be blank")
+        return normalized
+
     ip_address: str = Field(
         ...,
         max_length=253,

+ 29 - 0
backend/app/services/bambu_mqtt.py

@@ -366,6 +366,13 @@ class BambuMQTTClient:
         self._message_log: deque[MQTTLogEntry] = deque(maxlen=100)
         self._logging_enabled: bool = False
         self._last_message_time: float = 0.0  # Track when we last received a message
+        # Count of report-topic messages received since the last (re)connect.
+        # Lets check_staleness() distinguish "printer never sent a status
+        # report" (typically a wrong / mis-cased serial) from a normal quiet
+        # gap mid-session. _zero_report_hint_logged keeps the actionable hint
+        # to once per client lifetime so the stale loop doesn't spam it (#1465).
+        self._report_messages_since_connect: int = 0
+        self._zero_report_hint_logged: bool = False
         # Raw-message fan-out for VP MQTT bridge (non-proxy modes republish the
         # printer's pushes verbatim to slicers connected to a virtual printer).
         # Handlers receive (topic, payload_bytes) before JSON parsing.
@@ -467,6 +474,22 @@ class BambuMQTTClient:
             logger.warning(
                 f"[{self.serial_number}] Connection stale - no message for {now - self._last_message_time:.1f}s, forcing reconnect"
             )
+            # A connection that keeps going stale without ever receiving a
+            # status report is almost always a wrong or mis-cased serial
+            # number — the broker accepts the connection and the subscription
+            # regardless, but the printer publishes to device/<real-serial>/
+            # report, which is case-sensitive. Surface that once so the user
+            # has something actionable instead of an endless reconnect loop.
+            if self._report_messages_since_connect == 0 and not self._zero_report_hint_logged:
+                self._zero_report_hint_logged = True
+                logger.warning(
+                    "[%s] Connected and subscribed, but the printer has sent zero "
+                    "status reports. The most common cause is a wrong or mis-cased "
+                    "serial number — the device/<serial>/report MQTT topic is "
+                    "case-sensitive. Verify the serial number configured in Bambuddy "
+                    "exactly matches the printer.",
+                    self.serial_number,
+                )
             self._last_stale_reconnect = now
             self.state.connected = False
             if self.on_state_change:
@@ -587,6 +610,7 @@ class BambuMQTTClient:
             self._dev_mode_probe_time = 0.0
             self._dev_mode_probe_failures = 0
             self._connect_time = time.monotonic()
+            self._report_messages_since_connect = 0
             self._last_ams_cmd_time = 0.0
             self._ams_cmd_unanswered = 0
             client.subscribe(self.topic_subscribe)
@@ -729,6 +753,11 @@ class BambuMQTTClient:
                 self._handle_request_message(payload)
                 return
 
+            # Count status reports per connection so check_staleness() can tell
+            # "printer never sent a report" apart from a mid-session quiet gap.
+            if msg.topic == self.topic_subscribe:
+                self._report_messages_since_connect += 1
+
             # Log message if logging is enabled
             if self._logging_enabled:
                 self._message_log.append(

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

@@ -4084,6 +4084,47 @@ class TestStaleReconnect:
         assert mqtt_client.state.connected is True
         assert mqtt_client._stale_reconnecting is False
 
+    def test_check_staleness_logs_serial_hint_when_no_reports(self, mqtt_client, caplog):
+        """#1465 — a stale connection that never received a status report logs
+        an actionable serial-number hint, exactly once."""
+        import logging
+        import time
+
+        mqtt_client.state.connected = True
+        mqtt_client._last_message_time = time.time() - 120
+        mqtt_client._report_messages_since_connect = 0
+
+        with caplog.at_level(logging.WARNING):
+            mqtt_client.check_staleness()
+
+        assert mqtt_client._zero_report_hint_logged is True
+        assert any("zero status reports" in r.getMessage() for r in caplog.records)
+
+        # Re-arm staleness — the hint must not log a second time.
+        caplog.clear()
+        mqtt_client.state.connected = True
+        mqtt_client._last_message_time = time.time() - 120
+        mqtt_client._last_stale_reconnect = 0.0  # bypass the reconnect cooldown
+        with caplog.at_level(logging.WARNING):
+            mqtt_client.check_staleness()
+        assert not any("zero status reports" in r.getMessage() for r in caplog.records)
+
+    def test_check_staleness_no_serial_hint_when_reports_received(self, mqtt_client, caplog):
+        """A stale connection that DID receive reports (a normal mid-session
+        quiet gap) must not log the serial-number hint."""
+        import logging
+        import time
+
+        mqtt_client.state.connected = True
+        mqtt_client._last_message_time = time.time() - 120
+        mqtt_client._report_messages_since_connect = 5
+
+        with caplog.at_level(logging.WARNING):
+            mqtt_client.check_staleness()
+
+        assert mqtt_client._zero_report_hint_logged is False
+        assert not any("zero status reports" in r.getMessage() for r in caplog.records)
+
     def test_on_disconnect_skipped_during_stale_reconnect(self, mqtt_client):
         """_on_disconnect should not broadcast state when _stale_reconnecting is set."""
         state_changes = []

+ 42 - 0
backend/tests/unit/test_printer_schema.py

@@ -0,0 +1,42 @@
+"""Serial-number normalization on the printer schema (#1465).
+
+Bambu serial numbers are uppercase alphanumeric and the MQTT report topic
+``device/<serial>/report`` is case-sensitive. A serial entered in the wrong
+case connects and subscribes without error but never receives a message, so
+the schema normalizes it on input.
+"""
+
+import pytest
+from pydantic import ValidationError
+
+from backend.app.schemas.printer import PrinterCreate
+
+
+def _make(serial: str) -> PrinterCreate:
+    return PrinterCreate(
+        name="Test Printer",
+        serial_number=serial,
+        ip_address="192.168.1.50",
+        access_code="12345678",
+    )
+
+
+def test_serial_number_uppercased():
+    assert _make("01p00a3b1234567").serial_number == "01P00A3B1234567"
+
+
+def test_serial_number_whitespace_stripped():
+    assert _make("  01P00A3B1234567  ").serial_number == "01P00A3B1234567"
+
+
+def test_serial_number_stripped_and_uppercased():
+    assert _make(" 31b8c0ca1234567 ").serial_number == "31B8C0CA1234567"
+
+
+def test_already_normalized_serial_unchanged():
+    assert _make("31B8C0CA1234567").serial_number == "31B8C0CA1234567"
+
+
+def test_blank_serial_number_rejected():
+    with pytest.raises(ValidationError):
+        _make("   ")

Some files were not shown because too many files changed in this diff