Add Landis+Gyr Heat Meter integration (#73363)

* Add Landis+Gyr Heat Meter integration

* Add contant for better sensor config

* Add test for init

* Refactor some of the PR suggestions in config_flow

* Apply small fix

* Correct total_increasing to total

* Add test for restore state

* Add MWh entity that can be added as gas on the energy dashoard

* Remove GJ as unit

* Round MWh to 5 iso 3 digits

* Update homeassistant/components/landisgyr_heat_meter/const.py

* Update CODEOWNERS

Co-authored-by: Erik Montnemery <erik@montnemery.com>
This commit is contained in:
Vincent Knoop Pathuis 2022-08-18 16:40:04 +02:00 committed by GitHub
parent 65eb1584f7
commit 7a497c1e6e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 1011 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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."""

View File

@ -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,
),
)

View File

@ -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"
}

View File

@ -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)

View File

@ -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%]"
}
}
}

View File

@ -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"
}
}
}
}
}

View File

@ -197,6 +197,7 @@ FLOWS = {
"kulersky",
"lacrosse_view",
"lametric",
"landisgyr_heat_meter",
"launch_library",
"laundrify",
"lg_soundbar",

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
"""Tests for the Landis+Gyr Heat Meter component."""

View File

@ -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",
}

View File

@ -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)

View File

@ -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