mirror of
https://github.com/home-assistant/core.git
synced 2025-04-24 01:08:12 +00:00
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:
parent
65eb1584f7
commit
7a497c1e6e
@ -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
|
||||
|
56
homeassistant/components/landisgyr_heat_meter/__init__.py
Normal file
56
homeassistant/components/landisgyr_heat_meter/__init__.py
Normal 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
|
136
homeassistant/components/landisgyr_heat_meter/config_flow.py
Normal file
136
homeassistant/components/landisgyr_heat_meter/config_flow.py
Normal 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."""
|
192
homeassistant/components/landisgyr_heat_meter/const.py
Normal file
192
homeassistant/components/landisgyr_heat_meter/const.py
Normal 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,
|
||||
),
|
||||
)
|
13
homeassistant/components/landisgyr_heat_meter/manifest.json
Normal file
13
homeassistant/components/landisgyr_heat_meter/manifest.json
Normal 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"
|
||||
}
|
108
homeassistant/components/landisgyr_heat_meter/sensor.py
Normal file
108
homeassistant/components/landisgyr_heat_meter/sensor.py
Normal 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)
|
23
homeassistant/components/landisgyr_heat_meter/strings.json
Normal file
23
homeassistant/components/landisgyr_heat_meter/strings.json
Normal 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%]"
|
||||
}
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -197,6 +197,7 @@ FLOWS = {
|
||||
"kulersky",
|
||||
"lacrosse_view",
|
||||
"lametric",
|
||||
"landisgyr_heat_meter",
|
||||
"launch_library",
|
||||
"laundrify",
|
||||
"lg_soundbar",
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
1
tests/components/landisgyr_heat_meter/__init__.py
Normal file
1
tests/components/landisgyr_heat_meter/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for the Landis+Gyr Heat Meter component."""
|
227
tests/components/landisgyr_heat_meter/test_config_flow.py
Normal file
227
tests/components/landisgyr_heat_meter/test_config_flow.py
Normal 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",
|
||||
}
|
22
tests/components/landisgyr_heat_meter/test_init.py
Normal file
22
tests/components/landisgyr_heat_meter/test_init.py
Normal 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)
|
200
tests/components/landisgyr_heat_meter/test_sensor.py
Normal file
200
tests/components/landisgyr_heat_meter/test_sensor.py
Normal 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
|
Loading…
x
Reference in New Issue
Block a user