Landis+Gyr move coordinator to own file (#89433)

* Move coordinator to own file and add test cases

* Apply typing improvements from review

* Remove testcase for exception during setup

* Simplify unittest for failing serial connection

* Readd checks in serial connection test after review
This commit is contained in:
Vincent Knoop Pathuis 2023-03-10 15:57:35 +01:00 committed by GitHub
parent 029093d0b2
commit 75bca76e68
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 127 additions and 27 deletions

View file

@ -1,19 +1,17 @@
"""The Landis+Gyr Heat Meter integration."""
from __future__ import annotations
from datetime import timedelta
import logging
import ultraheat_api
from ultraheat_api.response import HeatMeterResponse
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_registry import async_migrate_entries
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN
from .coordinator import UltraheatCoordinator
_LOGGER = logging.getLogger(__name__)
@ -27,19 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
reader = ultraheat_api.UltraheatReader(entry.data[CONF_DEVICE])
api = ultraheat_api.HeatMeterService(reader)
async def async_update_data() -> HeatMeterResponse:
"""Fetch data from the API."""
_LOGGER.debug("Polling on %s", entry.data[CONF_DEVICE])
return await hass.async_add_executor_job(api.read)
# Polling is only daily to prevent battery drain.
coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
name="ultraheat_gateway",
update_method=async_update_data,
update_interval=timedelta(days=1),
)
coordinator = UltraheatCoordinator(hass, api)
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator

View file

@ -1,6 +1,9 @@
"""Constants for the Landis+Gyr Heat Meter integration."""
from datetime import timedelta
DOMAIN = "landisgyr_heat_meter"
GJ_TO_MWH = 0.277778 # conversion factor
ULTRAHEAT_TIMEOUT = 30 # reading the IR port can take some time
POLLING_INTERVAL = timedelta(days=1) # Polling is only daily to prevent battery drain.

View file

@ -0,0 +1,37 @@
"""Data update coordinator for the ultraheat api."""
import logging
import async_timeout
import serial
from ultraheat_api.response import HeatMeterResponse
from ultraheat_api.service import HeatMeterService
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import POLLING_INTERVAL, ULTRAHEAT_TIMEOUT
_LOGGER = logging.getLogger(__name__)
class UltraheatCoordinator(DataUpdateCoordinator[HeatMeterResponse]):
"""Coordinator for getting data from the ultraheat api."""
def __init__(self, hass: HomeAssistant, api: HeatMeterService) -> None:
"""Initialize my coordinator."""
super().__init__(
hass,
_LOGGER,
name="ultraheat",
update_interval=POLLING_INTERVAL,
)
self.api = api
async def _async_update_data(self) -> HeatMeterResponse:
"""Fetch data from API endpoint."""
try:
async with async_timeout.timeout(ULTRAHEAT_TIMEOUT):
return await self.hass.async_add_executor_job(self.api.read)
except (FileNotFoundError, serial.serialutil.SerialException) as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err

View file

