| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276 |
- """MQTT relay events reach the broker.
- Five relay methods were written and never called, one of them for a topic the
- wiki documents (``smart_plugs/energy``). The first test makes that impossible to
- repeat: every public ``on_*`` method of the relay has a caller. The rest cover
- the energy publisher; the printer online/offline and filament-low events are
- tested where they are produced (test_printer_offline_notification.py,
- test_filament_low_producer_2913.py).
- """
- import ast
- import re
- from pathlib import Path
- from unittest.mock import AsyncMock, patch
- import pytest
- from backend.app.models.smart_plug import SmartPlug
- from backend.app.services.mqtt_relay import MQTTRelayService
- from backend.app.services.smart_plug_manager import SmartPlugManager
- APP = Path(__file__).resolve().parents[2] / "app"
- def test_every_relay_event_has_a_caller():
- relay_source = (APP / "services" / "mqtt_relay.py").read_text(encoding="utf-8")
- service = next(
- node
- for node in ast.parse(relay_source).body
- if isinstance(node, ast.ClassDef) and node.name == MQTTRelayService.__name__
- )
- events = [
- node.name
- for node in service.body
- if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith("on_")
- ]
- callers = "\n".join(path.read_text(encoding="utf-8") for path in APP.rglob("*.py") if path.name != "mqtt_relay.py")
- uncalled = [name for name in events if not re.search(rf"\.{name}\(", callers)]
- assert events
- assert uncalled == []
- async def _plug(db_session, **kwargs) -> SmartPlug:
- fields = {"name": "Printer plug", "ip_address": "192.168.1.50"}
- fields.update(kwargs)
- plug = SmartPlug(**fields)
- db_session.add(plug)
- await db_session.commit()
- return plug
- @pytest.fixture
- def relay():
- with patch("backend.app.services.smart_plug_manager.mqtt_relay") as relay:
- relay.enabled = True
- relay.connected = True
- relay.on_smart_plug_energy = AsyncMock()
- yield relay
- @pytest.mark.asyncio
- async def test_each_enabled_plug_publishes_its_energy(db_session, relay):
- plug = await _plug(db_session)
- await _plug(db_session, name="Off", enabled=False)
- read = AsyncMock(return_value={"power": 120.5, "today": 1.25, "total": 310.0})
- with patch("backend.app.services.smart_plug_manager.get_plug_energy", read):
- await SmartPlugManager()._publish_energy_to_relay(db_session)
- relay.on_smart_plug_energy.assert_awaited_once_with(plug.id, "Printer plug", 120.5, 1.25, 310.0)
- @pytest.mark.asyncio
- async def test_a_plug_that_cannot_be_read_publishes_nothing(db_session, relay):
- await _plug(db_session)
- with patch("backend.app.services.smart_plug_manager.get_plug_energy", AsyncMock(return_value=None)):
- await SmartPlugManager()._publish_energy_to_relay(db_session)
- relay.on_smart_plug_energy.assert_not_awaited()
- @pytest.mark.asyncio
- async def test_one_failing_plug_does_not_stop_the_others(db_session, relay):
- await _plug(db_session, name="Broken")
- good = await _plug(db_session, name="Good")
- async def read(plug, _db):
- if plug.name == "Broken":
- raise OSError("unreachable")
- return {"power": 5.0, "today": 0.1, "total": 2.0}
- with patch("backend.app.services.smart_plug_manager.get_plug_energy", side_effect=read):
- await SmartPlugManager()._publish_energy_to_relay(db_session)
- relay.on_smart_plug_energy.assert_awaited_once_with(good.id, "Good", 5.0, 0.1, 2.0)
- @pytest.mark.parametrize(("enabled", "connected"), [(False, False), (True, False)])
- @pytest.mark.asyncio
- async def test_no_plug_is_read_while_the_relay_is_off(db_session, relay, enabled, connected):
- """The loop runs on every install; only relay users pay for the reads."""
- await _plug(db_session)
- relay.enabled = enabled
- relay.connected = connected
- read = AsyncMock(return_value={"power": 1.0, "today": 0.0, "total": 0.0})
- with patch("backend.app.services.smart_plug_manager.get_plug_energy", read):
- await SmartPlugManager()._publish_energy_to_relay(db_session)
- read.assert_not_awaited()
- relay.on_smart_plug_energy.assert_not_awaited()
- @pytest.mark.asyncio
- async def test_an_mqtt_plug_that_stopped_reporting_publishes_nothing(db_session, relay):
- """An MQTT plug's values are the last message received; once the plug goes
- quiet they are stale, and publishing them every minute would report a dead
- plug as drawing power."""
- await _plug(db_session, name="Quiet", plug_type="mqtt", ip_address=None)
- live = await _plug(db_session, name="Live", plug_type="mqtt", ip_address=None)
- relay.smart_plug_service.is_reachable.side_effect = lambda plug_id: plug_id == live.id
- read = AsyncMock(return_value={"power": 50.0, "today": 0.5, "total": 0.5})
- with patch("backend.app.services.smart_plug_manager.get_plug_energy", read):
- await SmartPlugManager()._publish_energy_to_relay(db_session)
- relay.on_smart_plug_energy.assert_awaited_once_with(live.id, "Live", 50.0, 0.5, 0.5)
- # -- smart_plugs/on and /off: every switch, not only the manual one -------------
- # The plug backends themselves (a REST toggle is a turn_on or turn_off).
- PLUG_BACKENDS = {"tasmota.py", "homeassistant.py", "rest_smart_plug.py", "mqtt_smart_plug.py"}
- # Runs a Home Assistant script through its entity's turn_on: no plug changes state.
- NOT_A_SWITCH = {("api/routes/smart_plugs.py", "trigger_associated_scripts")}
- def _called_names(node: ast.AST) -> set[str]:
- names = set()
- for call in ast.walk(node):
- if isinstance(call, ast.Call):
- if isinstance(call.func, ast.Attribute):
- names.add(call.func.attr)
- elif isinstance(call.func, ast.Name):
- names.add(call.func.id)
- return names
- def test_every_function_that_switches_a_plug_tells_the_relay():
- """smart_plugs/on and /off used to come only from the manual switch; auto-on,
- auto-off, schedules, the queue and failure detection switched plugs silently."""
- switching, missing = [], []
- for path in APP.rglob("*.py"):
- if path.name in PLUG_BACKENDS:
- continue
- rel = path.relative_to(APP).as_posix()
- for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
- if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) or (rel, node.name) in NOT_A_SWITCH:
- continue
- names = _called_names(node)
- if names & {"turn_on", "turn_off", "toggle"}:
- switching.append(f"{rel}:{node.name}")
- if "publish_plug_state" not in names:
- missing.append(f"{rel}:{node.name}")
- assert switching
- assert missing == []
- @pytest.fixture
- def session_for(db_session):
- """database.async_session() handing out the test session."""
- class _Session:
- async def __aenter__(self):
- return db_session
- async def __aexit__(self, *exc):
- return False
- with patch("backend.app.core.database.async_session", lambda: _Session()):
- yield
- @pytest.mark.asyncio
- async def test_publish_plug_state_names_the_printer_from_the_database(db_session, session_for):
- """Not from the connection cache: that keeps a renamed printer's old name
- and has none for an inactive printer."""
- from backend.app.models.printer import Printer
- from backend.app.services.smart_plug_manager import publish_plug_state
- printer = Printer(name="Workshop", serial_number="SN1", ip_address="192.0.2.1", access_code="x", is_active=False)
- db_session.add(printer)
- await db_session.commit()
- with patch("backend.app.services.smart_plug_manager.mqtt_relay") as relay:
- relay.on_smart_plug_state = AsyncMock()
- await publish_plug_state(3, "X1C plug", printer.id, on=False)
- await publish_plug_state(4, "Lamp", None, on=True)
- assert relay.on_smart_plug_state.await_args_list[0].kwargs == {
- "plug_id": 3,
- "plug_name": "X1C plug",
- "state": "off",
- "printer_id": printer.id,
- "printer_name": "Workshop",
- }
- assert relay.on_smart_plug_state.await_args_list[1].kwargs["printer_name"] is None
- assert relay.on_smart_plug_state.await_args_list[1].kwargs["state"] == "on"
- @pytest.mark.asyncio
- async def test_a_home_assistant_script_is_not_a_plug_switching(session_for):
- """Running a script through its entity changes no power state."""
- from backend.app.services.smart_plug_manager import publish_plug_state
- with patch("backend.app.services.smart_plug_manager.mqtt_relay") as relay:
- relay.on_smart_plug_state = AsyncMock()
- await publish_plug_state(5, "Lights script", None, on=True, ha_entity_id="script.printer_lights")
- await publish_plug_state(6, "Switch", None, on=True, ha_entity_id="switch.printer")
- relay.on_smart_plug_state.assert_awaited_once()
- assert relay.on_smart_plug_state.await_args.kwargs["plug_id"] == 6
- @pytest.mark.asyncio
- async def test_publish_plug_state_never_raises():
- """Switching the plug worked; a broker problem must not turn that into an error."""
- from backend.app.services.smart_plug_manager import publish_plug_state
- with patch("backend.app.services.smart_plug_manager.mqtt_relay") as relay:
- relay.on_smart_plug_state = AsyncMock(side_effect=RuntimeError("broker down"))
- await publish_plug_state(3, "X1C plug", None, on=True)
- @pytest.mark.asyncio
- async def test_the_delayed_auto_off_publishes_with_the_plug_name():
- manager = SmartPlugManager()
- service = AsyncMock()
- service.turn_off = AsyncMock(return_value=True)
- with (
- patch("backend.app.services.smart_plug_manager.printer_manager") as pm,
- patch("backend.app.services.smart_plug_manager.publish_plug_state", new_callable=AsyncMock) as publish,
- patch.object(manager, "get_service_for_plug", AsyncMock(return_value=service)),
- patch.object(manager, "_mark_auto_off_executed", new_callable=AsyncMock),
- ):
- pm.is_print_active.return_value = False
- await manager._delayed_off(
- 3, "tasmota", "1.2.3.4", None, None, None, printer_id=1, delay_seconds=0, plug_name="X1C plug"
- )
- publish.assert_awaited_once_with(3, "X1C plug", 1, on=False, ha_entity_id=None)
- # -- maintenance/alert carries the real values -----------------------------------
- def test_a_print_hours_item_reports_hours_since_and_the_interval():
- from types import SimpleNamespace
- from backend.app.api.routes.maintenance import maintenance_alert_values
- item = SimpleNamespace(
- interval_type="hours", interval_hours=200.0, hours_since_maintenance=212.5, days_since_maintenance=None
- )
- assert maintenance_alert_values(item) == (212.5, 200.0, "hours")
- def test_a_time_based_item_reports_days_since_and_the_interval():
- from types import SimpleNamespace
- from backend.app.api.routes.maintenance import maintenance_alert_values
- item = SimpleNamespace(
- interval_type="days", interval_hours=30.0, hours_since_maintenance=40.0, days_since_maintenance=31.2
- )
- assert maintenance_alert_values(item) == (31.2, 30.0, "days")
|