diff --git a/CODEOWNERS b/CODEOWNERS index 1f3ce12e63a..26d1a8da2f8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -587,6 +587,8 @@ build.json @home-assistant/supervisor /tests/components/lacrosse_view/ @IceBotYT /homeassistant/components/lametric/ @robbiet480 @frenck /tests/components/lametric/ @robbiet480 @frenck +/homeassistant/components/landisgyr_heat_meter/ @vpathuis +/tests/components/landisgyr_heat_meter/ @vpathuis /homeassistant/components/launch_library/ @ludeeus @DurgNomis-drol /tests/components/launch_library/ @ludeeus @DurgNomis-drol /homeassistant/components/laundrify/ @xLarry diff --git a/homeassistant/components/landisgyr_heat_meter/__init__.py b/homeassistant/components/landisgyr_heat_meter/__init__.py new file mode 100644 index 00000000000..b5a9a3fba79 --- /dev/null +++ b/homeassistant/components/landisgyr_heat_meter/__init__.py @@ -0,0 +1,56 @@ +"""The Landis+Gyr Heat Meter integration.""" +from __future__ import annotations + +import logging + +from ultraheat_api import HeatMeterService, UltraheatReader + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up heat meter from a config entry.""" + + _LOGGER.debug("Initializing %s integration on %s", DOMAIN, entry.data[CONF_DEVICE]) + reader = UltraheatReader(entry.data[CONF_DEVICE]) + + api = HeatMeterService(reader) + + async def async_update_data(): + """Fetch data from the API.""" + _LOGGER.info("Polling on %s", entry.data[CONF_DEVICE]) + return await hass.async_add_executor_job(api.read) + + # No automatic polling and no initial refresh of data is being done at this point, + # to prevent battery drain. The user will have to do it manually. + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name="ultraheat_gateway", + update_method=async_update_data, + update_interval=None, + ) + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/landisgyr_heat_meter/config_flow.py b/homeassistant/components/landisgyr_heat_meter/config_flow.py new file mode 100644 index 00000000000..e3dbbb7433b --- /dev/null +++ b/homeassistant/components/landisgyr_heat_meter/config_flow.py @@ -0,0 +1,136 @@ +"""Config flow for Landis+Gyr Heat Meter integration.""" +from __future__ import annotations + +import logging +import os + +import async_timeout +import serial +import serial.tools.list_ports +from ultraheat_api import HeatMeterService, UltraheatReader +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_DEVICE +from homeassistant.exceptions import HomeAssistantError + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +CONF_MANUAL_PATH = "Enter Manually" + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_DEVICE): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Ultraheat Heat Meter.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Step when setting up serial configuration.""" + errors = {} + + if user_input is not None: + if user_input[CONF_DEVICE] == CONF_MANUAL_PATH: + return await self.async_step_setup_serial_manual_path() + + dev_path = await self.hass.async_add_executor_job( + get_serial_by_id, user_input[CONF_DEVICE] + ) + + try: + return await self.validate_and_create_entry(dev_path) + except CannotConnect: + errors["base"] = "cannot_connect" + + ports = await self.get_ports() + + schema = vol.Schema({vol.Required(CONF_DEVICE): vol.In(ports)}) + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) + + async def async_step_setup_serial_manual_path(self, user_input=None): + """Set path manually.""" + errors = {} + + if user_input is not None: + dev_path = user_input[CONF_DEVICE] + try: + return await self.validate_and_create_entry(dev_path) + except CannotConnect: + errors["base"] = "cannot_connect" + + schema = vol.Schema({vol.Required(CONF_DEVICE): str}) + return self.async_show_form( + step_id="setup_serial_manual_path", + data_schema=schema, + errors=errors, + ) + + async def validate_and_create_entry(self, dev_path): + """Try to connect to the device path and return an entry.""" + model, device_number = await self.validate_ultraheat(dev_path) + + await self.async_set_unique_id(device_number) + self._abort_if_unique_id_configured() + data = { + CONF_DEVICE: dev_path, + "model": model, + "device_number": device_number, + } + return self.async_create_entry( + title=model, + data=data, + ) + + async def validate_ultraheat(self, port: str): + """Validate the user input allows us to connect.""" + + reader = UltraheatReader(port) + heat_meter = HeatMeterService(reader) + try: + async with async_timeout.timeout(10): + # validate and retrieve the model and device number for a unique id + data = await self.hass.async_add_executor_job(heat_meter.read) + _LOGGER.debug("Got data from Ultraheat API: %s", data) + + except Exception as err: + _LOGGER.warning("Failed read data from: %s. %s", port, err) + raise CannotConnect(f"Error communicating with device: {err}") from err + + _LOGGER.debug("Successfully connected to %s", port) + return data.model, data.device_number + + async def get_ports(self) -> dict: + """Get the available ports.""" + ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports) + formatted_ports = {} + for port in ports: + formatted_ports[ + port.device + ] = f"{port}, s/n: {port.serial_number or 'n/a'}" + ( + f" - {port.manufacturer}" if port.manufacturer else "" + ) + formatted_ports[CONF_MANUAL_PATH] = CONF_MANUAL_PATH + return formatted_ports + + +def get_serial_by_id(dev_path: str) -> str: + """Return a /dev/serial/by-id match for given device if available.""" + by_id = "/dev/serial/by-id" + if not os.path.isdir(by_id): + return dev_path + + for path in (entry.path for entry in os.scandir(by_id) if entry.is_symlink()): + if os.path.realpath(path) == dev_path: + return path + return dev_path + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/landisgyr_heat_meter/const.py b/homeassistant/components/landisgyr_heat_meter/const.py new file mode 100644 index 00000000000..70008890d1f --- /dev/null +++ b/homeassistant/components/landisgyr_heat_meter/const.py @@ -0,0 +1,192 @@ +"""Constants for the Landis+Gyr Heat Meter integration.""" + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ENERGY_MEGA_WATT_HOUR, TEMP_CELSIUS, VOLUME_CUBIC_METERS +from homeassistant.helpers.entity import EntityCategory + +DOMAIN = "landisgyr_heat_meter" + +GJ_TO_MWH = 0.277778 # conversion factor + +HEAT_METER_SENSOR_TYPES = ( + SensorEntityDescription( + key="heat_usage", + icon="mdi:fire", + name="Heat usage", + native_unit_of_measurement=ENERGY_MEGA_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + key="volume_usage_m3", + icon="mdi:fire", + name="Volume usage", + native_unit_of_measurement=VOLUME_CUBIC_METERS, + state_class=SensorStateClass.TOTAL, + ), + # Diagnostic entity for debugging, this will match the value in GJ indicated on the meter's display + SensorEntityDescription( + key="heat_usage_gj", + icon="mdi:fire", + name="Heat usage GJ", + native_unit_of_measurement="GJ", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="heat_previous_year", + icon="mdi:fire", + name="Heat usage previous year", + native_unit_of_measurement=ENERGY_MEGA_WATT_HOUR, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="volume_previous_year_m3", + icon="mdi:fire", + name="Volume usage previous year", + native_unit_of_measurement=VOLUME_CUBIC_METERS, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="ownership_number", + name="Ownership number", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="error_number", + name="Error number", + icon="mdi:home-alert", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="device_number", + name="Device number", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="measurement_period_minutes", + name="Measurement period minutes", + icon="mdi:clock-outline", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="power_max_kw", + name="Power max", + native_unit_of_measurement="kW", + icon="mdi:power-plug-outline", + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="power_max_previous_year_kw", + name="Power max previous year", + native_unit_of_measurement="kW", + icon="mdi:power-plug-outline", + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="flowrate_max_m3ph", + name="Flowrate max", + native_unit_of_measurement="m3ph", + icon="mdi:water-outline", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="flowrate_max_previous_year_m3ph", + name="Flowrate max previous year", + native_unit_of_measurement="m3ph", + icon="mdi:water-outline", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="return_temperature_max_c", + name="Return temperature max", + native_unit_of_measurement=TEMP_CELSIUS, + icon="mdi:thermometer", + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="return_temperature_max_previous_year_c", + name="Return temperature max previous year", + native_unit_of_measurement=TEMP_CELSIUS, + icon="mdi:thermometer", + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="flow_temperature_max_c", + name="Flow temperature max", + native_unit_of_measurement=TEMP_CELSIUS, + icon="mdi:thermometer", + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="flow_temperature_max_previous_year_c", + name="Flow temperature max previous year", + native_unit_of_measurement=TEMP_CELSIUS, + icon="mdi:thermometer", + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="operating_hours", + name="Operating hours", + icon="mdi:clock-outline", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="flow_hours", + name="Flow hours", + icon="mdi:clock-outline", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="fault_hours", + name="Fault hours", + icon="mdi:clock-outline", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="fault_hours_previous_year", + name="Fault hours previous year", + icon="mdi:clock-outline", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="yearly_set_day", + name="Yearly set day", + icon="mdi:clock-outline", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="monthly_set_day", + name="Monthly set day", + icon="mdi:clock-outline", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="meter_date_time", + name="Meter date time", + icon="mdi:clock-outline", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="measuring_range_m3ph", + name="Measuring range", + native_unit_of_measurement="m3ph", + icon="mdi:water-outline", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="settings_and_firmware", + name="Settings and firmware", + entity_category=EntityCategory.DIAGNOSTIC, + ), +) diff --git a/homeassistant/components/landisgyr_heat_meter/manifest.json b/homeassistant/components/landisgyr_heat_meter/manifest.json new file mode 100644 index 00000000000..359ca1acea6 --- /dev/null +++ b/homeassistant/components/landisgyr_heat_meter/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "landisgyr_heat_meter", + "name": "Landis+Gyr Heat Meter", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/landisgyr_heat_meter", + "requirements": ["ultraheat-api==0.4.1"], + "ssdp": [], + "zeroconf": [], + "homekit": {}, + "dependencies": [], + "codeowners": ["@vpathuis"], + "iot_class": "local_polling" +} diff --git a/homeassistant/components/landisgyr_heat_meter/sensor.py b/homeassistant/components/landisgyr_heat_meter/sensor.py new file mode 100644 index 00000000000..1d38b1f5816 --- /dev/null +++ b/homeassistant/components/landisgyr_heat_meter/sensor.py @@ -0,0 +1,108 @@ +"""Platform for sensor integration.""" +from __future__ import annotations + +from dataclasses import asdict +import logging + +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + RestoreSensor, + SensorDeviceClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util + +from . import DOMAIN +from .const import GJ_TO_MWH, HEAT_METER_SENSOR_TYPES + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the sensor platform.""" + _LOGGER.info("The Landis+Gyr Heat Meter sensor platform is being set up!") + + unique_id = entry.entry_id + coordinator = hass.data[DOMAIN][entry.entry_id] + + model = entry.data["model"] + + device = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer="Landis & Gyr", + model=model, + name="Landis+Gyr Heat Meter", + ) + + sensors = [] + + for description in HEAT_METER_SENSOR_TYPES: + sensors.append(HeatMeterSensor(coordinator, unique_id, description, device)) + + async_add_entities(sensors) + + +class HeatMeterSensor(CoordinatorEntity, RestoreSensor): + """Representation of a Sensor.""" + + def __init__(self, coordinator, unique_id, description, device): + """Set up the sensor with the initial values.""" + super().__init__(coordinator) + self.key = description.key + self._attr_unique_id = f"{DOMAIN}_{unique_id}_{description.key}" + self._attr_name = "Heat Meter " + description.name + if hasattr(description, "icon"): + self._attr_icon = description.icon + if hasattr(description, "entity_category"): + self._attr_entity_category = description.entity_category + if hasattr(description, ATTR_STATE_CLASS): + self._attr_state_class = description.state_class + if hasattr(description, ATTR_DEVICE_CLASS): + self._attr_device_class = description.device_class + if hasattr(description, ATTR_UNIT_OF_MEASUREMENT): + self._attr_native_unit_of_measurement = ( + description.native_unit_of_measurement + ) + self._attr_device_info = device + self._attr_should_poll = bool(self.key in ("heat_usage", "heat_previous_year")) + + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + state = await self.async_get_last_sensor_data() + if state: + self._attr_native_value = state.native_value + + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if self.key in asdict(self.coordinator.data): + if self.device_class == SensorDeviceClass.TIMESTAMP: + self._attr_native_value = dt_util.as_utc( + asdict(self.coordinator.data)[self.key] + ) + else: + self._attr_native_value = asdict(self.coordinator.data)[self.key] + + if self.key == "heat_usage": + self._attr_native_value = convert_gj_to_mwh( + self.coordinator.data.heat_usage_gj + ) + + if self.key == "heat_previous_year": + self._attr_native_value = convert_gj_to_mwh( + self.coordinator.data.heat_previous_year_gj + ) + + self.async_write_ha_state() + + +def convert_gj_to_mwh(gigajoule) -> float: + """Convert GJ to MWh using the conversion value.""" + return round(gigajoule * GJ_TO_MWH, 5) diff --git a/homeassistant/components/landisgyr_heat_meter/strings.json b/homeassistant/components/landisgyr_heat_meter/strings.json new file mode 100644 index 00000000000..61e170af2b3 --- /dev/null +++ b/homeassistant/components/landisgyr_heat_meter/strings.json @@ -0,0 +1,23 @@ +{ + "config": { + "step": { + "user": { + "data": { + "device": "Select device" + } + }, + "setup_serial_manual_path": { + "data": { + "device": "[%key:common::config_flow::data::usb_path%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/landisgyr_heat_meter/translations/en.json b/homeassistant/components/landisgyr_heat_meter/translations/en.json new file mode 100644 index 00000000000..6915e8cb36a --- /dev/null +++ b/homeassistant/components/landisgyr_heat_meter/translations/en.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "device": "Select device" + } + }, + "setup_serial_manual_path": { + "data": { + "device": "USB-device path" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 42b426b8864..2ce09bfcafa 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -197,6 +197,7 @@ FLOWS = { "kulersky", "lacrosse_view", "lametric", + "landisgyr_heat_meter", "launch_library", "laundrify", "lg_soundbar", diff --git a/requirements_all.txt b/requirements_all.txt index 96dfac44800..ecaaf30280c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2383,6 +2383,9 @@ twitchAPI==2.5.2 # homeassistant.components.ukraine_alarm uasiren==0.0.1 +# homeassistant.components.landisgyr_heat_meter +ultraheat-api==0.4.1 + # homeassistant.components.unifiprotect unifi-discovery==1.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2db72263205..2b72beee027 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1614,6 +1614,9 @@ twitchAPI==2.5.2 # homeassistant.components.ukraine_alarm uasiren==0.0.1 +# homeassistant.components.landisgyr_heat_meter +ultraheat-api==0.4.1 + # homeassistant.components.unifiprotect unifi-discovery==1.1.5 diff --git a/tests/components/landisgyr_heat_meter/__init__.py b/tests/components/landisgyr_heat_meter/__init__.py new file mode 100644 index 00000000000..0ee6eb22510 --- /dev/null +++ b/tests/components/landisgyr_heat_meter/__init__.py @@ -0,0 +1 @@ +"""Tests for the Landis+Gyr Heat Meter component.""" diff --git a/tests/components/landisgyr_heat_meter/test_config_flow.py b/tests/components/landisgyr_heat_meter/test_config_flow.py new file mode 100644 index 00000000000..b51d4493879 --- /dev/null +++ b/tests/components/landisgyr_heat_meter/test_config_flow.py @@ -0,0 +1,227 @@ +"""Test the Landis + Gyr Heat Meter config flow.""" +from dataclasses import dataclass +from unittest.mock import MagicMock, patch + +import serial.tools.list_ports + +from homeassistant import config_entries +from homeassistant.components.landisgyr_heat_meter import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM + + +def mock_serial_port(): + """Mock of a serial port.""" + port = serial.tools.list_ports_common.ListPortInfo("/dev/ttyUSB1234") + port.serial_number = "1234" + port.manufacturer = "Virtual serial port" + port.device = "/dev/ttyUSB1234" + port.description = "Some serial port" + + return port + + +@dataclass +class MockUltraheatRead: + """Mock of the response from the read method of the Ultraheat API.""" + + model: str + device_number: str + + +@patch("homeassistant.components.landisgyr_heat_meter.config_flow.HeatMeterService") +async def test_manual_entry(mock_heat_meter, hass: HomeAssistant) -> None: + """Test manual entry.""" + + mock_heat_meter().read.return_value = MockUltraheatRead("LUGCUH50", "123456789") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"device": "Enter Manually"} + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "setup_serial_manual_path" + assert result["errors"] == {} + + with patch( + "homeassistant.components.landisgyr_heat_meter.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"device": "/dev/ttyUSB0"} + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "LUGCUH50" + assert result["data"] == { + "device": "/dev/ttyUSB0", + "model": "LUGCUH50", + "device_number": "123456789", + } + + +@patch("homeassistant.components.landisgyr_heat_meter.config_flow.HeatMeterService") +@patch("serial.tools.list_ports.comports", return_value=[mock_serial_port()]) +async def test_list_entry(mock_port, mock_heat_meter, hass: HomeAssistant) -> None: + """Test select from list entry.""" + + mock_heat_meter().read.return_value = MockUltraheatRead("LUGCUH50", "123456789") + port = mock_serial_port() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"device": port.device} + ) + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "LUGCUH50" + assert result["data"] == { + "device": port.device, + "model": "LUGCUH50", + "device_number": "123456789", + } + + +@patch("homeassistant.components.landisgyr_heat_meter.config_flow.HeatMeterService") +async def test_manual_entry_fail(mock_heat_meter, hass: HomeAssistant) -> None: + """Test manual entry fails.""" + + mock_heat_meter().read.side_effect = Exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"device": "Enter Manually"} + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "setup_serial_manual_path" + assert result["errors"] == {} + + with patch( + "homeassistant.components.landisgyr_heat_meter.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"device": "/dev/ttyUSB0"} + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "setup_serial_manual_path" + assert result["errors"] == {"base": "cannot_connect"} + + +@patch("homeassistant.components.landisgyr_heat_meter.config_flow.HeatMeterService") +@patch("serial.tools.list_ports.comports", return_value=[mock_serial_port()]) +async def test_list_entry_fail(mock_port, mock_heat_meter, hass: HomeAssistant) -> None: + """Test select from list entry fails.""" + + mock_heat_meter().read.side_effect = Exception + port = mock_serial_port() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"device": port.device} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + +@patch("homeassistant.components.landisgyr_heat_meter.config_flow.HeatMeterService") +@patch("serial.tools.list_ports.comports", return_value=[mock_serial_port()]) +async def test_get_serial_by_id_realpath( + mock_port, mock_heat_meter, hass: HomeAssistant +) -> None: + """Test getting the serial path name.""" + + mock_heat_meter().read.return_value = MockUltraheatRead("LUGCUH50", "123456789") + port = mock_serial_port() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + scandir = [MagicMock(), MagicMock()] + scandir[0].path = "/dev/ttyUSB1234" + scandir[0].is_symlink.return_value = True + scandir[1].path = "/dev/ttyUSB5678" + scandir[1].is_symlink.return_value = True + + with patch("os.path") as path: + with patch("os.scandir", return_value=scandir): + path.isdir.return_value = True + path.realpath.side_effect = ["/dev/ttyUSB1234", "/dev/ttyUSB5678"] + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"device": port.device} + ) + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "LUGCUH50" + assert result["data"] == { + "device": port.device, + "model": "LUGCUH50", + "device_number": "123456789", + } + + +@patch("homeassistant.components.landisgyr_heat_meter.config_flow.HeatMeterService") +@patch("serial.tools.list_ports.comports", return_value=[mock_serial_port()]) +async def test_get_serial_by_id_dev_path( + mock_port, mock_heat_meter, hass: HomeAssistant +) -> None: + """Test getting the serial path name with no realpath result.""" + + mock_heat_meter().read.return_value = MockUltraheatRead("LUGCUH50", "123456789") + port = mock_serial_port() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + scandir = [MagicMock()] + scandir[0].path.return_value = "/dev/serial/by-id/USB5678" + scandir[0].is_symlink.return_value = True + + with patch("os.path") as path: + with patch("os.scandir", return_value=scandir): + path.isdir.return_value = True + path.realpath.side_effect = ["/dev/ttyUSB5678"] + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"device": port.device} + ) + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "LUGCUH50" + assert result["data"] == { + "device": port.device, + "model": "LUGCUH50", + "device_number": "123456789", + } diff --git a/tests/components/landisgyr_heat_meter/test_init.py b/tests/components/landisgyr_heat_meter/test_init.py new file mode 100644 index 00000000000..b3630fc4872 --- /dev/null +++ b/tests/components/landisgyr_heat_meter/test_init.py @@ -0,0 +1,22 @@ +"""Test the Landis + Gyr Heat Meter init.""" + +from homeassistant.const import CONF_DEVICE + +from tests.common import MockConfigEntry + + +async def test_unload_entry(hass): + """Test removing config entry.""" + entry = MockConfigEntry( + domain="landisgyr_heat_meter", + title="LUGCUH50", + data={CONF_DEVICE: "/dev/1234"}, + ) + + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert "landisgyr_heat_meter" in hass.config.components + + assert await hass.config_entries.async_remove(entry.entry_id) diff --git a/tests/components/landisgyr_heat_meter/test_sensor.py b/tests/components/landisgyr_heat_meter/test_sensor.py new file mode 100644 index 00000000000..505efb446b8 --- /dev/null +++ b/tests/components/landisgyr_heat_meter/test_sensor.py @@ -0,0 +1,200 @@ +"""The tests for the Landis+Gyr Heat Meter sensor platform.""" +from dataclasses import dataclass +import datetime +from unittest.mock import patch + +from homeassistant.components.homeassistant import ( + DOMAIN as HA_DOMAIN, + SERVICE_UPDATE_ENTITY, +) +from homeassistant.components.landisgyr_heat_meter.const import DOMAIN +from homeassistant.components.sensor import ( + ATTR_LAST_RESET, + ATTR_STATE_CLASS, + SensorDeviceClass, + SensorStateClass, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ENTITY_ID, + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, + ENERGY_MEGA_WATT_HOUR, + VOLUME_CUBIC_METERS, +) +from homeassistant.core import CoreState, State +from homeassistant.helpers import entity_registry +from homeassistant.helpers.entity import EntityCategory +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 + + +@dataclass +class MockHeatMeterResponse: + """Mock for HeatMeterResponse.""" + + heat_usage_gj: int + volume_usage_m3: int + heat_previous_year_gj: int + device_number: str + meter_date_time: datetime.datetime + + +@patch("homeassistant.components.landisgyr_heat_meter.HeatMeterService") +async def test_create_sensors(mock_heat_meter, hass): + """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) + + mock_heat_meter_response = MockHeatMeterResponse( + heat_usage_gj=123, + volume_usage_m3=456, + heat_previous_year_gj=111, + 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 26 attributes have been created + assert len(hass.states.async_all()) == 26 + entity_reg = entity_registry.async_get(hass) + + state = hass.states.get("sensor.heat_meter_heat_usage") + assert state + assert state.state == "34.16669" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_MEGA_WATT_HOUR + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY + + state = hass.states.get("sensor.heat_meter_volume_usage") + assert state + assert state.state == "456" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == VOLUME_CUBIC_METERS + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL + + state = hass.states.get("sensor.heat_meter_device_number") + assert state + assert state.state == "devicenr_789" + assert state.attributes.get(ATTR_STATE_CLASS) is None + entity_registry_entry = entity_reg.async_get("sensor.heat_meter_device_number") + assert entity_registry_entry.entity_category == EntityCategory.DIAGNOSTIC + + state = hass.states.get("sensor.heat_meter_meter_date_time") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:clock-outline" + assert state.attributes.get(ATTR_STATE_CLASS) is None + entity_registry_entry = entity_reg.async_get("sensor.heat_meter_meter_date_time") + assert entity_registry_entry.entity_category == EntityCategory.DIAGNOSTIC + + +@patch("homeassistant.components.landisgyr_heat_meter.HeatMeterService") +async def test_restore_state(mock_heat_meter, hass): + """Test sensor restore state.""" + # Home assistant is not running yet + hass.state = CoreState.not_running + last_reset = "2022-07-01T00:00:00.000000+00:00" + mock_restore_cache_with_extra_data( + hass, + [ + ( + State( + "sensor.heat_meter_heat_usage", + "34167", + attributes={ + ATTR_LAST_RESET: last_reset, + ATTR_UNIT_OF_MEASUREMENT: ENERGY_MEGA_WATT_HOUR, + ATTR_STATE_CLASS: SensorStateClass.TOTAL, + }, + ), + { + "native_value": 34167, + "native_unit_of_measurement": ENERGY_MEGA_WATT_HOUR, + "icon": "mdi:fire", + "last_reset": last_reset, + }, + ), + ( + State( + "sensor.heat_meter_volume_usage", + "456", + attributes={ + ATTR_LAST_RESET: last_reset, + ATTR_UNIT_OF_MEASUREMENT: VOLUME_CUBIC_METERS, + ATTR_STATE_CLASS: SensorStateClass.TOTAL, + }, + ), + { + "native_value": 456, + "native_unit_of_measurement": VOLUME_CUBIC_METERS, + "icon": "mdi:fire", + "last_reset": last_reset, + }, + ), + ( + State( + "sensor.heat_meter_device_number", + "devicenr_789", + attributes={ + ATTR_LAST_RESET: last_reset, + }, + ), + { + "native_value": "devicenr_789", + "native_unit_of_measurement": None, + "last_reset": last_reset, + }, + ), + ], + ) + entry_data = { + "device": "/dev/USB0", + "model": "LUGCUH50", + "device_number": "123456789", + } + + # create and add entry + mock_entry = MockConfigEntry(domain=DOMAIN, unique_id=DOMAIN, data=entry_data) + mock_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_entry.entry_id) + await async_setup_component(hass, HA_DOMAIN, {}) + await hass.async_block_till_done() + + # restore from cache + state = hass.states.get("sensor.heat_meter_heat_usage") + assert state + assert state.state == "34167" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_MEGA_WATT_HOUR + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL + + state = hass.states.get("sensor.heat_meter_volume_usage") + assert state + assert state.state == "456" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == VOLUME_CUBIC_METERS + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL + + state = hass.states.get("sensor.heat_meter_device_number") + assert state + print("STATE IS: ", state) + assert state.state == "devicenr_789" + assert state.attributes.get(ATTR_STATE_CLASS) is None