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
@@ -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."""