Files
home-automation/tests/test_modbus_poll.py
T

466 lines
19 KiB
Python
Raw Normal View History

"""Tests for M5-T04: Modbus polling service (app/services/modbus_poll.py).
Coverage:
1. poll_all_enabled_devices inserts +N reading rows with correct payload
when the driver returns known register data.
2. A device whose driver raises leaves last_poll_ok=False; other devices
in the same sweep are still polled and their readings are stored.
3. modbus_polling_enabled=False causes the entire sweep to be skipped
(no readings inserted, no driver calls made).
4. Per-device interval: a device whose last_poll_at is too recent is
skipped; one whose interval has elapsed is polled.
5. poll_device returns None on driver failure and does not propagate.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
from app.config import Settings
from app.models.modbus import ModbusDevice, ModbusReading
from app.services.modbus_poll import poll_all_enabled_devices, poll_device
# ---------------------------------------------------------------------------
# Helpers and fixtures
# ---------------------------------------------------------------------------
def _make_app_alembic_config(database_url: str) -> Config:
cfg = Config("alembic_app.ini")
cfg.set_main_option("sqlalchemy.url", database_url)
return cfg
@pytest.fixture()
def poll_db(tmp_path: Path):
"""Temporary SQLite DB upgraded to Alembic head, yields (engine, session_factory)."""
db_path = tmp_path / "poll_test.db"
db_url = f"sqlite:///{db_path}"
command.upgrade(_make_app_alembic_config(db_url), "head")
engine = create_engine(db_url, connect_args={"check_same_thread": False})
yield engine
engine.dispose()
def _make_device(
session: Session,
*,
friendly_name: str = "Test Meter",
host: str = "10.0.0.1",
port: int = 502,
unit_id: int = 1,
profile: str = "sdm120",
poll_interval_s: int = 5,
enabled: bool = True,
last_poll_at: datetime | None = None,
last_poll_ok: bool | None = None,
) -> ModbusDevice:
now = datetime.now(UTC)
device = ModbusDevice(
friendly_name=friendly_name,
host=host,
port=port,
unit_id=unit_id,
profile=profile,
poll_interval_s=poll_interval_s,
enabled=enabled,
last_poll_at=last_poll_at,
last_poll_ok=last_poll_ok,
created_at=now,
updated_at=now,
)
session.add(device)
session.commit()
return device
# Known payload that matches what decode() should produce for a minimal SDM120 profile.
KNOWN_PAYLOAD: dict[str, float] = {
"voltage": 230.20001220703125,
"current": 1.2999999523162842,
"active_power": 295.0,
"power_factor": 0.9800000190734863,
"frequency": 50.0,
"import_energy": 123.4000015258789,
"export_energy": 0.0,
"total_energy": 123.4000015258789,
}
def _make_bootstrap_settings(**overrides) -> Settings:
"""Return a Settings instance with test defaults and optional overrides."""
return Settings(
_env_file=None,
app_database_url="sqlite:///:memory:",
auth_bootstrap_password="test",
**overrides,
)
# ---------------------------------------------------------------------------
# 1. Successful poll inserts +N readings with correct payload
# ---------------------------------------------------------------------------
def test_poll_all_inserts_readings_for_enabled_devices(poll_db):
"""poll_all_enabled_devices inserts one reading per enabled device with correct payload."""
with Session(poll_db) as session:
d1 = _make_device(session, friendly_name="Meter A", host="10.0.0.1")
d2 = _make_device(session, friendly_name="Meter B", host="10.0.0.2")
# Verify baseline: no readings yet.
count_before = session.execute(select(ModbusReading)).scalars().all()
assert len(count_before) == 0
bootstrap = _make_bootstrap_settings(modbus_polling_enabled=True)
with (
patch("app.services.modbus_poll.profiles.load_profile") as mock_load,
patch("app.services.modbus_poll.driver.read_blocks") as mock_read,
patch("app.services.modbus_poll.profiles.decode") as mock_decode,
):
mock_profile = MagicMock()
mock_profile.blocks = [MagicMock(start=0x0000, count=0x0060)]
mock_load.return_value = mock_profile
mock_read.return_value = {0x0000: 0x4366, 0x0001: 0x3334}
mock_decode.return_value = KNOWN_PAYLOAD
poll_all_enabled_devices(session, bootstrap_settings=bootstrap)
readings = session.execute(select(ModbusReading)).scalars().all()
assert len(readings) == 2, f"Expected 2 readings, got {len(readings)}"
# Both readings should have the known payload.
for reading in readings:
assert reading.payload == KNOWN_PAYLOAD, (
f"Payload mismatch for reading id={reading.id}: {reading.payload}"
)
assert reading.recorded_at is not None
# Devices should be marked last_poll_ok=True.
session.expire_all()
fetched_d1 = session.get(ModbusDevice, d1.id)
fetched_d2 = session.get(ModbusDevice, d2.id)
assert fetched_d1.last_poll_ok is True
assert fetched_d2.last_poll_ok is True
assert fetched_d1.last_poll_at is not None
assert fetched_d2.last_poll_at is not None
# ---------------------------------------------------------------------------
# 2. One device fails — others still succeed; failed device gets last_poll_ok=False
# ---------------------------------------------------------------------------
def test_poll_all_isolates_device_failure(poll_db):
"""A driver error for one device must not prevent other devices from being polled."""
with Session(poll_db) as session:
d_ok = _make_device(session, friendly_name="OK Meter", host="10.0.0.1")
d_fail = _make_device(session, friendly_name="Broken Meter", host="10.0.0.2")
bootstrap = _make_bootstrap_settings(modbus_polling_enabled=True)
call_count = {"n": 0}
def fake_read_blocks(host, port, unit_id, blocks, **kwargs):
call_count["n"] += 1
if host == "10.0.0.2":
raise ConnectionError("Cannot connect to broken meter")
return {0x0000: 0x4366, 0x0001: 0x3334}
with (
patch("app.services.modbus_poll.profiles.load_profile") as mock_load,
patch("app.services.modbus_poll.driver.read_blocks", side_effect=fake_read_blocks),
patch("app.services.modbus_poll.profiles.decode") as mock_decode,
):
mock_profile = MagicMock()
mock_profile.blocks = [MagicMock(start=0x0000, count=0x0060)]
mock_load.return_value = mock_profile
mock_decode.return_value = KNOWN_PAYLOAD
# Must not raise even though one device fails.
poll_all_enabled_devices(session, bootstrap_settings=bootstrap)
# Only the OK meter should have a reading.
readings = session.execute(select(ModbusReading)).scalars().all()
assert len(readings) == 1, f"Expected 1 reading (OK meter only), got {len(readings)}"
assert readings[0].device_id == d_ok.id
# driver was called for both devices (fail attempt included).
assert call_count["n"] == 2
session.expire_all()
fetched_ok = session.get(ModbusDevice, d_ok.id)
fetched_fail = session.get(ModbusDevice, d_fail.id)
assert fetched_ok.last_poll_ok is True
assert fetched_fail.last_poll_ok is False
# ---------------------------------------------------------------------------
# 3. modbus_polling_enabled=False → entire sweep is skipped
# ---------------------------------------------------------------------------
def test_poll_all_skipped_when_polling_disabled(poll_db):
"""When modbus_polling_enabled=False, no devices are polled and no readings are inserted."""
with Session(poll_db) as session:
_make_device(session, friendly_name="Should Be Skipped", host="10.0.0.1")
bootstrap = _make_bootstrap_settings(modbus_polling_enabled=False)
with patch("app.services.modbus_poll.driver.read_blocks") as mock_read:
poll_all_enabled_devices(session, bootstrap_settings=bootstrap)
mock_read.assert_not_called()
readings = session.execute(select(ModbusReading)).scalars().all()
assert len(readings) == 0, "No readings should be inserted when polling is disabled"
def test_poll_all_skipped_via_env_var(poll_db, monkeypatch):
"""MODBUS_POLLING_ENABLED=false env var disables polling via Settings override."""
monkeypatch.setenv("MODBUS_POLLING_ENABLED", "false")
# Build settings after env var is set.
bootstrap = Settings(_env_file=None, app_database_url="sqlite:///:memory:")
assert bootstrap.modbus_polling_enabled is False, (
"MODBUS_POLLING_ENABLED=false should yield modbus_polling_enabled=False"
)
with Session(poll_db) as session:
_make_device(session, friendly_name="Env-gated Meter", host="10.0.0.1")
with patch("app.services.modbus_poll.driver.read_blocks") as mock_read:
poll_all_enabled_devices(session, bootstrap_settings=bootstrap)
mock_read.assert_not_called()
readings = session.execute(select(ModbusReading)).scalars().all()
assert len(readings) == 0
# ---------------------------------------------------------------------------
# 4. Per-device interval: skip if recently polled, poll if interval elapsed
# ---------------------------------------------------------------------------
def test_poll_all_respects_per_device_interval(poll_db):
"""Devices polled more recently than poll_interval_s are skipped; others are polled."""
now = datetime.now(UTC)
with Session(poll_db) as session:
# Device polled just 1 second ago with a 30-second interval → should be SKIPPED.
d_recent = _make_device(
session,
friendly_name="Recent Meter",
host="10.0.0.1",
poll_interval_s=30,
last_poll_at=now - timedelta(seconds=1),
)
# Device polled 60 seconds ago with a 30-second interval → should be POLLED.
d_due = _make_device(
session,
friendly_name="Due Meter",
host="10.0.0.2",
poll_interval_s=30,
last_poll_at=now - timedelta(seconds=60),
)
# Device never polled before → should be POLLED.
d_new = _make_device(
session,
friendly_name="New Meter",
host="10.0.0.3",
poll_interval_s=30,
last_poll_at=None,
)
bootstrap = _make_bootstrap_settings(modbus_polling_enabled=True)
with (
patch("app.services.modbus_poll.profiles.load_profile") as mock_load,
patch("app.services.modbus_poll.driver.read_blocks") as mock_read,
patch("app.services.modbus_poll.profiles.decode") as mock_decode,
):
mock_profile = MagicMock()
mock_profile.blocks = [MagicMock(start=0x0000, count=0x0060)]
mock_load.return_value = mock_profile
mock_read.return_value = {0x0000: 0x4366, 0x0001: 0x3334}
mock_decode.return_value = {"voltage": 230.2}
poll_all_enabled_devices(session, bootstrap_settings=bootstrap)
readings = session.execute(select(ModbusReading)).scalars().all()
assert len(readings) == 2, (
f"Expected 2 readings (due + new), got {len(readings)}: "
f"{[r.device_id for r in readings]}"
)
polled_device_ids = {r.device_id for r in readings}
assert d_due.id in polled_device_ids, "Due meter should have been polled"
assert d_new.id in polled_device_ids, "New meter should have been polled"
assert d_recent.id not in polled_device_ids, "Recent meter should have been skipped"
# ---------------------------------------------------------------------------
# 5. poll_device returns None on failure, does not propagate exception
# ---------------------------------------------------------------------------
def test_poll_device_returns_none_on_driver_failure(poll_db):
"""poll_device swallows driver exceptions and returns None."""
with Session(poll_db) as session:
device = _make_device(session, friendly_name="Failing Device", host="10.0.0.9")
with (
patch("app.services.modbus_poll.profiles.load_profile") as mock_load,
patch("app.services.modbus_poll.driver.read_blocks") as mock_read,
):
mock_profile = MagicMock()
mock_profile.blocks = [MagicMock(start=0x0000, count=0x0060)]
mock_load.return_value = mock_profile
mock_read.side_effect = RuntimeError("Simulated driver failure")
# Must NOT raise.
result = poll_device(session, device)
assert result is None, "poll_device should return None on failure"
session.expire_all()
fetched = session.get(ModbusDevice, device.id)
assert fetched.last_poll_ok is False
assert fetched.last_poll_at is not None
# No reading row was created.
readings = session.execute(select(ModbusReading)).scalars().all()
assert len(readings) == 0
def test_poll_device_returns_reading_on_success(poll_db):
"""poll_device returns the inserted ModbusReading on success."""
with Session(poll_db) as session:
device = _make_device(session, friendly_name="Success Device", host="10.0.0.5")
with (
patch("app.services.modbus_poll.profiles.load_profile") as mock_load,
patch("app.services.modbus_poll.driver.read_blocks") as mock_read,
patch("app.services.modbus_poll.profiles.decode") as mock_decode,
):
mock_profile = MagicMock()
mock_profile.blocks = [MagicMock(start=0x0000, count=0x0060)]
mock_load.return_value = mock_profile
mock_read.return_value = {0x0000: 0x4366, 0x0001: 0x3334}
mock_decode.return_value = KNOWN_PAYLOAD
result = poll_device(session, device)
assert result is not None, "poll_device should return a ModbusReading on success"
assert isinstance(result, ModbusReading)
assert result.device_id == device.id
assert result.payload == KNOWN_PAYLOAD
session.expire_all()
fetched = session.get(ModbusDevice, device.id)
assert fetched.last_poll_ok is True
# ---------------------------------------------------------------------------
# 6. Disabled devices are not polled
# ---------------------------------------------------------------------------
def test_poll_all_skips_disabled_devices(poll_db):
"""Devices with enabled=False must not be polled."""
with Session(poll_db) as session:
_make_device(session, friendly_name="Disabled Meter", host="10.0.0.1", enabled=False)
bootstrap = _make_bootstrap_settings(modbus_polling_enabled=True)
with patch("app.services.modbus_poll.driver.read_blocks") as mock_read:
poll_all_enabled_devices(session, bootstrap_settings=bootstrap)
mock_read.assert_not_called()
readings = session.execute(select(ModbusReading)).scalars().all()
assert len(readings) == 0, "Disabled devices must not produce readings"
# ---------------------------------------------------------------------------
# 7. Rollback on commit failure: no reading survives a failed poll
# ---------------------------------------------------------------------------
def test_poll_device_commit_failure_leaves_no_reading(poll_db):
"""When session.commit() raises after session.add(reading), the reading must NOT persist.
Scenario (cross-device): two devices, device A's success-branch commit fails,
device B's poll succeeds normally. Expected outcome:
- 1 reading row total (device B only).
- device A: last_poll_ok=False, no reading row.
- device B: last_poll_ok=True, 1 reading row.
This guards against the "zombie reading" bug where a pending ModbusReading
added in the success branch (before commit failed) survived in the session
and was flushed by device B's subsequent commit.
"""
with Session(poll_db) as session:
d_fail = _make_device(session, friendly_name="Commit-Fail Meter", host="10.0.0.1")
d_ok = _make_device(session, friendly_name="OK Meter", host="10.0.0.2")
bootstrap = _make_bootstrap_settings(modbus_polling_enabled=True)
# Track how many times commit() is called so we can make only the
# first call (device A's success-branch commit) raise while letting
# the subsequent calls (device A's failure-state commit, device B's
# success commit) succeed normally.
original_commit = session.commit
commit_call_count = {"n": 0}
def patched_commit():
commit_call_count["n"] += 1
if commit_call_count["n"] == 1:
raise RuntimeError("Simulated commit failure (DB write error)")
return original_commit()
with (
patch("app.services.modbus_poll.profiles.load_profile") as mock_load,
patch("app.services.modbus_poll.driver.read_blocks") as mock_read,
patch("app.services.modbus_poll.profiles.decode") as mock_decode,
patch.object(session, "commit", side_effect=patched_commit),
):
mock_profile = MagicMock()
mock_profile.blocks = [MagicMock(start=0x0000, count=0x0060)]
mock_load.return_value = mock_profile
mock_read.return_value = {0x0000: 0x4366, 0x0001: 0x3334}
mock_decode.return_value = KNOWN_PAYLOAD
# Must not raise despite the first commit failing.
poll_all_enabled_devices(session, bootstrap_settings=bootstrap)
# Only device B (OK Meter) should have a reading — device A's reading
# must have been rolled back and must NOT have been committed via
# device B's subsequent commit (the "zombie reading" scenario).
readings = session.execute(select(ModbusReading)).scalars().all()
assert len(readings) == 1, (
f"Expected exactly 1 reading (OK Meter only), got {len(readings)}: "
f"device_ids={[r.device_id for r in readings]}"
)
assert readings[0].device_id == d_ok.id, (
"The surviving reading must belong to the OK Meter, not the commit-fail device"
)
session.expire_all()
fetched_fail = session.get(ModbusDevice, d_fail.id)
fetched_ok = session.get(ModbusDevice, d_ok.id)
assert fetched_fail.last_poll_ok is False, (
"Commit-fail device must be marked last_poll_ok=False"
)
assert fetched_ok.last_poll_ok is True, (
"OK Meter must be marked last_poll_ok=True"
)