test_ams_humidity_index_guard_3140.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. """The 1-5 humidity index is never shown or stored as a percentage (#3140).
  2. Bambu sends ``humidity_raw`` (relative humidity, percent) and ``humidity`` (a
  3. 1-5 drop index). The index runs the other way -- OpenBambuAPI's push_info
  4. sample pairs ``humidity:30%`` with ``humidity_idx:4`` -- so substituting one
  5. for the other inverts the reading rather than approximating it. A unit sending
  6. only the index used to render as "2%" in the good/green band while being the
  7. second-wettest of the five steps, chart an average of index values as a
  8. percentage, and sit under every alarm threshold forever.
  9. The reporting install ran X1Plus, which Bambuddy does not support, and no
  10. stock-firmware printer is on record as sending the index alone. The guard is
  11. kept regardless because it is about what we do when the field is missing for
  12. any reason, and showing a number we cannot interpret is worse than showing
  13. none: every consumer of these serializers already handles ``None`` by hiding
  14. the indicator, skipping the unit or leaving a gap in the chart.
  15. """
  16. import asyncio
  17. import logging
  18. from unittest.mock import AsyncMock, MagicMock, patch
  19. import pytest
  20. from sqlalchemy import select
  21. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  22. import backend.app.main as main
  23. from backend.app.api.routes.printers import get_printer_status
  24. from backend.app.models.ams_history import AMSSensorHistory
  25. from backend.app.models.printer import Printer
  26. from backend.app.services.bambu_mqtt import PrinterState
  27. from backend.app.services.printer_manager import printer_state_to_dict
  28. from backend.app.utils.ams_humidity import ams_humidity_percent
  29. # --- the reading itself ---
  30. def test_a_reported_percentage_is_the_reading():
  31. assert ams_humidity_percent({"humidity_raw": 45}) == 45.0
  32. assert ams_humidity_percent({"humidity_raw": "45"}) == 45.0
  33. def test_a_fractional_percentage_survives():
  34. """``int("16.5")`` raised, which sent the old code to the index fallback --
  35. so a sensor reporting one decimal place read as a drop index."""
  36. assert ams_humidity_percent({"humidity_raw": "16.5"}) == 16.5
  37. def test_the_index_alone_is_not_a_reading():
  38. """The guard. Index 2 is the second-wettest step, and 2% is as dry as a
  39. unit can read -- the two are not interchangeable in either direction."""
  40. assert ams_humidity_percent({"humidity": 2}) is None
  41. assert ams_humidity_percent({"humidity": "2"}) is None
  42. def test_the_percentage_wins_when_both_are_present():
  43. assert ams_humidity_percent({"humidity": 4, "humidity_raw": "62"}) == 62.0
  44. def test_a_genuine_zero_is_a_reading():
  45. """Not ``None``: the history writer used to test truthiness, so a unit
  46. reading 0% stored NULL while the same pass wrote 0.0 to the other column."""
  47. assert ams_humidity_percent({"humidity_raw": 0}) == 0.0
  48. assert ams_humidity_percent({"humidity_raw": "0"}) == 0.0
  49. def test_an_unparseable_percentage_is_not_a_licence_to_use_the_index():
  50. assert ams_humidity_percent({"humidity_raw": "n/a", "humidity": 3}) is None
  51. assert ams_humidity_percent({"humidity_raw": None, "humidity": 3}) is None
  52. def test_a_unit_that_is_not_a_mapping_reads_as_no_unit():
  53. assert ams_humidity_percent(None) is None
  54. assert ams_humidity_percent("ams0") is None
  55. # --- what the two serializers of the same card report ---
  56. def _index_only_unit() -> dict:
  57. return {
  58. "ams": [
  59. {
  60. "id": 0,
  61. "humidity": "2", # index, no humidity_raw
  62. "temp": "24.0",
  63. "tray": [{"id": 0, "tray_type": "PLA"}],
  64. }
  65. ]
  66. }
  67. def test_the_websocket_serializer_reports_no_humidity_for_an_index_only_unit():
  68. result = printer_state_to_dict(PrinterState(connected=True, state="IDLE", raw_data=_index_only_unit()))
  69. assert result["ams"][0]["humidity"] is None
  70. assert result["ams"][0]["temp"] == "24.0" # the other sensor is unaffected
  71. @pytest.mark.asyncio
  72. async def test_the_rest_serializer_reports_no_humidity_for_an_index_only_unit(db_session):
  73. """The two serializers feed the same card and must not answer differently."""
  74. printer = Printer(name="X1C", serial_number="S-3140", ip_address="1.1.1.1", access_code="c", model="X1C")
  75. db_session.add(printer)
  76. await db_session.commit()
  77. state = PrinterState(connected=True, state="IDLE", raw_data=_index_only_unit())
  78. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  79. mock_pm.get_status.return_value = state
  80. mock_pm.get_drying_targets.return_value = {}
  81. status = await get_printer_status(printer.id, db=db_session)
  82. assert status.ams[0].humidity is None
  83. @pytest.mark.asyncio
  84. async def test_the_rest_serializer_still_reports_a_percentage(db_session):
  85. """The guard must not cost the supported case its reading."""
  86. printer = Printer(name="H2D", serial_number="S-3140b", ip_address="1.1.1.2", access_code="c", model="H2D")
  87. db_session.add(printer)
  88. await db_session.commit()
  89. raw = _index_only_unit()
  90. raw["ams"][0]["humidity_raw"] = "38"
  91. state = PrinterState(connected=True, state="IDLE", raw_data=raw)
  92. with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
  93. mock_pm.get_status.return_value = state
  94. mock_pm.get_drying_targets.return_value = {}
  95. status = await get_printer_status(printer.id, db=db_session)
  96. assert status.ams[0].humidity == 38
  97. # --- what the recorder writes, and what it alarms on ---
  98. def _state_with(unit: dict) -> PrinterState:
  99. return PrinterState(connected=True, state="IDLE", raw_data={"ams": [unit]})
  100. async def _run_one_pass(test_engine, unit: dict):
  101. """One pass of record_ams_history against a single AMS unit.
  102. Same shape as test_ams_temp_alarm_dispatch_2905: the loop is a no-arg
  103. infinite task, so a fake sleep that recognises its own intervals runs
  104. exactly one pass and then cancels it.
  105. """
  106. real_sleep = asyncio.sleep
  107. async def fake_sleep(seconds):
  108. if seconds == 10: # startup wait before the first pass
  109. return
  110. if seconds == main.AMS_HISTORY_INTERVAL: # pass finished cleanly
  111. raise asyncio.CancelledError
  112. if seconds == 60: # the loop's own except-Exception backoff
  113. raise AssertionError("record_ams_history raised; check the warning log")
  114. await real_sleep(seconds)
  115. service = MagicMock()
  116. service.on_ams_humidity_high = AsyncMock()
  117. service.on_ams_ht_humidity_high = AsyncMock()
  118. service.on_ams_temperature_high = AsyncMock()
  119. service.on_ams_ht_temperature_high = AsyncMock()
  120. maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  121. cooldown_before = dict(main._ams_alarm_cooldown)
  122. counter_before = main._ams_cleanup_counter
  123. main._ams_alarm_cooldown.clear()
  124. try:
  125. with (
  126. patch.object(main, "async_session", maker),
  127. patch.object(main, "notification_service", service),
  128. patch.object(main.printer_manager, "get_status", return_value=_state_with(unit)),
  129. patch.object(main.asyncio, "sleep", fake_sleep),
  130. ):
  131. await main.record_ams_history()
  132. finally:
  133. main._ams_alarm_cooldown.clear()
  134. main._ams_alarm_cooldown.update(cooldown_before)
  135. main._ams_cleanup_counter = counter_before
  136. return service
  137. async def _rows(db_session, printer_id: int) -> list[AMSSensorHistory]:
  138. result = await db_session.execute(select(AMSSensorHistory).where(AMSSensorHistory.printer_id == printer_id))
  139. return list(result.scalars().all())
  140. async def _printer(db_session, serial: str) -> Printer:
  141. printer = Printer(name="X1C", serial_number=serial, ip_address="1.1.1.1", access_code="c", model="X1C")
  142. db_session.add(printer)
  143. await db_session.commit()
  144. return printer
  145. @pytest.mark.asyncio
  146. async def test_an_index_only_unit_charts_a_gap_not_a_percentage(db_session, test_engine):
  147. """The temperature is still worth recording, so the row is written -- but
  148. with no humidity, which the chart draws as a gap rather than as a flat 2%
  149. line in the good band."""
  150. printer = await _printer(db_session, "S-3140c")
  151. service = await _run_one_pass(
  152. test_engine,
  153. {"id": 0, "humidity": "2", "temp": "24.0", "tray_exist_bits": "1", "tray": [{"tray_type": "PLA"}]},
  154. )
  155. rows = await _rows(db_session, printer.id)
  156. assert len(rows) == 1
  157. assert rows[0].humidity is None
  158. assert rows[0].humidity_raw is None
  159. assert rows[0].temperature == 24.0
  160. assert service.on_ams_humidity_high.await_count == 0
  161. @pytest.mark.asyncio
  162. async def test_an_index_only_unit_says_so_in_the_log_once(db_session, test_engine, caplog):
  163. """A blank humidity field on a supported printer would otherwise be silent.
  164. Nothing on record says a supported printer sends the index alone, and the
  165. guard makes such a unit stop reporting humidity entirely -- so it names
  166. itself in the log, once per unit, rather than leaving the card blank with
  167. no explanation anywhere.
  168. """
  169. printer = await _printer(db_session, "S-3140f")
  170. main._ams_index_only_logged.clear()
  171. unit = {"id": 0, "humidity": "2", "temp": "24.0", "tray_exist_bits": "1", "tray": [{"tray_type": "PLA"}]}
  172. try:
  173. with caplog.at_level(logging.INFO, logger="backend.app.main"):
  174. await _run_one_pass(test_engine, unit)
  175. await _run_one_pass(test_engine, unit)
  176. finally:
  177. main._ams_index_only_logged.clear()
  178. lines = [r.getMessage() for r in caplog.records if "humidity index" in r.getMessage()]
  179. assert len(lines) == 1
  180. assert printer.name in lines[0]
  181. assert "#3140" in lines[0]
  182. @pytest.mark.asyncio
  183. async def test_a_unit_reporting_no_humidity_at_all_is_not_logged(db_session, test_engine, caplog):
  184. """The note is about a unit whose index we are declining to use. A unit
  185. that sends neither field is not new and has nothing to report."""
  186. await _printer(db_session, "S-3140g")
  187. main._ams_index_only_logged.clear()
  188. try:
  189. with caplog.at_level(logging.INFO, logger="backend.app.main"):
  190. await _run_one_pass(
  191. test_engine,
  192. {"id": 0, "temp": "24.0", "tray_exist_bits": "1", "tray": [{"tray_type": "PLA"}]},
  193. )
  194. finally:
  195. main._ams_index_only_logged.clear()
  196. assert not [r for r in caplog.records if "humidity index" in r.getMessage()]
  197. @pytest.mark.asyncio
  198. async def test_a_zero_percent_reading_is_stored_in_both_columns(db_session, test_engine):
  199. """``float(raw) if raw else None`` stored NULL for a genuine 0%, while the
  200. same pass wrote 0.0 to ``humidity`` -- one row disagreeing with itself.
  201. Numeric 0, not "0": the truthiness test only swallowed the reading when the
  202. firmware sent the value as a number, which is how the defect survived the
  203. string-valued samples every other test here uses."""
  204. printer = await _printer(db_session, "S-3140d")
  205. await _run_one_pass(
  206. test_engine,
  207. {
  208. "id": 0,
  209. "humidity": "5",
  210. "humidity_raw": 0,
  211. "temp": "24.0",
  212. "tray_exist_bits": "1",
  213. "tray": [{"tray_type": "PLA"}],
  214. },
  215. )
  216. rows = await _rows(db_session, printer.id)
  217. assert len(rows) == 1
  218. assert rows[0].humidity == 0.0
  219. assert rows[0].humidity_raw == 0.0
  220. @pytest.mark.asyncio
  221. async def test_a_reported_percentage_still_alarms(db_session, test_engine):
  222. """The supported path, asserted alongside the guard so a regression that
  223. silenced every humidity alarm could not pass as the fix."""
  224. printer = await _printer(db_session, "S-3140e")
  225. service = await _run_one_pass(
  226. test_engine,
  227. {
  228. "id": 0,
  229. "humidity": "1",
  230. "humidity_raw": "80",
  231. "temp": "24.0",
  232. "tray_exist_bits": "1",
  233. "tray": [{"tray_type": "PLA"}],
  234. },
  235. )
  236. service.on_ams_humidity_high.assert_awaited_once()
  237. assert service.on_ams_humidity_high.await_args.args[3] == 80.0
  238. rows = await _rows(db_session, printer.id)
  239. assert rows[0].humidity == 80.0