Fix AMS slot changes failing until manual reconnect (#887)

Add timeout and retry to the developer mode probe. After a keep-alive
  timeout, paho auto-reconnects but the session can be half-broken: the
  printer sends status but ignores commands. The probe had no recovery —
  one unanswered probe permanently blocked retries. Now times out after
  10s with one retry; two consecutive failures force-close the socket for
  a clean reconnect.
This commit is contained in:
maziggy
2026-04-04 14:08:29 +02:00
parent 411c165291
commit 2d5dd1b31f
3 changed files with 184 additions and 0 deletions
@@ -2788,6 +2788,147 @@ class TestDeveloperModeDetection:
assert mqtt_client.state.developer_mode is False
class TestDeveloperModeProbeTimeout:
"""Tests for developer mode probe timeout, retry, and forced reconnect (#887).
When a printer's MQTT session is half-broken (sends status but ignores
commands), the developer mode probe gets no response. The timeout logic
retries once, then force-closes the socket on the second failure.
"""
@pytest.fixture
def mqtt_client(self):
from unittest.mock import MagicMock
from backend.app.services.bambu_mqtt import BambuMQTTClient
client = BambuMQTTClient(
ip_address="192.168.1.100",
serial_number="TEST123",
access_code="12345678",
)
# Simulate connected state with a mock MQTT client
client.state.connected = True
mock_paho = MagicMock()
mock_paho.socket.return_value = MagicMock()
client._client = mock_paho
return client
def _make_pushall_data(self):
"""Create a print data dict with >30 keys (triggers probe) and no 'fun' field."""
return {f"key_{i}": i for i in range(35)}
def test_first_timeout_allows_retry(self, mqtt_client):
"""After first probe timeout, _dev_mode_probed resets to allow retry."""
import time
data = self._make_pushall_data()
# First pushall triggers the probe
mqtt_client._update_state(data)
assert mqtt_client._dev_mode_probed is True
assert mqtt_client._dev_mode_probe_seq is not None
assert mqtt_client.state.developer_mode is None
# Simulate 11 seconds passing
mqtt_client._dev_mode_probe_time = time.monotonic() - 11.0
# Next status message detects the timeout
mqtt_client._update_state(data)
assert mqtt_client._dev_mode_probe_failures == 1
assert mqtt_client._dev_mode_probe_seq is None
# Should allow retry on next full message
assert mqtt_client._dev_mode_probed is False
# Connection should NOT be force-closed after 1 failure
assert mqtt_client.state.connected is True
def test_second_timeout_forces_reconnect(self, mqtt_client):
"""After two consecutive probe timeouts, force-close the socket."""
import time
data = self._make_pushall_data()
state_change_called = []
mqtt_client.on_state_change = lambda s: state_change_called.append(True)
# First probe + timeout
mqtt_client._update_state(data)
mqtt_client._dev_mode_probe_time = time.monotonic() - 11.0
mqtt_client._update_state(data)
assert mqtt_client._dev_mode_probe_failures == 1
# Second probe (retry) + timeout
mqtt_client._update_state(data) # triggers new probe
assert mqtt_client._dev_mode_probed is True
mqtt_client._dev_mode_probe_time = time.monotonic() - 11.0
mqtt_client._update_state(data) # detects second timeout
assert mqtt_client._dev_mode_probe_failures == 2
assert mqtt_client.state.connected is False
assert mqtt_client._stale_reconnecting is True
# Socket should have been closed
mqtt_client._client.socket().close.assert_called()
# on_state_change should have been called
assert len(state_change_called) > 0
def test_successful_probe_resets_failure_counter(self, mqtt_client):
"""A probe response after a previous failure resets the counter."""
import time
data = self._make_pushall_data()
# First probe + timeout → failure=1
mqtt_client._update_state(data)
seq = mqtt_client._dev_mode_probe_seq
mqtt_client._dev_mode_probe_time = time.monotonic() - 11.0
mqtt_client._update_state(data)
assert mqtt_client._dev_mode_probe_failures == 1
# Retry probe
mqtt_client._update_state(data)
new_seq = mqtt_client._dev_mode_probe_seq
assert new_seq is not None
assert new_seq != seq
# Simulate successful response
mqtt_client._handle_dev_mode_probe_response(
{
"command": "ams_filament_setting",
"sequence_id": new_seq,
"result": "success",
}
)
assert mqtt_client._dev_mode_probe_failures == 0
assert mqtt_client.state.developer_mode is True
assert mqtt_client._dev_mode_probe_seq is None
def test_no_timeout_when_probe_not_sent(self, mqtt_client):
"""The timeout branch is only entered when a probe is pending."""
# No probe sent — _dev_mode_probed is False, _dev_mode_probe_seq is None
data = {"gcode_state": "IDLE", "mc_percent": 0} # < 30 keys
mqtt_client._update_state(data)
assert mqtt_client._dev_mode_probe_failures == 0
def test_on_connect_resets_probe_state(self, mqtt_client):
"""_on_connect resets all probe-related state for fresh detection."""
import time
mqtt_client._dev_mode_probed = True
mqtt_client._dev_mode_probe_seq = "42"
mqtt_client._dev_mode_probe_time = time.monotonic()
mqtt_client._dev_mode_probe_failures = 2
mqtt_client.state.developer_mode = True
# subscribe() must return (result, mid) tuple
mqtt_client._client.subscribe.return_value = (0, 1)
mqtt_client._on_connect(mqtt_client._client, None, None, 0)
assert mqtt_client.state.developer_mode is None
assert mqtt_client._dev_mode_probed is False
assert mqtt_client._dev_mode_probe_seq is None
assert mqtt_client._dev_mode_probe_time == 0.0
assert mqtt_client._dev_mode_probe_failures == 0
class TestVtTrayNormalization:
"""Tests for vt_tray dict→list normalization in _update_state.