|
@@ -0,0 +1,306 @@
|
|
|
|
|
+"""Energy tracking with more than one plug linked to a printer (#2859).
|
|
|
|
|
+
|
|
|
|
|
+The reporter had two Home Assistant plugs on a P1S -- its own plug plus a dry
|
|
|
|
|
+box he wanted to switch from the printer card -- and no archive on that printer
|
|
|
|
|
+ever carried an energy figure, while his single-plug X2D was fine. Both energy
|
|
|
|
|
+call sites selected every plug for the printer and then called
|
|
|
|
|
+``scalar_one_or_none()``, which raises on two rows; the print-start handler
|
|
|
|
|
+caught that as an ordinary failure, so ``energy_start_kwh`` was never written
|
|
|
|
|
+and the print-end handler reported "no start kWh recorded" -- indistinguishable
|
|
|
|
|
+from having no plug at all.
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+from types import SimpleNamespace
|
|
|
|
|
+from unittest.mock import AsyncMock, patch
|
|
|
|
|
+
|
|
|
|
|
+import pytest
|
|
|
|
|
+
|
|
|
|
|
+from backend.app.services.energy_plug import energy_plug_candidates, select_energy_reading
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _plug(plug_id: int, name: str, *, power: bool = True, ha_entity_id: str | None = None) -> SimpleNamespace:
|
|
|
|
|
+ return SimpleNamespace(
|
|
|
|
|
+ id=plug_id,
|
|
|
|
|
+ name=name,
|
|
|
|
|
+ controls_printer_power=power,
|
|
|
|
|
+ plug_type="homeassistant" if ha_entity_id else "tasmota",
|
|
|
|
|
+ ha_entity_id=ha_entity_id,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class TestCandidateOrdering:
|
|
|
|
|
+ """Ordering has to be stable: the print-end delta is only meaningful if it
|
|
|
|
|
+ reads the same counter the print-start reading came from."""
|
|
|
|
|
+
|
|
|
|
|
+ @pytest.mark.asyncio
|
|
|
|
|
+ async def test_no_plugs_for_printer(self, db_session, printer_factory):
|
|
|
|
|
+ printer = await printer_factory()
|
|
|
|
|
+
|
|
|
|
|
+ assert await energy_plug_candidates(db_session, printer.id) == []
|
|
|
|
|
+
|
|
|
|
|
+ @pytest.mark.asyncio
|
|
|
|
|
+ async def test_single_plug_is_the_candidate(self, db_session, printer_factory, smart_plug_factory):
|
|
|
|
|
+ printer = await printer_factory()
|
|
|
|
|
+ plug = await smart_plug_factory(name="P1S Power", printer_id=printer.id)
|
|
|
|
|
+
|
|
|
|
|
+ candidates = await energy_plug_candidates(db_session, printer.id)
|
|
|
|
|
+
|
|
|
|
|
+ assert [c.id for c in candidates] == [plug.id]
|
|
|
|
|
+
|
|
|
|
|
+ @pytest.mark.asyncio
|
|
|
|
|
+ async def test_power_plug_sorts_ahead_of_earlier_accessory(self, db_session, printer_factory, smart_plug_factory):
|
|
|
|
|
+ printer = await printer_factory()
|
|
|
|
|
+ accessory = await smart_plug_factory(
|
|
|
|
|
+ name="Dry Box",
|
|
|
|
|
+ plug_type="homeassistant",
|
|
|
|
|
+ printer_id=printer.id,
|
|
|
|
|
+ controls_printer_power=False,
|
|
|
|
|
+ )
|
|
|
|
|
+ power = await smart_plug_factory(
|
|
|
|
|
+ name="P1S Power",
|
|
|
|
|
+ plug_type="homeassistant",
|
|
|
|
|
+ printer_id=printer.id,
|
|
|
|
|
+ controls_printer_power=True,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ candidates = await energy_plug_candidates(db_session, printer.id)
|
|
|
|
|
+
|
|
|
|
|
+ assert [c.id for c in candidates] == [power.id, accessory.id]
|
|
|
|
|
+
|
|
|
|
|
+ @pytest.mark.asyncio
|
|
|
|
|
+ async def test_ties_break_by_id(self, db_session, printer_factory, smart_plug_factory):
|
|
|
|
|
+ """`controls_printer_power` defaults to on, so two plugs claiming it is
|
|
|
|
|
+ the normal case rather than a misconfiguration."""
|
|
|
|
|
+ printer = await printer_factory()
|
|
|
|
|
+ first = await smart_plug_factory(name="First", plug_type="homeassistant", printer_id=printer.id)
|
|
|
|
|
+ second = await smart_plug_factory(name="Second", plug_type="homeassistant", printer_id=printer.id)
|
|
|
|
|
+
|
|
|
|
|
+ candidates = await energy_plug_candidates(db_session, printer.id)
|
|
|
|
|
+
|
|
|
|
|
+ assert [c.id for c in candidates] == [first.id, second.id]
|
|
|
|
|
+
|
|
|
|
|
+ @pytest.mark.asyncio
|
|
|
|
|
+ async def test_other_printers_plugs_are_not_candidates(self, db_session, printer_factory, smart_plug_factory):
|
|
|
|
|
+ mine = await printer_factory(name="P1S")
|
|
|
|
|
+ theirs = await printer_factory(name="X2D")
|
|
|
|
|
+ plug = await smart_plug_factory(name="P1S Power", printer_id=mine.id)
|
|
|
|
|
+ await smart_plug_factory(name="X2D Power", printer_id=theirs.id)
|
|
|
|
|
+
|
|
|
|
|
+ candidates = await energy_plug_candidates(db_session, mine.id)
|
|
|
|
|
+
|
|
|
|
|
+ assert [c.id for c in candidates] == [plug.id]
|
|
|
|
|
+
|
|
|
|
|
+ @pytest.mark.asyncio
|
|
|
|
|
+ async def test_unlinked_plug_is_not_a_candidate(self, db_session, printer_factory, smart_plug_factory):
|
|
|
|
|
+ printer = await printer_factory()
|
|
|
|
|
+ await smart_plug_factory(name="Bench Plug", printer_id=None)
|
|
|
|
|
+
|
|
|
|
|
+ assert await energy_plug_candidates(db_session, printer.id) == []
|
|
|
|
|
+
|
|
|
|
|
+ @pytest.mark.asyncio
|
|
|
|
|
+ async def test_no_printer_matches_nothing(self, db_session, smart_plug_factory):
|
|
|
|
|
+ """`printer_id == None` would compile to `IS NULL` and hand back every
|
|
|
|
|
+ unlinked plug, so a print could be billed against a bench plug."""
|
|
|
|
|
+ await smart_plug_factory(name="Bench Plug", printer_id=None)
|
|
|
|
|
+
|
|
|
|
|
+ assert await energy_plug_candidates(db_session, None) == []
|
|
|
|
|
+
|
|
|
|
|
+ @pytest.mark.asyncio
|
|
|
|
|
+ async def test_disabled_plug_sorts_last(self, db_session, printer_factory, smart_plug_factory):
|
|
|
|
|
+ printer = await printer_factory()
|
|
|
|
|
+ retired = await smart_plug_factory(
|
|
|
|
|
+ name="Retired Plug",
|
|
|
|
|
+ plug_type="homeassistant",
|
|
|
|
|
+ printer_id=printer.id,
|
|
|
|
|
+ enabled=False,
|
|
|
|
|
+ )
|
|
|
|
|
+ live = await smart_plug_factory(name="P1S Power", plug_type="homeassistant", printer_id=printer.id)
|
|
|
|
|
+
|
|
|
|
|
+ candidates = await energy_plug_candidates(db_session, printer.id)
|
|
|
|
|
+
|
|
|
|
|
+ assert [c.id for c in candidates] == [live.id, retired.id]
|
|
|
|
|
+
|
|
|
|
|
+ @pytest.mark.asyncio
|
|
|
|
|
+ async def test_only_plug_is_used_even_when_disabled(self, db_session, printer_factory, smart_plug_factory):
|
|
|
|
|
+ """Ranking, not filtering: a printer whose single plug is disabled
|
|
|
|
|
+ tracked energy before this module existed and has to keep doing so."""
|
|
|
|
|
+ printer = await printer_factory()
|
|
|
|
|
+ retired = await smart_plug_factory(name="Retired Plug", printer_id=printer.id, enabled=False)
|
|
|
|
|
+
|
|
|
|
|
+ candidates = await energy_plug_candidates(db_session, printer.id)
|
|
|
|
|
+
|
|
|
|
|
+ assert [c.id for c in candidates] == [retired.id]
|
|
|
|
|
+
|
|
|
|
|
+ @pytest.mark.asyncio
|
|
|
|
|
+ async def test_home_assistant_script_sorts_last(self, db_session, printer_factory, smart_plug_factory):
|
|
|
|
|
+ """A `script.*` entity is linked for the automation triggers and has
|
|
|
|
|
+ nothing to meter, so it is asked only if nothing else answers."""
|
|
|
|
|
+ printer = await printer_factory()
|
|
|
|
|
+ script = await smart_plug_factory(
|
|
|
|
|
+ name="Notify Script",
|
|
|
|
|
+ plug_type="homeassistant",
|
|
|
|
|
+ ha_entity_id="script.notify_done",
|
|
|
|
|
+ printer_id=printer.id,
|
|
|
|
|
+ )
|
|
|
|
|
+ plug = await smart_plug_factory(
|
|
|
|
|
+ name="P1S Power",
|
|
|
|
|
+ plug_type="homeassistant",
|
|
|
|
|
+ ha_entity_id="switch.p1s",
|
|
|
|
|
+ printer_id=printer.id,
|
|
|
|
|
+ enabled=False,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ candidates = await energy_plug_candidates(db_session, printer.id)
|
|
|
|
|
+
|
|
|
|
|
+ # Even disabled, a real switch outranks a script.
|
|
|
|
|
+ assert [c.id for c in candidates] == [plug.id, script.id]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class TestSelectEnergyReading:
|
|
|
|
|
+ @pytest.mark.asyncio
|
|
|
|
|
+ async def test_picks_the_plug_that_reports_a_counter(self):
|
|
|
|
|
+ """The decisive test in practice: an accessory is usually switch-only,
|
|
|
|
|
+ so it drops out without the user configuring anything."""
|
|
|
|
|
+ dry_box = _plug(1, "Dry Box")
|
|
|
|
|
+ printer_plug = _plug(2, "P1S Power")
|
|
|
|
|
+
|
|
|
|
|
+ async def read(plug, _db):
|
|
|
|
|
+ return {"power": 3.0} if plug is dry_box else {"power": 120.0, "total": 41.5}
|
|
|
|
|
+
|
|
|
|
|
+ selected = await select_energy_reading([dry_box, printer_plug], read, db=None)
|
|
|
|
|
+
|
|
|
|
|
+ assert selected is not None
|
|
|
|
|
+ plug, energy = selected
|
|
|
|
|
+ assert plug is printer_plug
|
|
|
|
|
+ assert energy["total"] == 41.5
|
|
|
|
|
+
|
|
|
|
|
+ @pytest.mark.asyncio
|
|
|
|
|
+ async def test_stops_at_the_first_usable_reading(self):
|
|
|
|
|
+ first = _plug(1, "P1S Power")
|
|
|
|
|
+ second = _plug(2, "Dry Box")
|
|
|
|
|
+ seen = []
|
|
|
|
|
+
|
|
|
|
|
+ async def read(plug, _db):
|
|
|
|
|
+ seen.append(plug.name)
|
|
|
|
|
+ return {"total": 1.0}
|
|
|
|
|
+
|
|
|
|
|
+ selected = await select_energy_reading([first, second], read, db=None)
|
|
|
|
|
+
|
|
|
|
|
+ assert selected[0] is first
|
|
|
|
|
+ assert seen == ["P1S Power"]
|
|
|
|
|
+
|
|
|
|
|
+ @pytest.mark.asyncio
|
|
|
|
|
+ async def test_unreachable_plug_does_not_end_the_search(self):
|
|
|
|
|
+ offline = _plug(1, "Offline")
|
|
|
|
|
+ printer_plug = _plug(2, "P1S Power")
|
|
|
|
|
+
|
|
|
|
|
+ async def read(plug, _db):
|
|
|
|
|
+ return None if plug is offline else {"total": 7.0}
|
|
|
|
|
+
|
|
|
|
|
+ selected = await select_energy_reading([offline, printer_plug], read, db=None)
|
|
|
|
|
+
|
|
|
|
|
+ assert selected[0] is printer_plug
|
|
|
|
|
+
|
|
|
|
|
+ @pytest.mark.asyncio
|
|
|
|
|
+ async def test_zero_is_a_reading(self):
|
|
|
|
|
+ """A freshly reset counter is a perfectly good baseline; treating 0 as
|
|
|
|
|
+ missing would drop the first print after a plug replacement."""
|
|
|
|
|
+ plug = _plug(1, "P1S Power")
|
|
|
|
|
+
|
|
|
|
|
+ selected = await select_energy_reading([plug], AsyncMock(return_value={"total": 0.0}), db=None)
|
|
|
|
|
+
|
|
|
|
|
+ assert selected is not None
|
|
|
|
|
+ assert selected[1]["total"] == 0.0
|
|
|
|
|
+
|
|
|
|
|
+ @pytest.mark.asyncio
|
|
|
|
|
+ async def test_none_when_nothing_reports_a_counter(self):
|
|
|
|
|
+ async def read(_plug, _db):
|
|
|
|
|
+ return {"power": 3.0, "total": None}
|
|
|
|
|
+
|
|
|
|
|
+ assert await select_energy_reading([_plug(1, "Dry Box")], read, db=None) is None
|
|
|
|
|
+
|
|
|
|
|
+ @pytest.mark.asyncio
|
|
|
|
|
+ async def test_none_for_an_empty_candidate_list(self):
|
|
|
|
|
+ assert await select_energy_reading([], AsyncMock(), db=None) is None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class TestRecordEnergyStart:
|
|
|
|
|
+ """The reported failure, end to end."""
|
|
|
|
|
+
|
|
|
|
|
+ @pytest.mark.asyncio
|
|
|
|
|
+ async def test_two_plugs_no_longer_lose_the_start_reading(
|
|
|
|
|
+ self, db_session, printer_factory, smart_plug_factory, archive_factory
|
|
|
|
|
+ ):
|
|
|
|
|
+ printer = await printer_factory()
|
|
|
|
|
+ await smart_plug_factory(
|
|
|
|
|
+ name="Dry Box",
|
|
|
|
|
+ plug_type="homeassistant",
|
|
|
|
|
+ printer_id=printer.id,
|
|
|
|
|
+ controls_printer_power=False,
|
|
|
|
|
+ )
|
|
|
|
|
+ await smart_plug_factory(
|
|
|
|
|
+ name="P1S Power",
|
|
|
|
|
+ plug_type="homeassistant",
|
|
|
|
|
+ printer_id=printer.id,
|
|
|
|
|
+ controls_printer_power=True,
|
|
|
|
|
+ )
|
|
|
|
|
+ archive = await archive_factory(printer.id)
|
|
|
|
|
+
|
|
|
|
|
+ from backend.app.main import _record_energy_start
|
|
|
|
|
+
|
|
|
|
|
+ async def read(plug, _db):
|
|
|
|
|
+ return {"power": 120.0, "total": 41.5} if plug.name == "P1S Power" else {"power": 2.0}
|
|
|
|
|
+
|
|
|
|
|
+ with patch("backend.app.main._get_plug_energy", side_effect=read):
|
|
|
|
|
+ recorded = await _record_energy_start(archive, printer.id, db_session)
|
|
|
|
|
+
|
|
|
|
|
+ assert recorded is True
|
|
|
|
|
+ assert archive.energy_start_kwh == 41.5
|
|
|
|
|
+
|
|
|
|
|
+ @pytest.mark.asyncio
|
|
|
|
|
+ async def test_single_plug_is_unchanged(self, db_session, printer_factory, smart_plug_factory, archive_factory):
|
|
|
|
|
+ printer = await printer_factory()
|
|
|
|
|
+ await smart_plug_factory(name="X2D Power", printer_id=printer.id)
|
|
|
|
|
+ archive = await archive_factory(printer.id)
|
|
|
|
|
+
|
|
|
|
|
+ from backend.app.main import _record_energy_start
|
|
|
|
|
+
|
|
|
|
|
+ with patch("backend.app.main._get_plug_energy", AsyncMock(return_value={"total": 12.25})):
|
|
|
|
|
+ recorded = await _record_energy_start(archive, printer.id, db_session)
|
|
|
|
|
+
|
|
|
|
|
+ assert recorded is True
|
|
|
|
|
+ assert archive.energy_start_kwh == 12.25
|
|
|
|
|
+
|
|
|
|
|
+ @pytest.mark.asyncio
|
|
|
|
|
+ async def test_no_plug_records_nothing(self, db_session, printer_factory, archive_factory):
|
|
|
|
|
+ printer = await printer_factory()
|
|
|
|
|
+ archive = await archive_factory(printer.id)
|
|
|
|
|
+
|
|
|
|
|
+ from backend.app.main import _record_energy_start
|
|
|
|
|
+
|
|
|
|
|
+ recorded = await _record_energy_start(archive, printer.id, db_session)
|
|
|
|
|
+
|
|
|
|
|
+ assert recorded is False
|
|
|
|
|
+ assert archive.energy_start_kwh is None
|
|
|
|
|
+
|
|
|
|
|
+ @pytest.mark.asyncio
|
|
|
|
|
+ async def test_names_the_plugs_it_tried_when_none_measures(
|
|
|
|
|
+ self, db_session, printer_factory, smart_plug_factory, archive_factory, capture_logs
|
|
|
|
|
+ ):
|
|
|
|
|
+ """ "No plug reports energy" and "no plug at all" used to log the same
|
|
|
|
|
+ way, which is what made this invisible for the reporter."""
|
|
|
|
|
+ printer = await printer_factory()
|
|
|
|
|
+ await smart_plug_factory(name="Dry Box", plug_type="homeassistant", printer_id=printer.id)
|
|
|
|
|
+ await smart_plug_factory(name="Chamber Light", plug_type="homeassistant", printer_id=printer.id)
|
|
|
|
|
+ archive = await archive_factory(printer.id)
|
|
|
|
|
+
|
|
|
|
|
+ from backend.app.main import _record_energy_start
|
|
|
|
|
+
|
|
|
|
|
+ with patch("backend.app.main._get_plug_energy", AsyncMock(return_value={"power": 1.0})):
|
|
|
|
|
+ recorded = await _record_energy_start(archive, printer.id, db_session)
|
|
|
|
|
+
|
|
|
|
|
+ assert recorded is False
|
|
|
|
|
+ assert archive.energy_start_kwh is None
|
|
|
|
|
+ logged = "\n".join(record.getMessage() for record in capture_logs.get_warnings())
|
|
|
|
|
+ assert "Dry Box" in logged
|
|
|
|
|
+ assert "Chamber Light" in logged
|