fix(mqtt): fail-safe request topic subscription for ams_mapping capture (#405)

Some printer MQTT brokers (e.g. P1S) reject subscriptions to the request
topic by killing the TCP connection, causing a disconnect loop. The request
topic subscription for ams_mapping capture is now fail-safe with two
detection paths:

- SUBACK rejection: on_subscribe callback detects failure code 0x80
- Broker kills connection: on_disconnect detects disconnect within 10s of
  unconfirmed subscription attempt

After detection, the request topic is disabled for that printer and
reconnection succeeds with only the report topic. Printers that support
the request topic (e.g. X1E, H2D) get ams_mapping capture; printers that
don't (e.g. P1S) gracefully degrade to tray_now-based tracking.
This commit is contained in:
maziggy
2026-02-17 09:33:32 +01:00
parent 83ed4b2581
commit a4fad2d2f5
3 changed files with 174 additions and 2 deletions
+62 -1
View File
@@ -304,6 +304,14 @@ class BambuMQTTClient:
# Intercepts slicer/Bambuddy print commands to get the slot-to-tray mapping
self._captured_ams_mapping: list[int] | None = None
# Request topic subscription tracking
# Some printer MQTT brokers (e.g. P1S) reject subscriptions to the request
# topic by killing the TCP connection. We detect this and gracefully degrade.
self._request_topic_supported: bool = True
self._request_topic_sub_mid: int | None = None
self._request_topic_sub_time: float = 0.0
self._request_topic_confirmed: bool = False
@property
def topic_subscribe(self) -> str:
return f"device/{self.serial_number}/report"
@@ -337,7 +345,19 @@ class BambuMQTTClient:
if rc == 0:
self.state.connected = True
client.subscribe(self.topic_subscribe)
client.subscribe(self.topic_publish) # Intercept print commands for ams_mapping
# Subscribe to request topic for ams_mapping capture (if supported by broker)
if self._request_topic_supported:
result, mid = client.subscribe(self.topic_publish)
if result == mqtt.MQTT_ERR_SUCCESS:
self._request_topic_sub_mid = mid
self._request_topic_sub_time = time.time()
self._request_topic_confirmed = False
else:
logger.warning(
"[%s] Failed to send request topic subscription",
self.serial_number,
)
self._request_topic_supported = False
# Request full status update (includes nozzle info in push_status response)
self._request_push_all()
# Request firmware version info
@@ -352,6 +372,29 @@ class BambuMQTTClient:
else:
self.state.connected = False
def _on_subscribe(self, client, userdata, mid, reason_code_list, properties=None):
"""Handle SUBACK responses to detect request topic subscription rejection."""
if mid == self._request_topic_sub_mid:
for rc in reason_code_list:
if rc.is_failure:
logger.warning(
"[%s] Request topic subscription rejected (code=%d: %s). "
"ams_mapping capture from slicer-initiated prints unavailable.",
self.serial_number,
rc.value,
rc.getName(),
)
self._request_topic_supported = False
else:
logger.info(
"[%s] Request topic subscription accepted. "
"ams_mapping capture enabled for slicer-initiated prints.",
self.serial_number,
)
self._request_topic_confirmed = True
self._request_topic_sub_mid = None
self._request_topic_sub_time = 0.0
def _on_disconnect(self, client, userdata, disconnect_flags=None, rc=None, properties=None):
# Ignore spurious disconnect callbacks if we've received a message recently
# Paho-mqtt sometimes fires disconnect callbacks while the connection is still active
@@ -363,6 +406,23 @@ class BambuMQTTClient:
return
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,
# the broker likely killed the connection due to the unauthorized subscription.
if (
self._request_topic_sub_time > 0
and not self._request_topic_confirmed
and time.time() - self._request_topic_sub_time < 10.0
):
logger.warning(
"[%s] Disconnected shortly after request topic subscription. Disabling request topic for this printer.",
self.serial_number,
)
self._request_topic_supported = False
self._request_topic_sub_mid = None
self._request_topic_sub_time = 0.0
self.state.connected = False
if self.on_state_change:
self.on_state_change(self.state)
@@ -2105,6 +2165,7 @@ class BambuMQTTClient:
self._client.username_pw_set("bblp", self.access_code)
self._client.on_connect = self._on_connect
self._client.on_disconnect = self._on_disconnect
self._client.on_subscribe = self._on_subscribe
self._client.on_message = self._on_message
# TLS setup - Bambu uses self-signed certs
@@ -860,6 +860,117 @@ class TestNozzleRackData:
assert mqtt_client.state.nozzles[1].nozzle_diameter == "0.6"
class TestRequestTopicFailSafe:
"""Tests for graceful degradation when broker rejects request topic subscription."""
@pytest.fixture
def mqtt_client(self):
from backend.app.services.bambu_mqtt import BambuMQTTClient
client = BambuMQTTClient(
ip_address="192.168.1.100",
serial_number="TEST123",
access_code="12345678",
)
return client
def test_request_topic_supported_by_default(self, mqtt_client):
"""Request topic subscription is attempted by default."""
assert mqtt_client._request_topic_supported is True
assert mqtt_client._request_topic_confirmed is False
def test_on_subscribe_confirms_success(self, mqtt_client):
"""Successful SUBACK marks request topic as confirmed."""
from paho.mqtt.reasoncodes import ReasonCode
mqtt_client._request_topic_sub_mid = 42
rc = ReasonCode(9, identifier=0) # SUBACK packetType=9, QoS 0 = success
mqtt_client._on_subscribe(None, None, 42, [rc], None)
assert mqtt_client._request_topic_confirmed is True
assert mqtt_client._request_topic_supported is True
assert mqtt_client._request_topic_sub_mid is None
assert mqtt_client._request_topic_sub_time == 0.0
def test_on_subscribe_detects_rejection(self, mqtt_client):
"""SUBACK with failure code disables request topic."""
from paho.mqtt.reasoncodes import ReasonCode
mqtt_client._request_topic_sub_mid = 42
rc = ReasonCode(9, identifier=0x80) # SUBACK packetType=9, 0x80 = failure
mqtt_client._on_subscribe(None, None, 42, [rc], None)
assert mqtt_client._request_topic_supported is False
assert mqtt_client._request_topic_confirmed is False
def test_on_subscribe_ignores_other_mids(self, mqtt_client):
"""SUBACK for other subscriptions (e.g. report topic) is ignored."""
from paho.mqtt.reasoncodes import ReasonCode
mqtt_client._request_topic_sub_mid = 42
rc = ReasonCode(9, identifier=0x80)
mqtt_client._on_subscribe(None, None, 99, [rc], None)
# Not affected — mid doesn't match
assert mqtt_client._request_topic_supported is True
def test_disconnect_after_subscription_disables_topic(self, mqtt_client):
"""Disconnect within 10s of subscription attempt disables request topic."""
import time
mqtt_client._request_topic_sub_time = time.time()
mqtt_client._request_topic_confirmed = False
mqtt_client._last_message_time = 0.0
mqtt_client._on_disconnect(None, None)
assert mqtt_client._request_topic_supported is False
assert mqtt_client._request_topic_sub_time == 0.0
def test_disconnect_after_confirmation_does_not_disable(self, mqtt_client):
"""Disconnect after SUBACK confirmation keeps request topic enabled."""
import time
mqtt_client._request_topic_sub_time = time.time()
mqtt_client._request_topic_confirmed = True
mqtt_client._last_message_time = 0.0
mqtt_client._on_disconnect(None, None)
assert mqtt_client._request_topic_supported is True
def test_late_disconnect_does_not_disable(self, mqtt_client):
"""Disconnect long after subscription (>10s) doesn't blame request topic."""
import time
mqtt_client._request_topic_sub_time = time.time() - 30.0
mqtt_client._request_topic_confirmed = False
mqtt_client._last_message_time = 0.0
mqtt_client._on_disconnect(None, None)
assert mqtt_client._request_topic_supported is True
def test_on_connect_skips_request_topic_when_unsupported(self, mqtt_client):
"""After marking unsupported, reconnect skips request topic subscription."""
mqtt_client._request_topic_supported = False
subscribe_calls = []
mock_client = type(
"MockClient",
(),
{
"subscribe": lambda self, topic: subscribe_calls.append(topic) or (0, 1),
},
)()
mqtt_client._on_connect(mock_client, None, None, 0)
# Only report topic subscribed, not request topic
assert len(subscribe_calls) == 1
assert subscribe_calls[0] == mqtt_client.topic_subscribe
class TestRequestTopicAmsMapping:
"""Tests for capturing ams_mapping from the MQTT request topic."""