test_hms_severity_2728.py 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. """HMS severity is Bambu's alert level, not the part byte (#2728).
  2. Every payload here is real: the reporter's P2S, the H2C from #1840 and the H2S
  3. cancel echo from #2728's thread, exactly as the report topic sent them.
  4. """
  5. from types import SimpleNamespace
  6. import pytest
  7. from backend.app import main as main_module
  8. from backend.app.main import _hms_errors_to_notify, _hms_fault_counts, _hms_notify_key, _take_new_hms_faults
  9. from backend.app.services.bambu_mqtt import BambuMQTTClient
  10. @pytest.fixture
  11. def client():
  12. return BambuMQTTClient(ip_address="192.168.1.100", serial_number="TESTSEV", access_code="12345678")
  13. def _parse(client, hms: list[dict]):
  14. client._update_state({"hms": hms})
  15. return client.state.hms_errors
  16. @pytest.mark.parametrize(
  17. ("attr", "code", "level", "part_byte"),
  18. [
  19. # P2S (#2728): 0500-0300-0002-000E, part byte 03 -> was shown as 3.
  20. (83886848, 131086, 2, 3),
  21. # P2S (#2728): 0500-0600-0002-0070, part byte 06 -> fell through to "Info".
  22. (83887616, 131184, 2, 6),
  23. # P2S (#2728): 0500-0200-0003-000A, part byte 02 -> was shown as "Serious".
  24. (83886592, 196618, 3, 2),
  25. # H2C (#1840): 0500-0600-0002-0005 paused the printer and read as "Info".
  26. (0x05000600, 0x00020005, 2, 6),
  27. ],
  28. )
  29. def test_severity_is_the_alert_level_in_the_code(client, attr, code, level, part_byte):
  30. """The level is the high half of `code`, as Bambu Studio decodes it; the
  31. old expression read attr bits 8-15, the part byte, and got every one of
  32. these wrong."""
  33. (error,) = _parse(client, [{"attr": attr, "code": code}])
  34. assert (attr >> 8) & 0xFF == part_byte # what used to be reported
  35. assert error.severity == level
  36. def test_a_task_stopping_fault_is_level_one(client):
  37. """0300-0200-0001-0008, nozzle temperature abnormal, a level-1 code in
  38. Bambu's catalogue."""
  39. (error,) = _parse(client, [{"attr": 0x03000200, "code": 0x00010008}])
  40. assert error.severity == 1
  41. assert error.description
  42. def test_level_zero_is_not_promoted(client):
  43. """Bambu defines 0 as an invalid level. It used to be bumped to 2 and so
  44. rendered as a fault; it now stays 0 and reads as unknown."""
  45. (error,) = _parse(client, [{"attr": 0x05000000, "code": 0x00004038}])
  46. assert error.severity == 0
  47. def test_the_h2s_cancel_echo_is_kept_but_has_no_text(client):
  48. """0C00-0100-0002-001B arrives after a user cancel. Bambu lists it with no
  49. text, so the UI does not count it; it cannot be filtered by its short code
  50. because 0C00-0300-0003-001B, a real spaghetti notice, shares that."""
  51. (error,) = _parse(client, [{"attr": 201326848, "code": 131099}])
  52. assert error.full_code == "0C0001000002001B"
  53. assert error.severity == 2
  54. assert error.description is None
  55. @pytest.mark.parametrize(
  56. ("print_error", "level"),
  57. [(0x03004000, 1), (0x03008004, 2), (0x0C00C003, 3)],
  58. )
  59. def test_print_error_takes_its_level_from_the_error_number(client, print_error, level):
  60. """print_error has no level field, and every entry used to get a flat 3."""
  61. client._update_state({"print_error": print_error})
  62. (error,) = client.state.hms_errors
  63. assert error.severity == level
  64. def test_print_error_text_follows_the_printer_model():
  65. """The serial prefix picks the model's own text: 0300_8001 means opposite
  66. things on a 20P and a 093."""
  67. texts = {}
  68. for prefix in ("20P", "093"):
  69. c = BambuMQTTClient(ip_address="192.168.1.100", serial_number=f"{prefix}XXXX", access_code="12345678")
  70. c._update_state({"print_error": 0x03008001})
  71. texts[prefix] = c.state.hms_errors[0].description
  72. assert "paused by the user" in texts["20P"]
  73. assert "pause command" in texts["093"]
  74. def _fault(full_code: str, severity: int = 2, description: str | None = "Described.", actions: list | None = None):
  75. return SimpleNamespace(
  76. full_code=full_code,
  77. severity=severity,
  78. attr=int(full_code[:8], 16),
  79. code=f"0x{int(full_code[8:], 16):x}",
  80. description=description,
  81. actions=actions or [],
  82. )
  83. class TestNotificationFilter:
  84. """ojimpo's note on #2728: the notification path filtered `severity >= 2`.
  85. On the real level that would drop the task-stopping errors."""
  86. def test_errors_and_warnings_are_notified(self):
  87. errors = [_fault("0300020000010008", 1), _fault("0300030000020001", 2)]
  88. keys = {_hms_notify_key(e) for e in errors}
  89. assert _hms_errors_to_notify(errors, keys) == errors
  90. def test_level_zero_is_not(self):
  91. error = _fault("0500000000004038", 0)
  92. assert _hms_errors_to_notify([error], {_hms_notify_key(error)}) == []
  93. class TestWhatCounts:
  94. """One rule for the printer card, notifications and the MQTT relay; the
  95. frontend's filterKnownHMSErrors mirrors it."""
  96. def test_an_hms_notice_without_actions_does_not_count(self):
  97. """0300-9700-0003-0001 "The top cover is open": a printer can hold it
  98. through a whole print, and it must not turn the card into a problem or
  99. send a notification each time it reappears."""
  100. assert not _hms_fault_counts(_fault("0300970000030001", 3, "The top cover is open."))
  101. def test_an_hms_notice_with_actions_counts(self):
  102. """It is waiting on the user, so its buttons have to be reachable."""
  103. assert _hms_fault_counts(_fault("0300970000030001", 3, None, ["OK_BUTTON"]))
  104. def test_a_print_error_prompt_still_counts(self):
  105. """0xCxxx print_error prompts counted before #2728 and still do."""
  106. prompt = SimpleNamespace(full_code="1880C003", severity=3, description="Unable to start drying.", actions=[])
  107. assert _hms_fault_counts(prompt)
  108. def test_a_fault_without_text_or_actions_does_not_count(self):
  109. """The H2S cancel echo, which the relay used to drop only by accident."""
  110. assert not _hms_fault_counts(_fault("0C0001000002001B", 2, None))
  111. def test_an_actionable_fault_without_text_counts(self):
  112. assert _hms_fault_counts(_fault("0500020000020070", 2, None, ["IGNORE_RESUME"]))
  113. def test_level_zero_never_counts(self):
  114. assert not _hms_fault_counts(_fault("0500000000004038", 0, "Text", ["OK_BUTTON"]))
  115. class TestNotificationDeduplication:
  116. """Faults were keyed by `attr` alone, which for an `hms[]` fault is only the
  117. module and part, so two faults on one part shared a key."""
  118. PRINTER = 9001
  119. @pytest.fixture(autouse=True)
  120. def _clean(self):
  121. main_module._notified_hms_errors.pop(self.PRINTER, None)
  122. main_module._hms_last_seen.pop(self.PRINTER, None)
  123. yield
  124. main_module._notified_hms_errors.pop(self.PRINTER, None)
  125. main_module._hms_last_seen.pop(self.PRINTER, None)
  126. def test_two_faults_on_one_part_are_both_notified(self):
  127. """#1840's H2C held both of these at once; they share attr 05000600."""
  128. first, second = _fault("0500060000020005"), _fault("0500060000020006")
  129. assert first.attr == second.attr
  130. assert _take_new_hms_faults(self.PRINTER, [first, second]) == [first, second]
  131. def test_a_fault_replaced_on_the_same_part_is_notified(self):
  132. """The first clears and a different one on the same part appears in the
  133. same update: the old key did not change, so it looked already sent."""
  134. first, second = _fault("0500060000020005"), _fault("0500060000020006")
  135. assert _take_new_hms_faults(self.PRINTER, [first]) == [first]
  136. assert _take_new_hms_faults(self.PRINTER, [second]) == [second]
  137. def test_a_held_fault_is_notified_once(self):
  138. fault = _fault("0500060000020005")
  139. assert _take_new_hms_faults(self.PRINTER, [fault]) == [fault]
  140. assert _take_new_hms_faults(self.PRINTER, [fault]) == []
  141. assert _take_new_hms_faults(self.PRINTER, [fault, _fault("0300020000010008", 1)])[0].full_code == (
  142. "0300020000010008"
  143. )
  144. def test_a_print_error_keeps_its_own_key(self):
  145. """For a print_error the full code is the whole value, as attr was."""
  146. error = SimpleNamespace(full_code="03008004", attr=0x03008004, code="0x8004", severity=2)
  147. assert _hms_notify_key(error) == "03008004"
  148. def test_an_entry_without_a_full_code_still_tells_faults_apart(self):
  149. a = SimpleNamespace(full_code="", attr=0x05000600, code="0x20005", severity=2)
  150. b = SimpleNamespace(full_code="", attr=0x05000600, code="0x20006", severity=2)
  151. assert _hms_notify_key(a) != _hms_notify_key(b)