test_rfid_refresh_ack_3206.py 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. """RFID refresh reports what the printer said, and falls back to M620 R (#3206).
  2. An X1C on X1Plus (base 01.08.02.00) answers ams_get_rfid with
  3. ``result: FAIL, reason: ERROR STATE``, yet the refresh returned success and
  4. scheduled the K-profile re-apply. The refresh now waits for the answer, sends
  5. the legacy ``M620 R<global tray>`` gcode Bambu Studio uses for printers without
  6. the new protocol when ams_get_rfid is refused, and reports a refusal of both.
  7. """
  8. import json
  9. from unittest.mock import MagicMock, patch
  10. import pytest
  11. from backend.app.services.bambu_mqtt import BambuMQTTClient
  12. from backend.app.utils.printer_models import uses_legacy_rfid_refresh
  13. def _client(answers: dict[str, dict | None], model: str = "X1C") -> tuple[BambuMQTTClient, list[dict]]:
  14. """A connected, idle client whose printer answers each command per ``answers``.
  15. ``answers`` maps a command name to the result fields the printer sends
  16. back, or None for no answer at all. Returns the client and the list of
  17. commands it published.
  18. """
  19. client = BambuMQTTClient(ip_address="10.0.0.1", serial_number="X1C", access_code="c", model=model)
  20. client._client = MagicMock()
  21. client.state.connected = True
  22. client.state.tray_now = 255
  23. sent: list[dict] = []
  24. def publish(_topic, body, **_kw):
  25. command = json.loads(body)["print"]
  26. sent.append(command)
  27. answer = answers.get(command["command"])
  28. if answer is not None:
  29. client._process_message({"print": {**command, **answer}})
  30. client._client.publish.side_effect = publish
  31. return client, sent
  32. ACCEPT = {"result": "SUCCESS", "reason": "SUCCESS"}
  33. REFUSE = {"result": "FAIL", "reason": "ERROR STATE"}
  34. class TestRfidRefreshAck:
  35. @pytest.mark.asyncio
  36. async def test_accepted_sends_only_ams_get_rfid(self):
  37. client, sent = _client({"ams_get_rfid": ACCEPT})
  38. ok, message = await client.ams_refresh_tray(0, 3)
  39. assert ok
  40. assert message == "Refreshing AMS 0 tray 3"
  41. assert [c["command"] for c in sent] == ["ams_get_rfid"]
  42. assert sent[0]["ams_id"] == 0 and sent[0]["slot_id"] == 3
  43. @pytest.mark.asyncio
  44. async def test_refused_falls_back_to_m620_with_the_global_tray(self):
  45. client, sent = _client({"ams_get_rfid": REFUSE, "gcode_line": ACCEPT})
  46. ok, message = await client.ams_refresh_tray(2, 1)
  47. assert ok
  48. assert "legacy" in message
  49. assert [c["command"] for c in sent] == ["ams_get_rfid", "gcode_line"]
  50. assert sent[1]["param"] == "M620 R9\n"
  51. @pytest.mark.asyncio
  52. async def test_both_refused_reports_the_printer_reason(self):
  53. client, _ = _client({"ams_get_rfid": REFUSE, "gcode_line": REFUSE})
  54. ok, message = await client.ams_refresh_tray(0, 3)
  55. assert not ok
  56. assert "ERROR STATE" in message
  57. @pytest.mark.asyncio
  58. async def test_no_fallback_for_units_without_a_legacy_index(self):
  59. # AMS-HT (128+) has no M620 R index; only firmware with ams_get_rfid has one.
  60. client, sent = _client({"ams_get_rfid": REFUSE})
  61. ok, message = await client.ams_refresh_tray(128, 0)
  62. assert not ok
  63. assert "ERROR STATE" in message
  64. assert [c["command"] for c in sent] == ["ams_get_rfid"]
  65. @pytest.mark.asyncio
  66. async def test_no_fallback_on_a_newer_protocol_model(self):
  67. # Bambu Studio never sends M620 R to these; a refusal there is reported.
  68. client, sent = _client({"ams_get_rfid": REFUSE}, model="H2D")
  69. ok, message = await client.ams_refresh_tray(0, 1)
  70. assert not ok
  71. assert "ERROR STATE" in message
  72. assert [c["command"] for c in sent] == ["ams_get_rfid"]
  73. @pytest.mark.asyncio
  74. @pytest.mark.parametrize("state", ["RUNNING", "PAUSE", "PREPARE", "SLICING"])
  75. async def test_no_fallback_during_a_job(self, state):
  76. # A gcode_line would be executed inside the running print.
  77. client, sent = _client({"ams_get_rfid": REFUSE})
  78. client.state.state = state
  79. ok, _ = await client.ams_refresh_tray(0, 1)
  80. assert not ok
  81. assert [c["command"] for c in sent] == ["ams_get_rfid"]
  82. @pytest.mark.asyncio
  83. async def test_no_answer_counts_as_accepted(self):
  84. client, sent = _client({})
  85. client._rfid_ack_timeout = 0.1
  86. ok, _ = await client.ams_refresh_tray(0, 0)
  87. assert ok
  88. assert [c["command"] for c in sent] == ["ams_get_rfid"]
  89. assert client._pending_rfid_acks == {}
  90. @pytest.mark.asyncio
  91. async def test_unrelated_gcode_line_ack_is_not_taken_for_ours(self):
  92. client, _ = _client({"ams_get_rfid": REFUSE})
  93. client._process_message({"print": {"command": "gcode_line", "sequence_id": "0", **REFUSE}})
  94. client._rfid_ack_timeout = 0.1
  95. ok, _ = await client.ams_refresh_tray(0, 0)
  96. # ams_get_rfid refused, M620 R sent but unanswered -> accepted.
  97. assert ok
  98. class TestRefreshRoute:
  99. @pytest.mark.asyncio
  100. async def test_refused_refresh_is_400_and_schedules_no_pa_reapply(self):
  101. from fastapi import HTTPException
  102. from backend.app.api.routes import printers as printers_routes
  103. client, _ = _client({"ams_get_rfid": REFUSE, "gcode_line": REFUSE})
  104. db = MagicMock()
  105. result = MagicMock()
  106. result.scalar_one_or_none.return_value = MagicMock(id=1)
  107. async def execute(*_a, **_kw):
  108. return result
  109. db.execute = execute
  110. with (
  111. patch.object(printers_routes, "printer_manager") as pm,
  112. patch.object(printers_routes, "spawn_background_task") as spawn,
  113. ):
  114. pm.get_client.return_value = client
  115. with pytest.raises(HTTPException) as exc:
  116. await printers_routes.refresh_ams_slot(1, 0, 3, None, db)
  117. assert exc.value.status_code == 400
  118. assert "ERROR STATE" in exc.value.detail
  119. spawn.assert_not_called()
  120. class TestLegacyRfidModels:
  121. @pytest.mark.parametrize("model", ["X1C", "X1", "X1E", "P1P", "P1S", "A1", "A1 Mini", "BL-P001", "C12", "N1"])
  122. def test_legacy(self, model):
  123. assert uses_legacy_rfid_refresh(model)
  124. @pytest.mark.parametrize("model", ["H2D", "H2C", "H2S", "P2S", "A2L", "X2D", "O1D", "N7", "", None])
  125. def test_not_legacy(self, model):
  126. assert not uses_legacy_rfid_refresh(model)