| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306 |
- """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
|