test_total_layers_print_start.py 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. """The layer total must survive the print-start reset (#2702).
  2. `_update_state` applies `total_layer_num` early and, further down, resets
  3. `total_layers` when it detects a new print (added by #1771 so the previous
  4. print's total can't bleed into the next one's usage split). Those two run in
  5. the same function on the same frame, so a frame that carried both the new
  6. print's total *and* the transition into RUNNING had its total applied and then
  7. zeroed.
  8. That is unrecoverable rather than merely late: Bambu firmware sends only
  9. changed fields, so the printer never re-sends a total it already published.
  10. The value reappears only in a full pushall — i.e. on reconnect or a manual
  11. Force Refresh — which is why the reporter saw `n/0` for nine minutes on a
  12. flawless connection, why it looked random, and why a *stable* link made it
  13. worse.
  14. Frames here are trimmed to the fields the code under test reads. No printer is
  15. needed: the fix is a property of how one function orders its own writes.
  16. """
  17. from __future__ import annotations
  18. import json
  19. import pytest
  20. @pytest.fixture
  21. def client():
  22. """A client with a recording stand-in for the MQTT connection."""
  23. from unittest.mock import MagicMock
  24. from backend.app.services.bambu_mqtt import BambuMQTTClient
  25. c = BambuMQTTClient(
  26. ip_address="192.168.1.100",
  27. serial_number="TEST123",
  28. access_code="12345678",
  29. )
  30. c._client = MagicMock()
  31. # A new print is only detected once a previous state has been observed
  32. # (#1304 guard), so give every test a plausible pre-print history.
  33. c._previous_gcode_state = "IDLE"
  34. c._previous_gcode_file = None
  35. c._was_running = False
  36. return c
  37. def pushalls(client) -> list[dict]:
  38. """Every pushall published on this client, decoded."""
  39. sent = []
  40. for call in client._client.publish.call_args_list:
  41. payload = json.loads(call.args[1])
  42. if payload.get("pushing", {}).get("command") == "pushall":
  43. sent.append(payload)
  44. return sent
  45. def running_frame(**extra) -> dict:
  46. """A frame that flips the printer into RUNNING with a file — a new print."""
  47. return {"gcode_state": "RUNNING", "gcode_file": "widget.3mf", "subtask_name": "widget", **extra}
  48. # ---------------------------------------------------------------------------
  49. # The regression
  50. # ---------------------------------------------------------------------------
  51. def test_total_arriving_with_the_start_frame_survives(client):
  52. """The reported bug: total and transition in one frame lost the total."""
  53. client._update_state(running_frame(total_layer_num=33, layer_num=0))
  54. assert client.state.total_layers == 33
  55. def test_total_arriving_with_the_start_frame_needs_no_pushall(client):
  56. """We already have the denominator, so don't spend a round-trip on it."""
  57. client._update_state(running_frame(total_layer_num=33))
  58. assert pushalls(client) == []
  59. assert client._total_layers_refresh_armed is False
  60. def test_previous_prints_total_still_cannot_bleed_through(client):
  61. """#1771's reason for the reset — preserved exactly.
  62. A start frame with no total of its own must land on 0, never on the
  63. finished print's denominator.
  64. """
  65. client.state.total_layers = 120 # left over from the print that just ended
  66. client._update_state(running_frame())
  67. assert client.state.total_layers == 0
  68. def test_start_frame_without_a_total_asks_the_printer_for_one(client):
  69. """Covers the ordering where the total was published a frame or two early.
  70. Re-applying this frame's own value can't help there — the value was
  71. already consumed and zeroed — so recovery has to come from a pushall,
  72. the only message that re-sends unchanged fields.
  73. """
  74. client._update_state(running_frame())
  75. assert len(pushalls(client)) == 1
  76. assert client._total_layers_refresh_armed is True
  77. # ---------------------------------------------------------------------------
  78. # The one-shot re-request
  79. # ---------------------------------------------------------------------------
  80. def test_first_layer_advance_without_a_total_re_requests_once(client):
  81. client._update_state(running_frame())
  82. assert len(pushalls(client)) == 1 # from print start
  83. client._update_state({"layer_num": 1})
  84. assert len(pushalls(client)) == 2
  85. assert client._total_layers_refresh_armed is False
  86. def test_later_layer_advances_do_not_keep_re_requesting(client):
  87. """An unanswered pushall must not become a per-layer retry loop."""
  88. client._update_state(running_frame())
  89. client._update_state({"layer_num": 1})
  90. before = len(pushalls(client))
  91. for layer in range(2, 12):
  92. client._update_state({"layer_num": layer})
  93. assert len(pushalls(client)) == before
  94. def test_no_re_request_once_the_total_is_known(client):
  95. """The pushall answered: layers advance without further traffic."""
  96. client._update_state(running_frame())
  97. client._update_state({"total_layer_num": 33}) # the pushall's answer
  98. before = len(pushalls(client))
  99. client._update_state({"layer_num": 1})
  100. client._update_state({"layer_num": 2})
  101. assert client.state.total_layers == 33
  102. assert len(pushalls(client)) == before
  103. def test_the_recovered_total_is_what_downstream_reads(client):
  104. """End-to-end on the reporter's sequence, minus the 9-minute wait.
  105. Start with no total, layers advance at `n/0`, the pushall answers, and
  106. from then on the UI, `{total_layers}` notifications and the usage-split
  107. denominator all see 33 — they read this one field.
  108. """
  109. client._update_state(running_frame())
  110. client._update_state({"layer_num": 1})
  111. assert client.state.total_layers == 0 # the symptom in the screenshot
  112. client._update_state({"layer_num": 2, "total_layer_num": 33})
  113. assert (client.state.layer_num, client.state.total_layers) == (2, 33)
  114. def test_the_pushall_answer_does_not_re_trigger_the_reset(client):
  115. """Loop safety: the answer is a *full* frame, gcode_state and file included.
  116. If that re-tripped the new-print detection it would reset the total it just
  117. delivered and request another pushall, once per round-trip, forever.
  118. """
  119. client._update_state(running_frame())
  120. assert len(pushalls(client)) == 1
  121. client._update_state(running_frame(total_layer_num=33, layer_num=1, mc_percent=3))
  122. assert client.state.total_layers == 33
  123. assert len(pushalls(client)) == 1
  124. # ---------------------------------------------------------------------------
  125. # Interaction with the pre-existing firmware-reset guard
  126. # ---------------------------------------------------------------------------
  127. def test_firmware_reset_to_zero_mid_print_is_still_ignored(client):
  128. """P1S zeroes total_layer_num at print end; #1771's guard keeps the total."""
  129. client._update_state(running_frame(total_layer_num=33))
  130. client._update_state({"layer_num": 33, "total_layer_num": 0})
  131. assert client.state.total_layers == 33
  132. @pytest.mark.parametrize("value", [None, "", 0, "0", -1, "abc", "33.7", [], {}, 3.9])
  133. def test_unusable_totals_do_not_break_ingest(client, value):
  134. """A bad total must not escape `_update_state`.
  135. The old parse did a bare ``int(data["total_layer_num"])``. `_on_message`
  136. catches only `JSONDecodeError` and paho is left at
  137. ``suppress_exceptions = False``, so anything this raised was re-raised on
  138. the network thread and took the printer connection down over one field.
  139. `None`, `[]` and `{}` all did exactly that.
  140. """
  141. client._update_state(running_frame(total_layer_num=value))
  142. assert client.state.total_layers in (0, 3) # 3.9 truncates; the rest are 0
  143. assert client.state.gcode_file == "widget.3mf" # the rest of the frame landed
  144. def test_an_unusable_total_does_not_stop_the_layer_counter(client):
  145. """The read happens before the layer block, so it must not be able to raise.
  146. Otherwise a firmware sending a malformed total would freeze `layer_num` for
  147. the whole print — the frame would abort before reaching it.
  148. """
  149. client._update_state(running_frame())
  150. client._update_state({"layer_num": 7, "total_layer_num": "not-a-number"})
  151. assert client.state.layer_num == 7
  152. def test_a_string_total_is_accepted(client):
  153. """Bambu ships numbers as strings in plenty of other fields."""
  154. client._update_state(running_frame(total_layer_num="33"))
  155. assert client.state.total_layers == 33
  156. def test_a_zero_total_on_the_start_frame_counts_as_no_total(client):
  157. """`total_layer_num: 0` is the firmware's "don't know yet", not a value."""
  158. client.state.total_layers = 120
  159. client._update_state(running_frame(total_layer_num=0))
  160. assert client.state.total_layers == 0
  161. assert len(pushalls(client)) == 1
  162. # ---------------------------------------------------------------------------
  163. # A restarted print (file change while RUNNING) takes the same path
  164. # ---------------------------------------------------------------------------
  165. def test_file_change_while_running_also_keeps_its_own_total(client):
  166. """`is_file_change` shares the reset, so it needs the same treatment."""
  167. client._update_state(running_frame(total_layer_num=33))
  168. client._was_running = True
  169. client._update_state(
  170. {"gcode_state": "RUNNING", "gcode_file": "other.3mf", "subtask_name": "other", "total_layer_num": 77}
  171. )
  172. assert client.state.total_layers == 77