@ -3,11 +3,13 @@ from dataclasses import dataclass
import datetime
from unittest.mock import patch
import serial
from homeassistant.components.homeassistant import (
DOMAIN as HA_DOMAIN,
SERVICE_UPDATE_ENTITY,
)
from homeassistant.components.landisgyr_heat_meter.const import DOMAIN
from homeassistant.components.landisgyr_heat_meter.const import DOMAIN, POLLING_INTERVAL
from homeassistant.components.sensor import (
ATTR_LAST_RESET,
ATTR_STATE_CLASS,
@ -19,6 +21,7 @@ from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_ICON,
ATTR_UNIT_OF_MEASUREMENT,
STATE_UNAVAILABLE,
EntityCategory,
UnitOfEnergy,
UnitOfVolume,
@ -28,21 +31,29 @@ from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from tests.common import MockConfigEntry, mock_restore_cache_with_extra_data
from tests.common import (
MockConfigEntry,
async_fire_time_changed,
mock_restore_cache_with_extra_data,
)
API_HEAT_METER_SERVICE = (
"homeassistant.components.landisgyr_heat_meter.ultraheat_api.HeatMeterService"
)
@dataclass
class MockHeatMeterResponse:
"""Mock for HeatMeterResponse."""
heat_usage_gj: int
volume_usage_m3: int
heat_previous_year_gj: int
heat_usage_gj: float
volume_usage_m3: float
heat_previous_year_gj: float
device_number: str
meter_date_time: datetime.datetime
@patch("homeassistant.components.landisgyr_heat_meter.ultraheat_api.HeatMeterService")
@patch(API_HEAT_METER_SERVICE)
async def test_create_sensors(
mock_heat_meter, hass: HomeAssistant, entity_registry: er.EntityRegistry
) -> None:
@ -57,9 +68,9 @@ async def test_create_sensors(
mock_entry.add_to_hass(hass)
mock_heat_meter_response = MockHeatMeterResponse(
heat_usage_gj=123,
volume_usage_m3=456,
heat_previous_year_gj=111,
heat_usage_gj=123.0,
volume_usage_m3=456.0,
heat_previous_year_gj=111.0,
device_number="devicenr_789",
meter_date_time=dt_util.as_utc(datetime.datetime(2022, 5, 19, 19, 41, 17)),
)
@ -89,7 +100,7 @@ async def test_create_sensors(
state = hass.states.get("sensor.heat_meter_volume_usage")
assert state
assert state.state == "456"
assert state.state == "456.0"
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS
assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL
@ -110,7 +121,7 @@ async def test_create_sensors(
assert entity_registry_entry.entity_category == EntityCategory.DIAGNOSTIC
@patch("homeassistant.components.landisgyr_heat_meter.ultraheat_api.HeatMeterService")
@patch(API_HEAT_METER_SERVICE)
async def test_restore_state(mock_heat_meter, hass: HomeAssistant) -> None:
"""Test sensor restore state."""
# Home assistant is not running yet
@ -199,3 +210,66 @@ async def test_restore_state(mock_heat_meter, hass: HomeAssistant) -> None:
assert state
assert state.state == "devicenr_789"
assert state.attributes.get(ATTR_STATE_CLASS) is None
@patch(API_HEAT_METER_SERVICE)
async def test_exception_on_polling(mock_heat_meter, hass: HomeAssistant) -> None:
"""Test sensor."""
entry_data = {
"device": "/dev/USB0",
"model": "LUGCUH50",
"device_number": "123456789",
}
mock_entry = MockConfigEntry(domain=DOMAIN, unique_id=DOMAIN, data=entry_data)
mock_entry.add_to_hass(hass)
# First setup normally
mock_heat_meter_response = MockHeatMeterResponse(
heat_usage_gj=123.0,
volume_usage_m3=456.0,
heat_previous_year_gj=111.0,
device_number="devicenr_789",
meter_date_time=dt_util.as_utc(datetime.datetime(2022, 5, 19, 19, 41, 17)),
)
mock_heat_meter().read.return_value = mock_heat_meter_response
await hass.config_entries.async_setup(mock_entry.entry_id)
await async_setup_component(hass, HA_DOMAIN, {})
await hass.async_block_till_done()
await hass.services.async_call(
HA_DOMAIN,
SERVICE_UPDATE_ENTITY,
{ATTR_ENTITY_ID: "sensor.heat_meter_heat_usage"},
blocking=True,
)
await hass.async_block_till_done()
# check if initial setup succeeded
state = hass.states.get("sensor.heat_meter_heat_usage")
assert state
assert state.state == "34.16669"
# Now 'disable' the connection and wait for polling and see if it fails
mock_heat_meter().read.side_effect = serial.serialutil.SerialException
async_fire_time_changed(hass, dt_util.utcnow() + POLLING_INTERVAL)
await hass.async_block_till_done()
state = hass.states.get("sensor.heat_meter_heat_usage")
assert state.state == STATE_UNAVAILABLE
# Now 'enable' and see if next poll succeeds
mock_heat_meter_response = MockHeatMeterResponse(
heat_usage_gj=124.0,
volume_usage_m3=457.0,
heat_previous_year_gj=112.0,
device_number="devicenr_789",
meter_date_time=dt_util.as_utc(datetime.datetime(2022, 5, 19, 20, 41, 17)),
)
mock_heat_meter().read.return_value = mock_heat_meter_response
mock_heat_meter().read.side_effect = None
async_fire_time_changed(hass, dt_util.utcnow() + POLLING_INTERVAL)
await hass.async_block_till_done()
state = hass.states.get("sensor.heat_meter_heat_usage")
assert state
assert state.state == "34.44447"