From 75bca76e6864b724854fd04e79fc8a3365319d92 Mon Sep 17 00:00:00 2001 From: Vincent Knoop Pathuis <48653141+vpathuis@users.noreply.github.com> Date: Fri, 10 Mar 2023 15:57:35 +0100 Subject: [PATCH] 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 --- .../landisgyr_heat_meter/__init__.py | 18 +--- .../components/landisgyr_heat_meter/const.py | 3 + .../landisgyr_heat_meter/coordinator.py | 37 +++++++ .../landisgyr_heat_meter/test_sensor.py | 96 ++++++++++++++++--- 4 files changed, 127 insertions(+), 27 deletions(-) create mode 100644 homeassistant/components/landisgyr_heat_meter/coordinator.py diff --git a/homeassistant/components/landisgyr_heat_meter/__init__.py b/homeassistant/components/landisgyr_heat_meter/__init__.py index eae5e91196c..541fef017d0 100644 --- a/homeassistant/components/landisgyr_heat_meter/__init__.py +++ b/homeassistant/components/landisgyr_heat_meter/__init__.py @@ -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 diff --git a/homeassistant/components/landisgyr_heat_meter/const.py b/homeassistant/components/landisgyr_heat_meter/const.py index 5d27a8a1705..56f5980a839 100644 --- a/homeassistant/components/landisgyr_heat_meter/const.py +++ b/homeassistant/components/landisgyr_heat_meter/const.py @@ -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. diff --git a/homeassistant/components/landisgyr_heat_meter/coordinator.py b/homeassistant/components/landisgyr_heat_meter/coordinator.py new file mode 100644 index 00000000000..c85c661e79c --- /dev/null +++ b/homeassistant/components/landisgyr_heat_meter/coordinator.py @@ -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 diff --git a/tests/components/landisgyr_heat_meter/test_sensor.py b/tests/components/landisgyr_heat_meter/test_sensor.py index 9a94491a94f..854ead82b3d 100644 --- a/tests/components/landisgyr_heat_meter/test_sensor.py +++ b/tests/components/landisgyr_heat_meter/test_sensor.py @@ -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"