test_mqtt_reconnect_replay_3454.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. """A printer command is never sent again on a later connection (#3454).
  2. When the connection to a printer dropped and paho reconnected the same client,
  3. paho re-sent every QoS 1 publish still in its outbox. Bambu's broker doesn't
  4. reliably PUBACK (#1164), so the outbox held commands from hours ago: a `stop`
  5. from 09:25 cancelled the print running at 13:29, and a `project_file` raised
  6. "Device is busy" (0500_4004). The client now empties the outbox in on_connect,
  7. which paho calls before it re-sends.
  8. These tests drive a real paho client through its own reconnect() and CONNACK
  9. handling, with only the socket stubbed, so a paho upgrade that changes either
  10. fails here instead of bringing the replay back.
  11. """
  12. import json
  13. import struct
  14. import time
  15. from unittest.mock import MagicMock, patch
  16. import paho.mqtt.client as mqtt
  17. import pytest
  18. from backend.app.services.bambu_mqtt import BambuMQTTClient
  19. from backend.app.utils.paho_teardown import retire_paho_client
  20. STOP = json.dumps({"print": {"command": "stop", "sequence_id": "1"}})
  21. PROJECT_FILE = json.dumps({"print": {"command": "project_file", "sequence_id": "2"}})
  22. def _paho() -> mqtt.Client:
  23. return mqtt.Client(
  24. callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
  25. client_id="bambuddy_test_3454",
  26. protocol=mqtt.MQTTv311,
  27. )
  28. def _queue_unacked(client: mqtt.Client, topic: str, *payloads: str) -> None:
  29. """Commands sent on the old connection whose PUBACK never came."""
  30. for payload in payloads:
  31. client.publish(topic, payload, qos=1) # no socket: stays in the outbox
  32. for message in client._out_messages.values():
  33. message.state = mqtt.mqtt_ms_wait_for_puback
  34. client._inflight_messages += 1
  35. def _reconnect(client: mqtt.Client) -> list[str]:
  36. """paho's own reconnect() of the same client, then its CONNACK handling,
  37. with only the socket stubbed; returns the payloads put on the wire."""
  38. sent: list[str] = []
  39. def send_publish(mid, topic, payload=b"", *args, **kwargs):
  40. sent.append(payload.decode() if isinstance(payload, bytes) else payload)
  41. return mqtt.MQTTErrorCode.MQTT_ERR_SUCCESS
  42. sock = MagicMock()
  43. sock.send.side_effect = len # every write goes out whole
  44. if not client._host:
  45. client.connect_async("192.0.2.1", 8883)
  46. with (
  47. patch.object(client, "_create_socket", return_value=sock),
  48. patch.object(client, "_send_connect", return_value=mqtt.MQTTErrorCode.MQTT_ERR_SUCCESS),
  49. patch.object(client, "_send_publish", side_effect=send_publish),
  50. ):
  51. assert client.reconnect() == mqtt.MQTTErrorCode.MQTT_ERR_SUCCESS
  52. client._in_packet = {"remaining_length": 2, "packet": struct.pack("!BB", 0, 0)}
  53. client._handle_connack()
  54. return sent
  55. @pytest.fixture
  56. def printer():
  57. printer = BambuMQTTClient(
  58. ip_address="192.168.1.100", serial_number="SN3454", access_code="12345678", model="A1 mini"
  59. )
  60. paho = _paho()
  61. paho.on_connect = printer._on_connect
  62. printer._client = paho
  63. return printer
  64. def _commands(sent: list[str]) -> list[str]:
  65. return [json.loads(p).get("print", json.loads(p).get("pushing", {})).get("command") for p in sent]
  66. def test_paho_re_sends_the_old_outbox_after_on_connect():
  67. # paho's own behaviour, which the fix relies on: the outbox survives a
  68. # reconnect and goes out again, only after on_connect has run, and the
  69. # in-flight count has been reset to 0 by then.
  70. client = _paho()
  71. seen_by_on_connect: list[tuple[int, int]] = []
  72. client.on_connect = lambda c, *a: seen_by_on_connect.append((len(c._out_messages), c._inflight_messages))
  73. _queue_unacked(client, "device/SN3454/request", STOP, PROJECT_FILE)
  74. sent = _reconnect(client)
  75. assert seen_by_on_connect == [(2, 0)]
  76. assert sent == [STOP, PROJECT_FILE]
  77. def test_a_stale_stop_and_project_file_are_not_sent_again(printer):
  78. _queue_unacked(printer._client, printer.topic_publish, STOP, PROJECT_FILE)
  79. sent = _reconnect(printer._client)
  80. assert "stop" not in _commands(sent)
  81. assert "project_file" not in _commands(sent)
  82. assert printer.state.connected is True
  83. def test_commands_the_new_connection_sends_are_kept(printer):
  84. _queue_unacked(printer._client, printer.topic_publish, STOP)
  85. sent = _reconnect(printer._client)
  86. # on_connect asks for a full status push on the new connection.
  87. assert "pushall" in _commands(sent)
  88. assert sent.count(STOP) == 0
  89. # The outbox holds only the new connection's commands, each counted once
  90. # as in flight, so paho's ceiling isn't eaten by dropped ones.
  91. outbox = list(printer._client._out_messages.values())
  92. assert outbox
  93. assert STOP not in [m.payload.decode() for m in outbox]
  94. assert printer._client._inflight_messages == len(outbox)
  95. def test_the_virtual_printer_bridges_commands_are_dropped_too(printer):
  96. # Slicer commands forwarded by a virtual printer go through publish_raw.
  97. printer.publish_raw(printer.topic_publish, PROJECT_FILE)
  98. for message in printer._client._out_messages.values():
  99. message.state = mqtt.mqtt_ms_wait_for_puback
  100. assert PROJECT_FILE not in _reconnect(printer._client)
  101. def test_dropping_is_logged_with_the_count(printer, caplog):
  102. _queue_unacked(printer._client, printer.topic_publish, STOP, PROJECT_FILE)
  103. with caplog.at_level("INFO", logger="backend.app.services.bambu_mqtt"):
  104. _reconnect(printer._client)
  105. assert "Dropped 2 command(s) queued on the previous MQTT connection" in caplog.text
  106. def test_paho_without_those_internals_warns(printer, caplog):
  107. class OtherPaho:
  108. def subscribe(self, *a, **k):
  109. return (mqtt.MQTT_ERR_SUCCESS, 1)
  110. def publish(self, *a, **k):
  111. return mqtt.MQTTMessageInfo(1)
  112. with caplog.at_level("WARNING", logger="backend.app.services.bambu_mqtt"):
  113. printer._drop_previous_connection_commands(OtherPaho())
  114. assert "Cannot clear paho's outbox on reconnect" in caplog.text
  115. def test_a_retired_client_keeps_no_outbox_to_replay():
  116. # A client retired while stuck in its TLS handshake can still finish it
  117. # and connect; its on_connect is gone, so the outbox must be too.
  118. client = _paho()
  119. _queue_unacked(client, "device/SN3454/request", STOP)
  120. with patch.object(client, "loop_stop"), patch.object(client, "disconnect"):
  121. retire_paho_client(client, "SN3454")
  122. for _ in range(200):
  123. if not client._out_messages:
  124. break
  125. time.sleep(0.01)
  126. assert STOP not in _reconnect(client)