test_mqtt_debug_on_change.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. """Per-frame debug dumps must log transitions, not every frame (#2555).
  2. The state dumps in the push_status handler fired whenever their field was
  3. *present* in the frame. A full push_status carries every field, so they fired on
  4. every frame regardless of whether anything had changed — several while their own
  5. comment claimed to log "when X changes".
  6. On one printer that is ~1.5 lines/s and nobody noticed. On a 19-printer farm it
  7. is ~100 lines/s: the reporter turned on debug logging as asked, and the 5 MB log
  8. rolled over in under five minutes. 27,727 of the 29,830 lines in the support
  9. bundle were these dumps, and the queue problem we were chasing was nowhere in the
  10. window.
  11. """
  12. import logging
  13. from unittest.mock import MagicMock, patch
  14. from backend.app.services.bambu_mqtt import BambuMQTTClient
  15. def _client() -> BambuMQTTClient:
  16. return BambuMQTTClient(ip_address="10.0.0.1", serial_number="SERIAL", access_code="code", model="A1")
  17. class TestDebugOnChange:
  18. def test_repeated_identical_values_log_once(self):
  19. client = _client()
  20. with patch("backend.app.services.bambu_mqtt.logger") as log:
  21. for _ in range(50):
  22. client._debug_on_change("wifi_signal", -52, "[%s] wifi_signal: %s", "SERIAL", -52)
  23. assert log.debug.call_count == 1, (
  24. f"50 identical frames produced {log.debug.call_count} log lines — this is the flood"
  25. )
  26. def test_each_change_is_logged(self):
  27. """Suppressing repeats must not suppress transitions — the transitions are
  28. the entire reason anyone reads these lines."""
  29. client = _client()
  30. with patch("backend.app.services.bambu_mqtt.logger") as log:
  31. for value in (-52, -52, -60, -60, -52):
  32. client._debug_on_change("wifi_signal", value, "[%s] wifi_signal: %s", "SERIAL", value)
  33. assert log.debug.call_count == 3
  34. assert [c.args[-1] for c in log.debug.call_args_list] == [-52, -60, -52]
  35. def test_keys_are_tracked_independently(self):
  36. client = _client()
  37. with patch("backend.app.services.bambu_mqtt.logger") as log:
  38. client._debug_on_change("tray_now", 1, "tray_now: %s", 1)
  39. client._debug_on_change("ams_status", 1, "ams_status: %s", 1)
  40. client._debug_on_change("tray_now", 1, "tray_now: %s", 1) # repeat, suppressed
  41. assert log.debug.call_count == 2, "same value under a different key must not be swallowed"
  42. def test_printers_are_tracked_independently(self):
  43. """State is per-client. Two printers reporting the same value must each
  44. get their own line — a farm is exactly where this matters."""
  45. a, b = _client(), _client()
  46. with patch("backend.app.services.bambu_mqtt.logger") as log:
  47. a._debug_on_change("tray_now", 3, "tray_now: %s", 3)
  48. b._debug_on_change("tray_now", 3, "tray_now: %s", 3)
  49. assert log.debug.call_count == 2
  50. def test_composite_values_detect_a_change_in_any_field(self):
  51. """Messages that render several fields must pass all of them, or a change
  52. in the unwatched field is silently dropped."""
  53. client = _client()
  54. with patch("backend.app.services.bambu_mqtt.logger") as log:
  55. client._debug_on_change("chamber", (40.0, 0.0, False), "chamber %s %s %s", 40.0, 0.0, False)
  56. client._debug_on_change("chamber", (40.0, 60.0, True), "chamber %s %s %s", 40.0, 60.0, True)
  57. assert log.debug.call_count == 2, "target/heating changed while current stayed 40.0 — must still log"
  58. def test_dict_values_compare_by_content(self):
  59. """The AMS dict dump is the biggest line by volume; it is a fresh dict every
  60. frame, so identity comparison would never suppress anything."""
  61. client = _client()
  62. with patch("backend.app.services.bambu_mqtt.logger") as log:
  63. for _ in range(10):
  64. client._debug_on_change("ams", {"tray_now": "0", "bits": "7000000"}, "ams: %s", {})
  65. client._debug_on_change("ams", {"tray_now": "1", "bits": "7000000"}, "ams: %s", {})
  66. assert log.debug.call_count == 2
  67. class TestRuntimeDebugToggle:
  68. """Debug logging is turned on at RUNTIME (POST /support/debug-logging) and these
  69. clients outlive the toggle — which is the whole workflow this change serves:
  70. "enable debug logging, reproduce, send the bundle".
  71. So the cache must not be warmed while running at INFO. If it were, the operator
  72. would enable debug, and every steady-state value would already be "seen" — an
  73. idle printer's bundle would contain none of these lines at all, which is worse
  74. than the flood it replaced.
  75. """
  76. def test_enabling_debug_at_runtime_still_dumps_a_baseline(self):
  77. client = _client()
  78. mqtt_logger = logging.getLogger("backend.app.services.bambu_mqtt")
  79. original = mqtt_logger.level
  80. try:
  81. # Steady state at INFO: the app has been running for hours.
  82. mqtt_logger.setLevel(logging.INFO)
  83. with patch("backend.app.services.bambu_mqtt.logger", wraps=mqtt_logger) as log:
  84. for _ in range(200):
  85. client._debug_on_change("wifi_signal", -52, "wifi %s", -52)
  86. assert log.debug.call_count == 0, "nothing should be emitted at INFO"
  87. # Operator flips debug on. The value has NOT changed — but they turned
  88. # this on to see the printer's state, so the very next frame must dump it.
  89. mqtt_logger.setLevel(logging.DEBUG)
  90. with patch("backend.app.services.bambu_mqtt.logger", wraps=mqtt_logger) as log:
  91. client._debug_on_change("wifi_signal", -52, "wifi %s", -52)
  92. assert log.debug.call_count == 1, (
  93. "no baseline after enabling debug — the cache was warmed while at "
  94. "INFO, so the operator sees nothing until the value happens to change"
  95. )
  96. # ...and it still dedups from there.
  97. for _ in range(50):
  98. client._debug_on_change("wifi_signal", -52, "wifi %s", -52)
  99. assert log.debug.call_count == 1
  100. finally:
  101. mqtt_logger.setLevel(original)
  102. def test_disabling_debug_drops_the_cache(self):
  103. """Off -> on must be as cold as a fresh process, not just first-ever-on."""
  104. client = _client()
  105. mqtt_logger = logging.getLogger("backend.app.services.bambu_mqtt")
  106. original = mqtt_logger.level
  107. try:
  108. mqtt_logger.setLevel(logging.DEBUG)
  109. client._debug_on_change("tray_now", 2, "tray_now %s", 2)
  110. assert client._debug_last
  111. mqtt_logger.setLevel(logging.INFO)
  112. client._debug_on_change("tray_now", 2, "tray_now %s", 2)
  113. assert client._debug_last == {}, "cache must be dropped while debug is off"
  114. finally:
  115. mqtt_logger.setLevel(original)
  116. class TestRealDumpSitesAreGated:
  117. """End-to-end: feed the same push_status frame twice and count the lines."""
  118. def test_identical_push_status_frames_do_not_re_dump_state(self):
  119. # Deliberately the client's real PrinterState, not a mock: a MagicMock
  120. # state would return the same stub object for every attribute read, so
  121. # the values would compare equal and the test would pass even with the
  122. # gating removed.
  123. client = _client()
  124. assert not isinstance(client.state, MagicMock)
  125. frame = {
  126. "print": {
  127. "ams": {
  128. "ams": [],
  129. "ams_exist_bits": "1",
  130. "tray_exist_bits": "f",
  131. "tray_now": "0",
  132. },
  133. "wifi_signal": "-52dBm",
  134. "ipcam": {"ipcam_record": "enable"},
  135. }
  136. }
  137. logging.getLogger("backend.app.services.bambu_mqtt").setLevel(logging.DEBUG)
  138. with patch("backend.app.services.bambu_mqtt.logger") as log:
  139. log.isEnabledFor.return_value = True
  140. client._process_message(dict(frame))
  141. first = [c.args[0] for c in log.debug.call_args_list]
  142. log.debug.reset_mock()
  143. client._process_message(dict(frame))
  144. second = [c.args[0] for c in log.debug.call_args_list]
  145. # Frame 1 must still dump — the point is to log transitions, not to go quiet.
  146. assert first, "the first frame stopped dumping state entirely — the logs are now useless"
  147. # Frame 2 is byte-identical, so it must produce NOTHING. Asserting merely
  148. # "fewer than frame 1" is not enough: a couple of these sites happen to be
  149. # naturally one-shot, so an ungated build still measures 3 < 5 and the
  150. # assertion passes while every real dump keeps firing on every frame.
  151. assert second == [], f"an identical push_status frame re-dumped {len(second)} line(s): {second}"