test_energy_plug_selection_2859.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. """Energy tracking with more than one plug linked to a printer (#2859).
  2. The reporter had two Home Assistant plugs on a P1S -- its own plug plus a dry
  3. box he wanted to switch from the printer card -- and no archive on that printer
  4. ever carried an energy figure, while his single-plug X2D was fine. Both energy
  5. call sites selected every plug for the printer and then called
  6. ``scalar_one_or_none()``, which raises on two rows; the print-start handler
  7. caught that as an ordinary failure, so ``energy_start_kwh`` was never written
  8. and the print-end handler reported "no start kWh recorded" -- indistinguishable
  9. from having no plug at all.
  10. """
  11. from types import SimpleNamespace
  12. from unittest.mock import AsyncMock, patch
  13. import pytest
  14. from backend.app.services.energy_plug import energy_plug_candidates, select_energy_reading
  15. def _plug(plug_id: int, name: str, *, power: bool = True, ha_entity_id: str | None = None) -> SimpleNamespace:
  16. return SimpleNamespace(
  17. id=plug_id,
  18. name=name,
  19. controls_printer_power=power,
  20. plug_type="homeassistant" if ha_entity_id else "tasmota",
  21. ha_entity_id=ha_entity_id,
  22. )
  23. class TestCandidateOrdering:
  24. """Ordering has to be stable: the print-end delta is only meaningful if it
  25. reads the same counter the print-start reading came from."""
  26. @pytest.mark.asyncio
  27. async def test_no_plugs_for_printer(self, db_session, printer_factory):
  28. printer = await printer_factory()
  29. assert await energy_plug_candidates(db_session, printer.id) == []
  30. @pytest.mark.asyncio
  31. async def test_single_plug_is_the_candidate(self, db_session, printer_factory, smart_plug_factory):
  32. printer = await printer_factory()
  33. plug = await smart_plug_factory(name="P1S Power", printer_id=printer.id)
  34. candidates = await energy_plug_candidates(db_session, printer.id)
  35. assert [c.id for c in candidates] == [plug.id]
  36. @pytest.mark.asyncio
  37. async def test_power_plug_sorts_ahead_of_earlier_accessory(self, db_session, printer_factory, smart_plug_factory):
  38. printer = await printer_factory()
  39. accessory = await smart_plug_factory(
  40. name="Dry Box",
  41. plug_type="homeassistant",
  42. printer_id=printer.id,
  43. controls_printer_power=False,
  44. )
  45. power = await smart_plug_factory(
  46. name="P1S Power",
  47. plug_type="homeassistant",
  48. printer_id=printer.id,
  49. controls_printer_power=True,
  50. )
  51. candidates = await energy_plug_candidates(db_session, printer.id)
  52. assert [c.id for c in candidates] == [power.id, accessory.id]
  53. @pytest.mark.asyncio
  54. async def test_ties_break_by_id(self, db_session, printer_factory, smart_plug_factory):
  55. """`controls_printer_power` defaults to on, so two plugs claiming it is
  56. the normal case rather than a misconfiguration."""
  57. printer = await printer_factory()
  58. first = await smart_plug_factory(name="First", plug_type="homeassistant", printer_id=printer.id)
  59. second = await smart_plug_factory(name="Second", plug_type="homeassistant", printer_id=printer.id)
  60. candidates = await energy_plug_candidates(db_session, printer.id)
  61. assert [c.id for c in candidates] == [first.id, second.id]
  62. @pytest.mark.asyncio
  63. async def test_other_printers_plugs_are_not_candidates(self, db_session, printer_factory, smart_plug_factory):
  64. mine = await printer_factory(name="P1S")
  65. theirs = await printer_factory(name="X2D")
  66. plug = await smart_plug_factory(name="P1S Power", printer_id=mine.id)
  67. await smart_plug_factory(name="X2D Power", printer_id=theirs.id)
  68. candidates = await energy_plug_candidates(db_session, mine.id)
  69. assert [c.id for c in candidates] == [plug.id]
  70. @pytest.mark.asyncio
  71. async def test_unlinked_plug_is_not_a_candidate(self, db_session, printer_factory, smart_plug_factory):
  72. printer = await printer_factory()
  73. await smart_plug_factory(name="Bench Plug", printer_id=None)
  74. assert await energy_plug_candidates(db_session, printer.id) == []
  75. @pytest.mark.asyncio
  76. async def test_no_printer_matches_nothing(self, db_session, smart_plug_factory):
  77. """`printer_id == None` would compile to `IS NULL` and hand back every
  78. unlinked plug, so a print could be billed against a bench plug."""
  79. await smart_plug_factory(name="Bench Plug", printer_id=None)
  80. assert await energy_plug_candidates(db_session, None) == []
  81. @pytest.mark.asyncio
  82. async def test_disabled_plug_sorts_last(self, db_session, printer_factory, smart_plug_factory):
  83. printer = await printer_factory()
  84. retired = await smart_plug_factory(
  85. name="Retired Plug",
  86. plug_type="homeassistant",
  87. printer_id=printer.id,
  88. enabled=False,
  89. )
  90. live = await smart_plug_factory(name="P1S Power", plug_type="homeassistant", printer_id=printer.id)
  91. candidates = await energy_plug_candidates(db_session, printer.id)
  92. assert [c.id for c in candidates] == [live.id, retired.id]
  93. @pytest.mark.asyncio
  94. async def test_only_plug_is_used_even_when_disabled(self, db_session, printer_factory, smart_plug_factory):
  95. """Ranking, not filtering: a printer whose single plug is disabled
  96. tracked energy before this module existed and has to keep doing so."""
  97. printer = await printer_factory()
  98. retired = await smart_plug_factory(name="Retired Plug", printer_id=printer.id, enabled=False)
  99. candidates = await energy_plug_candidates(db_session, printer.id)
  100. assert [c.id for c in candidates] == [retired.id]
  101. @pytest.mark.asyncio
  102. async def test_home_assistant_script_sorts_last(self, db_session, printer_factory, smart_plug_factory):
  103. """A `script.*` entity is linked for the automation triggers and has
  104. nothing to meter, so it is asked only if nothing else answers."""
  105. printer = await printer_factory()
  106. script = await smart_plug_factory(
  107. name="Notify Script",
  108. plug_type="homeassistant",
  109. ha_entity_id="script.notify_done",
  110. printer_id=printer.id,
  111. )
  112. plug = await smart_plug_factory(
  113. name="P1S Power",
  114. plug_type="homeassistant",
  115. ha_entity_id="switch.p1s",
  116. printer_id=printer.id,
  117. enabled=False,
  118. )
  119. candidates = await energy_plug_candidates(db_session, printer.id)
  120. # Even disabled, a real switch outranks a script.
  121. assert [c.id for c in candidates] == [plug.id, script.id]
  122. class TestSelectEnergyReading:
  123. @pytest.mark.asyncio
  124. async def test_picks_the_plug_that_reports_a_counter(self):
  125. """The decisive test in practice: an accessory is usually switch-only,
  126. so it drops out without the user configuring anything."""
  127. dry_box = _plug(1, "Dry Box")
  128. printer_plug = _plug(2, "P1S Power")
  129. async def read(plug, _db):
  130. return {"power": 3.0} if plug is dry_box else {"power": 120.0, "total": 41.5}
  131. selected = await select_energy_reading([dry_box, printer_plug], read, db=None)
  132. assert selected is not None
  133. plug, energy = selected
  134. assert plug is printer_plug
  135. assert energy["total"] == 41.5
  136. @pytest.mark.asyncio
  137. async def test_stops_at_the_first_usable_reading(self):
  138. first = _plug(1, "P1S Power")
  139. second = _plug(2, "Dry Box")
  140. seen = []
  141. async def read(plug, _db):
  142. seen.append(plug.name)
  143. return {"total": 1.0}
  144. selected = await select_energy_reading([first, second], read, db=None)
  145. assert selected[0] is first
  146. assert seen == ["P1S Power"]
  147. @pytest.mark.asyncio
  148. async def test_unreachable_plug_does_not_end_the_search(self):
  149. offline = _plug(1, "Offline")
  150. printer_plug = _plug(2, "P1S Power")
  151. async def read(plug, _db):
  152. return None if plug is offline else {"total": 7.0}
  153. selected = await select_energy_reading([offline, printer_plug], read, db=None)
  154. assert selected[0] is printer_plug
  155. @pytest.mark.asyncio
  156. async def test_zero_is_a_reading(self):
  157. """A freshly reset counter is a perfectly good baseline; treating 0 as
  158. missing would drop the first print after a plug replacement."""
  159. plug = _plug(1, "P1S Power")
  160. selected = await select_energy_reading([plug], AsyncMock(return_value={"total": 0.0}), db=None)
  161. assert selected is not None
  162. assert selected[1]["total"] == 0.0
  163. @pytest.mark.asyncio
  164. async def test_none_when_nothing_reports_a_counter(self):
  165. async def read(_plug, _db):
  166. return {"power": 3.0, "total": None}
  167. assert await select_energy_reading([_plug(1, "Dry Box")], read, db=None) is None
  168. @pytest.mark.asyncio
  169. async def test_none_for_an_empty_candidate_list(self):
  170. assert await select_energy_reading([], AsyncMock(), db=None) is None
  171. class TestRecordEnergyStart:
  172. """The reported failure, end to end."""
  173. @pytest.mark.asyncio
  174. async def test_two_plugs_no_longer_lose_the_start_reading(
  175. self, db_session, printer_factory, smart_plug_factory, archive_factory
  176. ):
  177. printer = await printer_factory()
  178. await smart_plug_factory(
  179. name="Dry Box",
  180. plug_type="homeassistant",
  181. printer_id=printer.id,
  182. controls_printer_power=False,
  183. )
  184. await smart_plug_factory(
  185. name="P1S Power",
  186. plug_type="homeassistant",
  187. printer_id=printer.id,
  188. controls_printer_power=True,
  189. )
  190. archive = await archive_factory(printer.id)
  191. from backend.app.main import _record_energy_start
  192. async def read(plug, _db):
  193. return {"power": 120.0, "total": 41.5} if plug.name == "P1S Power" else {"power": 2.0}
  194. with patch("backend.app.main._get_plug_energy", side_effect=read):
  195. recorded = await _record_energy_start(archive, printer.id, db_session)
  196. assert recorded is True
  197. assert archive.energy_start_kwh == 41.5
  198. @pytest.mark.asyncio
  199. async def test_single_plug_is_unchanged(self, db_session, printer_factory, smart_plug_factory, archive_factory):
  200. printer = await printer_factory()
  201. await smart_plug_factory(name="X2D Power", printer_id=printer.id)
  202. archive = await archive_factory(printer.id)
  203. from backend.app.main import _record_energy_start
  204. with patch("backend.app.main._get_plug_energy", AsyncMock(return_value={"total": 12.25})):
  205. recorded = await _record_energy_start(archive, printer.id, db_session)
  206. assert recorded is True
  207. assert archive.energy_start_kwh == 12.25
  208. @pytest.mark.asyncio
  209. async def test_no_plug_records_nothing(self, db_session, printer_factory, archive_factory):
  210. printer = await printer_factory()
  211. archive = await archive_factory(printer.id)
  212. from backend.app.main import _record_energy_start
  213. recorded = await _record_energy_start(archive, printer.id, db_session)
  214. assert recorded is False
  215. assert archive.energy_start_kwh is None
  216. @pytest.mark.asyncio
  217. async def test_names_the_plugs_it_tried_when_none_measures(
  218. self, db_session, printer_factory, smart_plug_factory, archive_factory, capture_logs
  219. ):
  220. """ "No plug reports energy" and "no plug at all" used to log the same
  221. way, which is what made this invisible for the reporter."""
  222. printer = await printer_factory()
  223. await smart_plug_factory(name="Dry Box", plug_type="homeassistant", printer_id=printer.id)
  224. await smart_plug_factory(name="Chamber Light", plug_type="homeassistant", printer_id=printer.id)
  225. archive = await archive_factory(printer.id)
  226. from backend.app.main import _record_energy_start
  227. with patch("backend.app.main._get_plug_energy", AsyncMock(return_value={"power": 1.0})):
  228. recorded = await _record_energy_start(archive, printer.id, db_session)
  229. assert recorded is False
  230. assert archive.energy_start_kwh is None
  231. logged = "\n".join(record.getMessage() for record in capture_logs.get_warnings())
  232. assert "Dry Box" in logged
  233. assert "Chamber Light" in logged