|
@@ -0,0 +1,259 @@
|
|
|
|
|
+"""Choosing which of a printer's plugs is "the" power plug (#2830).
|
|
|
|
|
+
|
|
|
|
|
+The printer card's Power row carries the power on/off and auto-off-after-print
|
|
|
|
|
+controls, so it has to land on the plug that actually feeds the printer. It used
|
|
|
|
|
+to take the first non-script row the database happened to return, which put an
|
|
|
|
|
+exhaust fan -- flagged as not powering the printer, and explicitly hidden from
|
|
|
|
|
+the card -- in front of the outlet the printer is plugged into.
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+from types import SimpleNamespace
|
|
|
|
|
+
|
|
|
|
|
+import pytest
|
|
|
|
|
+
|
|
|
|
|
+from backend.app.api.routes.smart_plugs import (
|
|
|
|
|
+ _can_be_switched,
|
|
|
|
|
+ _is_script_plug,
|
|
|
|
|
+ _main_plug_rank,
|
|
|
|
|
+ _pick_main_plug,
|
|
|
|
|
+ _reports_power,
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+pytestmark = pytest.mark.unit
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _plug(plug_id=1, **kwargs):
|
|
|
|
|
+ """A plug with every flag at its model default, overridable per test."""
|
|
|
|
|
+ defaults = {
|
|
|
|
|
+ "id": plug_id,
|
|
|
|
|
+ "name": f"Plug {plug_id}",
|
|
|
|
|
+ "plug_type": "tasmota",
|
|
|
|
|
+ "ha_entity_id": None,
|
|
|
|
|
+ "ha_power_entity": None,
|
|
|
|
|
+ "mqtt_topic": None,
|
|
|
|
|
+ "mqtt_power_topic": None,
|
|
|
|
|
+ "rest_power_path": None,
|
|
|
|
|
+ "controls_printer_power": True,
|
|
|
|
|
+ "enabled": True,
|
|
|
|
|
+ "show_on_printer_card": True,
|
|
|
|
|
+ }
|
|
|
|
|
+ return SimpleNamespace(**{**defaults, **kwargs})
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class TestTheReportedCase:
|
|
|
|
|
+ def test_the_printers_outlet_beats_an_exhaust_fan(self):
|
|
|
|
|
+ """The reporter's two Home Assistant plugs on one X1C. The fan was
|
|
|
|
|
+ created first, so with no ranking it won on row order alone."""
|
|
|
|
|
+ fan = _plug(
|
|
|
|
|
+ 1,
|
|
|
|
|
+ name="Print Farm Exhaust Fan",
|
|
|
|
|
+ plug_type="homeassistant",
|
|
|
|
|
+ ha_entity_id="switch.print_farm_exhaust_fan",
|
|
|
|
|
+ controls_printer_power=False,
|
|
|
|
|
+ show_on_printer_card=False,
|
|
|
|
|
+ )
|
|
|
|
|
+ outlet = _plug(
|
|
|
|
|
+ 2,
|
|
|
|
|
+ name="Bambu X1C Outlet",
|
|
|
|
|
+ plug_type="homeassistant",
|
|
|
|
|
+ ha_entity_id="switch.bambu_x1c_outlet",
|
|
|
|
|
+ ha_power_entity="sensor.bambu_x1c_outlet_power",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ assert _pick_main_plug([fan, outlet]) is outlet
|
|
|
|
|
+
|
|
|
|
|
+ def test_and_it_wins_whichever_order_they_arrive_in(self):
|
|
|
|
|
+ fan = _plug(1, plug_type="homeassistant", ha_entity_id="switch.fan", controls_printer_power=False)
|
|
|
|
|
+ outlet = _plug(2, plug_type="homeassistant", ha_entity_id="switch.outlet")
|
|
|
|
|
+
|
|
|
|
|
+ assert _pick_main_plug([outlet, fan]) is outlet
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class TestTheOrderOfPreference:
|
|
|
|
|
+ def test_a_switch_beats_a_script(self):
|
|
|
|
|
+ """A script cannot be switched off, so it can never be the power plug."""
|
|
|
|
|
+ script = _plug(1, plug_type="homeassistant", ha_entity_id="script.start_print")
|
|
|
|
|
+ switch = _plug(2, plug_type="homeassistant", ha_entity_id="switch.outlet")
|
|
|
|
|
+
|
|
|
|
|
+ assert _pick_main_plug([script, switch]) is switch
|
|
|
|
|
+
|
|
|
|
|
+ def test_a_switchable_plug_beats_a_monitor_only_mqtt_plug(self):
|
|
|
|
|
+ """``control_smart_plug`` rejects MQTT plugs as monitor-only, so the
|
|
|
|
|
+ row's on/off button would answer with an error. An MQTT plug is also
|
|
|
|
|
+ the kind that reports watts, so it would otherwise win on rank 5 --
|
|
|
|
|
+ this is the one pair where the tiebreak could have done real harm."""
|
|
|
|
|
+ monitor = _plug(1, plug_type="mqtt", mqtt_power_topic="tele/printer/SENSOR")
|
|
|
|
|
+ switch = _plug(2, plug_type="homeassistant", ha_entity_id="switch.outlet")
|
|
|
|
|
+
|
|
|
|
|
+ assert _pick_main_plug([monitor, switch]) is switch
|
|
|
|
|
+
|
|
|
|
|
+ def test_a_monitor_only_plug_still_holds_the_row_on_its_own(self):
|
|
|
|
|
+ """Rank, not filter: a printer whose only plug is an MQTT monitor keeps
|
|
|
|
|
+ the wattage readout it has always had."""
|
|
|
|
|
+ monitor = _plug(1, plug_type="mqtt", mqtt_power_topic="tele/printer/SENSOR")
|
|
|
|
|
+
|
|
|
|
|
+ assert _pick_main_plug([monitor]) is monitor
|
|
|
|
|
+
|
|
|
|
|
+ def test_a_power_plug_beats_an_accessory(self):
|
|
|
|
|
+ accessory = _plug(1, controls_printer_power=False)
|
|
|
|
|
+ outlet = _plug(2)
|
|
|
|
|
+
|
|
|
|
|
+ assert _pick_main_plug([accessory, outlet]) is outlet
|
|
|
|
|
+
|
|
|
|
|
+ def test_an_enabled_plug_beats_a_disabled_one(self):
|
|
|
|
|
+ """A disabled plug ignores automation -- its auto-off toggle on the card
|
|
|
|
|
+ would sit there doing nothing."""
|
|
|
|
|
+ disabled = _plug(1, enabled=False)
|
|
|
|
|
+ live = _plug(2)
|
|
|
|
|
+
|
|
|
|
|
+ assert _pick_main_plug([disabled, live]) is live
|
|
|
|
|
+
|
|
|
|
|
+ def test_a_visible_plug_beats_a_hidden_one(self):
|
|
|
|
|
+ hidden = _plug(1, show_on_printer_card=False)
|
|
|
|
|
+ visible = _plug(2)
|
|
|
|
|
+
|
|
|
|
|
+ assert _pick_main_plug([hidden, visible]) is visible
|
|
|
|
|
+
|
|
|
|
|
+ def test_powering_the_printer_outranks_being_visible(self):
|
|
|
|
|
+ """The two only disagree when someone hides the plug that really feeds
|
|
|
|
|
+ the printer. Letting the display flag win would hand the power buttons
|
|
|
|
|
+ to an accessory, which is the harm #2629 fixed for the scheduler."""
|
|
|
|
|
+ visible_accessory = _plug(1, controls_printer_power=False, show_on_printer_card=True)
|
|
|
|
|
+ hidden_outlet = _plug(2, controls_printer_power=True, show_on_printer_card=False)
|
|
|
|
|
+
|
|
|
|
|
+ assert _pick_main_plug([visible_accessory, hidden_outlet]) is hidden_outlet
|
|
|
|
|
+
|
|
|
|
|
+ def test_a_plug_that_reports_watts_breaks_a_tie(self):
|
|
|
|
|
+ """Otherwise the row reads "--" while a plug that knows the answer sits
|
|
|
|
|
+ one rank below it."""
|
|
|
|
|
+ mute = _plug(1, plug_type="homeassistant", ha_entity_id="switch.a")
|
|
|
|
|
+ metered = _plug(2, plug_type="homeassistant", ha_entity_id="switch.b", ha_power_entity="sensor.b_power")
|
|
|
|
|
+
|
|
|
|
|
+ assert _pick_main_plug([mute, metered]) is metered
|
|
|
|
|
+
|
|
|
|
|
+ def test_reporting_watts_does_not_outrank_powering_the_printer(self):
|
|
|
|
|
+ metered_accessory = _plug(1, ha_power_entity="sensor.fan_power", controls_printer_power=False)
|
|
|
|
|
+ mute_outlet = _plug(2, plug_type="homeassistant", ha_entity_id="switch.outlet")
|
|
|
|
|
+
|
|
|
|
|
+ assert _pick_main_plug([metered_accessory, mute_outlet]) is mute_outlet
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class TestDeterminism:
|
|
|
|
|
+ def test_equal_plugs_resolve_by_id(self):
|
|
|
|
|
+ """The query had no ORDER BY, so on Postgres an unrelated UPDATE could
|
|
|
|
|
+ move a row and silently swap which plug the card called the printer's
|
|
|
|
|
+ power."""
|
|
|
|
|
+ first = _plug(1)
|
|
|
|
|
+ second = _plug(2)
|
|
|
|
|
+
|
|
|
|
|
+ assert _pick_main_plug([second, first]) is first
|
|
|
|
|
+
|
|
|
|
|
+ def test_the_documented_order_holds_end_to_end(self):
|
|
|
|
|
+ """One plug per criterion, each failing only that one, ranked together.
|
|
|
|
|
+ Pairwise tests would pass against an order with the middle two swapped."""
|
|
|
|
|
+ ideal = _plug(6)
|
|
|
|
|
+ mute = _plug(5, plug_type="homeassistant", ha_entity_id="switch.mute")
|
|
|
|
|
+ hidden = _plug(4, show_on_printer_card=False)
|
|
|
|
|
+ disabled = _plug(3, enabled=False)
|
|
|
|
|
+ accessory = _plug(2, controls_printer_power=False)
|
|
|
|
|
+ script = _plug(1, plug_type="homeassistant", ha_entity_id="script.a")
|
|
|
|
|
+
|
|
|
|
|
+ ranked = sorted([script, accessory, disabled, hidden, mute, ideal], key=_main_plug_rank)
|
|
|
|
|
+
|
|
|
|
|
+ assert ranked == [ideal, mute, hidden, disabled, accessory, script]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class TestNoPlugs:
|
|
|
|
|
+ def test_no_plugs_means_no_main_plug(self):
|
|
|
|
|
+ assert _pick_main_plug([]) is None
|
|
|
|
|
+
|
|
|
|
|
+ def test_all_scripts_still_yields_one(self):
|
|
|
|
|
+ """Pre-existing behaviour: a printer whose only entities are scripts
|
|
|
|
|
+ still gets a Power row, because the card nests everything else inside
|
|
|
|
|
+ it. Ranking must not turn that into an empty card."""
|
|
|
|
|
+ second = _plug(2, plug_type="homeassistant", ha_entity_id="script.b")
|
|
|
|
|
+ first = _plug(1, plug_type="homeassistant", ha_entity_id="script.a")
|
|
|
|
|
+
|
|
|
|
|
+ assert _pick_main_plug([second, first]) is first
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class TestSwitchability:
|
|
|
|
|
+ @pytest.mark.parametrize(
|
|
|
|
|
+ "plug,expected",
|
|
|
|
|
+ [
|
|
|
|
|
+ (_plug(plug_type="tasmota"), True),
|
|
|
|
|
+ (_plug(plug_type="rest"), True),
|
|
|
|
|
+ (_plug(plug_type="homeassistant", ha_entity_id="switch.outlet"), True),
|
|
|
|
|
+ (_plug(plug_type="homeassistant", ha_entity_id="light.chamber"), True),
|
|
|
|
|
+ (_plug(plug_type="homeassistant", ha_entity_id="script.start"), False),
|
|
|
|
|
+ (_plug(plug_type="mqtt", mqtt_topic="zigbee2mqtt/plug"), False),
|
|
|
|
|
+ ],
|
|
|
|
|
+ )
|
|
|
|
|
+ def test_matches_what_the_control_endpoint_accepts(self, plug, expected):
|
|
|
|
|
+ assert _can_be_switched(plug) is expected
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class TestUnsetFlags:
|
|
|
|
|
+ """An upgraded database adds these columns by ALTER, so they are nullable
|
|
|
|
|
+ there even though a fresh one declares them NOT NULL. Every row is
|
|
|
|
|
+ backfilled with the default and nothing writes a null, but a rank that
|
|
|
|
|
+ raised on one would take down the whole printers page."""
|
|
|
|
|
+
|
|
|
|
|
+ def test_a_plug_with_null_flags_ranks_last_rather_than_raising(self):
|
|
|
|
|
+ unset = _plug(1, controls_printer_power=None, enabled=None, show_on_printer_card=None)
|
|
|
|
|
+ ordinary = _plug(2)
|
|
|
|
|
+
|
|
|
|
|
+ assert _pick_main_plug([unset, ordinary]) is ordinary
|
|
|
|
|
+
|
|
|
|
|
+ def test_and_still_holds_the_row_when_it_is_the_only_plug(self):
|
|
|
|
|
+ unset = _plug(1, controls_printer_power=None, enabled=None, show_on_printer_card=None)
|
|
|
|
|
+
|
|
|
|
|
+ assert _pick_main_plug([unset]) is unset
|
|
|
|
|
+
|
|
|
|
|
+ def test_a_plug_with_no_type_at_all_does_not_raise(self):
|
|
|
|
|
+ assert _pick_main_plug([_plug(1, plug_type=None)]) is not None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class TestScriptDetection:
|
|
|
|
|
+ @pytest.mark.parametrize(
|
|
|
|
|
+ "entity_id,expected",
|
|
|
|
|
+ [
|
|
|
|
|
+ ("script.start_print", True),
|
|
|
|
|
+ ("switch.outlet", False),
|
|
|
|
|
+ ("light.chamber", False),
|
|
|
|
|
+ (None, False),
|
|
|
|
|
+ ],
|
|
|
|
|
+ )
|
|
|
|
|
+ def test_only_ha_script_entities_count(self, entity_id, expected):
|
|
|
|
|
+ assert _is_script_plug(_plug(plug_type="homeassistant", ha_entity_id=entity_id)) is expected
|
|
|
|
|
+
|
|
|
|
|
+ def test_a_tasmota_plug_is_never_a_script(self):
|
|
|
|
|
+ assert _is_script_plug(_plug(plug_type="tasmota", ha_entity_id="script.confusing")) is False
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class TestPowerCapability:
|
|
|
|
|
+ """Read from configuration, not measured -- this runs on every card render.
|
|
|
|
|
+
|
|
|
|
|
+ It is a floor: an HA plug with no dedicated sensor may still report watts
|
|
|
|
|
+ from the switch entity's own ``current_power_w`` attribute, which only a
|
|
|
|
|
+ live read would show. Good enough for a tiebreak, and it never decides
|
|
|
|
|
+ anything on its own.
|
|
|
|
|
+ """
|
|
|
|
|
+
|
|
|
|
|
+ def test_tasmota_reports_power_natively(self):
|
|
|
|
|
+ assert _reports_power(_plug(plug_type="tasmota")) is True
|
|
|
|
|
+
|
|
|
|
|
+ def test_home_assistant_needs_a_power_sensor(self):
|
|
|
|
|
+ assert _reports_power(_plug(plug_type="homeassistant", ha_entity_id="switch.a")) is False
|
|
|
|
|
+ assert _reports_power(_plug(plug_type="homeassistant", ha_power_entity="sensor.a_power")) is True
|
|
|
|
|
+
|
|
|
|
|
+ def test_mqtt_accepts_either_the_new_or_the_legacy_topic(self):
|
|
|
|
|
+ assert _reports_power(_plug(plug_type="mqtt")) is False
|
|
|
|
|
+ assert _reports_power(_plug(plug_type="mqtt", mqtt_power_topic="tele/plug/SENSOR")) is True
|
|
|
|
|
+ assert _reports_power(_plug(plug_type="mqtt", mqtt_topic="zigbee2mqtt/plug")) is True
|
|
|
|
|
+
|
|
|
|
|
+ def test_rest_needs_a_power_path(self):
|
|
|
|
|
+ """The REST service returns no energy at all without one, so a URL on
|
|
|
|
|
+ its own proves nothing."""
|
|
|
|
|
+ assert _reports_power(_plug(plug_type="rest")) is False
|
|
|
|
|
+ assert _reports_power(_plug(plug_type="rest", rest_power_path="apower")) is True
|