test_main_plug_rank_2830.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. """Choosing which of a printer's plugs is "the" power plug (#2830).
  2. The printer card's Power row carries the power on/off and auto-off-after-print
  3. controls, so it has to land on the plug that actually feeds the printer. It used
  4. to take the first non-script row the database happened to return, which put an
  5. exhaust fan -- flagged as not powering the printer, and explicitly hidden from
  6. the card -- in front of the outlet the printer is plugged into.
  7. """
  8. from types import SimpleNamespace
  9. import pytest
  10. from backend.app.api.routes.smart_plugs import (
  11. _can_be_switched,
  12. _is_script_plug,
  13. _main_plug_rank,
  14. _pick_main_plug,
  15. _reports_power,
  16. )
  17. pytestmark = pytest.mark.unit
  18. def _plug(plug_id=1, **kwargs):
  19. """A plug with every flag at its model default, overridable per test."""
  20. defaults = {
  21. "id": plug_id,
  22. "name": f"Plug {plug_id}",
  23. "plug_type": "tasmota",
  24. "ha_entity_id": None,
  25. "ha_power_entity": None,
  26. "mqtt_topic": None,
  27. "mqtt_power_topic": None,
  28. "rest_power_path": None,
  29. "controls_printer_power": True,
  30. "enabled": True,
  31. "show_on_printer_card": True,
  32. }
  33. return SimpleNamespace(**{**defaults, **kwargs})
  34. class TestTheReportedCase:
  35. def test_the_printers_outlet_beats_an_exhaust_fan(self):
  36. """The reporter's two Home Assistant plugs on one X1C. The fan was
  37. created first, so with no ranking it won on row order alone."""
  38. fan = _plug(
  39. 1,
  40. name="Print Farm Exhaust Fan",
  41. plug_type="homeassistant",
  42. ha_entity_id="switch.print_farm_exhaust_fan",
  43. controls_printer_power=False,
  44. show_on_printer_card=False,
  45. )
  46. outlet = _plug(
  47. 2,
  48. name="Bambu X1C Outlet",
  49. plug_type="homeassistant",
  50. ha_entity_id="switch.bambu_x1c_outlet",
  51. ha_power_entity="sensor.bambu_x1c_outlet_power",
  52. )
  53. assert _pick_main_plug([fan, outlet]) is outlet
  54. def test_and_it_wins_whichever_order_they_arrive_in(self):
  55. fan = _plug(1, plug_type="homeassistant", ha_entity_id="switch.fan", controls_printer_power=False)
  56. outlet = _plug(2, plug_type="homeassistant", ha_entity_id="switch.outlet")
  57. assert _pick_main_plug([outlet, fan]) is outlet
  58. class TestTheOrderOfPreference:
  59. def test_a_switch_beats_a_script(self):
  60. """A script cannot be switched off, so it can never be the power plug."""
  61. script = _plug(1, plug_type="homeassistant", ha_entity_id="script.start_print")
  62. switch = _plug(2, plug_type="homeassistant", ha_entity_id="switch.outlet")
  63. assert _pick_main_plug([script, switch]) is switch
  64. def test_a_switchable_plug_beats_a_monitor_only_mqtt_plug(self):
  65. """``control_smart_plug`` rejects MQTT plugs as monitor-only, so the
  66. row's on/off button would answer with an error. An MQTT plug is also
  67. the kind that reports watts, so it would otherwise win on rank 5 --
  68. this is the one pair where the tiebreak could have done real harm."""
  69. monitor = _plug(1, plug_type="mqtt", mqtt_power_topic="tele/printer/SENSOR")
  70. switch = _plug(2, plug_type="homeassistant", ha_entity_id="switch.outlet")
  71. assert _pick_main_plug([monitor, switch]) is switch
  72. def test_a_monitor_only_plug_still_holds_the_row_on_its_own(self):
  73. """Rank, not filter: a printer whose only plug is an MQTT monitor keeps
  74. the wattage readout it has always had."""
  75. monitor = _plug(1, plug_type="mqtt", mqtt_power_topic="tele/printer/SENSOR")
  76. assert _pick_main_plug([monitor]) is monitor
  77. def test_a_power_plug_beats_an_accessory(self):
  78. accessory = _plug(1, controls_printer_power=False)
  79. outlet = _plug(2)
  80. assert _pick_main_plug([accessory, outlet]) is outlet
  81. def test_an_enabled_plug_beats_a_disabled_one(self):
  82. """A disabled plug ignores automation -- its auto-off toggle on the card
  83. would sit there doing nothing."""
  84. disabled = _plug(1, enabled=False)
  85. live = _plug(2)
  86. assert _pick_main_plug([disabled, live]) is live
  87. def test_a_visible_plug_beats_a_hidden_one(self):
  88. hidden = _plug(1, show_on_printer_card=False)
  89. visible = _plug(2)
  90. assert _pick_main_plug([hidden, visible]) is visible
  91. def test_powering_the_printer_outranks_being_visible(self):
  92. """The two only disagree when someone hides the plug that really feeds
  93. the printer. Letting the display flag win would hand the power buttons
  94. to an accessory, which is the harm #2629 fixed for the scheduler."""
  95. visible_accessory = _plug(1, controls_printer_power=False, show_on_printer_card=True)
  96. hidden_outlet = _plug(2, controls_printer_power=True, show_on_printer_card=False)
  97. assert _pick_main_plug([visible_accessory, hidden_outlet]) is hidden_outlet
  98. def test_a_plug_that_reports_watts_breaks_a_tie(self):
  99. """Otherwise the row reads "--" while a plug that knows the answer sits
  100. one rank below it."""
  101. mute = _plug(1, plug_type="homeassistant", ha_entity_id="switch.a")
  102. metered = _plug(2, plug_type="homeassistant", ha_entity_id="switch.b", ha_power_entity="sensor.b_power")
  103. assert _pick_main_plug([mute, metered]) is metered
  104. def test_reporting_watts_does_not_outrank_powering_the_printer(self):
  105. metered_accessory = _plug(1, ha_power_entity="sensor.fan_power", controls_printer_power=False)
  106. mute_outlet = _plug(2, plug_type="homeassistant", ha_entity_id="switch.outlet")
  107. assert _pick_main_plug([metered_accessory, mute_outlet]) is mute_outlet
  108. class TestDeterminism:
  109. def test_equal_plugs_resolve_by_id(self):
  110. """The query had no ORDER BY, so on Postgres an unrelated UPDATE could
  111. move a row and silently swap which plug the card called the printer's
  112. power."""
  113. first = _plug(1)
  114. second = _plug(2)
  115. assert _pick_main_plug([second, first]) is first
  116. def test_the_documented_order_holds_end_to_end(self):
  117. """One plug per criterion, each failing only that one, ranked together.
  118. Pairwise tests would pass against an order with the middle two swapped."""
  119. ideal = _plug(6)
  120. mute = _plug(5, plug_type="homeassistant", ha_entity_id="switch.mute")
  121. hidden = _plug(4, show_on_printer_card=False)
  122. disabled = _plug(3, enabled=False)
  123. accessory = _plug(2, controls_printer_power=False)
  124. script = _plug(1, plug_type="homeassistant", ha_entity_id="script.a")
  125. ranked = sorted([script, accessory, disabled, hidden, mute, ideal], key=_main_plug_rank)
  126. assert ranked == [ideal, mute, hidden, disabled, accessory, script]
  127. class TestNoPlugs:
  128. def test_no_plugs_means_no_main_plug(self):
  129. assert _pick_main_plug([]) is None
  130. def test_all_scripts_still_yields_one(self):
  131. """Pre-existing behaviour: a printer whose only entities are scripts
  132. still gets a Power row, because the card nests everything else inside
  133. it. Ranking must not turn that into an empty card."""
  134. second = _plug(2, plug_type="homeassistant", ha_entity_id="script.b")
  135. first = _plug(1, plug_type="homeassistant", ha_entity_id="script.a")
  136. assert _pick_main_plug([second, first]) is first
  137. class TestSwitchability:
  138. @pytest.mark.parametrize(
  139. "plug,expected",
  140. [
  141. (_plug(plug_type="tasmota"), True),
  142. (_plug(plug_type="rest"), True),
  143. (_plug(plug_type="homeassistant", ha_entity_id="switch.outlet"), True),
  144. (_plug(plug_type="homeassistant", ha_entity_id="light.chamber"), True),
  145. (_plug(plug_type="homeassistant", ha_entity_id="script.start"), False),
  146. (_plug(plug_type="mqtt", mqtt_topic="zigbee2mqtt/plug"), False),
  147. ],
  148. )
  149. def test_matches_what_the_control_endpoint_accepts(self, plug, expected):
  150. assert _can_be_switched(plug) is expected
  151. class TestUnsetFlags:
  152. """An upgraded database adds these columns by ALTER, so they are nullable
  153. there even though a fresh one declares them NOT NULL. Every row is
  154. backfilled with the default and nothing writes a null, but a rank that
  155. raised on one would take down the whole printers page."""
  156. def test_a_plug_with_null_flags_ranks_last_rather_than_raising(self):
  157. unset = _plug(1, controls_printer_power=None, enabled=None, show_on_printer_card=None)
  158. ordinary = _plug(2)
  159. assert _pick_main_plug([unset, ordinary]) is ordinary
  160. def test_and_still_holds_the_row_when_it_is_the_only_plug(self):
  161. unset = _plug(1, controls_printer_power=None, enabled=None, show_on_printer_card=None)
  162. assert _pick_main_plug([unset]) is unset
  163. def test_a_plug_with_no_type_at_all_does_not_raise(self):
  164. assert _pick_main_plug([_plug(1, plug_type=None)]) is not None
  165. class TestScriptDetection:
  166. @pytest.mark.parametrize(
  167. "entity_id,expected",
  168. [
  169. ("script.start_print", True),
  170. ("switch.outlet", False),
  171. ("light.chamber", False),
  172. (None, False),
  173. ],
  174. )
  175. def test_only_ha_script_entities_count(self, entity_id, expected):
  176. assert _is_script_plug(_plug(plug_type="homeassistant", ha_entity_id=entity_id)) is expected
  177. def test_a_tasmota_plug_is_never_a_script(self):
  178. assert _is_script_plug(_plug(plug_type="tasmota", ha_entity_id="script.confusing")) is False
  179. class TestPowerCapability:
  180. """Read from configuration, not measured -- this runs on every card render.
  181. It is a floor: an HA plug with no dedicated sensor may still report watts
  182. from the switch entity's own ``current_power_w`` attribute, which only a
  183. live read would show. Good enough for a tiebreak, and it never decides
  184. anything on its own.
  185. """
  186. def test_tasmota_reports_power_natively(self):
  187. assert _reports_power(_plug(plug_type="tasmota")) is True
  188. def test_home_assistant_needs_a_power_sensor(self):
  189. assert _reports_power(_plug(plug_type="homeassistant", ha_entity_id="switch.a")) is False
  190. assert _reports_power(_plug(plug_type="homeassistant", ha_power_entity="sensor.a_power")) is True
  191. def test_mqtt_accepts_either_the_new_or_the_legacy_topic(self):
  192. assert _reports_power(_plug(plug_type="mqtt")) is False
  193. assert _reports_power(_plug(plug_type="mqtt", mqtt_power_topic="tele/plug/SENSOR")) is True
  194. assert _reports_power(_plug(plug_type="mqtt", mqtt_topic="zigbee2mqtt/plug")) is True
  195. def test_rest_needs_a_power_path(self):
  196. """The REST service returns no energy at all without one, so a URL on
  197. its own proves nothing."""
  198. assert _reports_power(_plug(plug_type="rest")) is False
  199. assert _reports_power(_plug(plug_type="rest", rest_power_path="apower")) is True