Add support for multiple devices linked to a Viessmann account (#96044)

* care about all devices

* use first device for diagnostics

* update constants

* handle multiple devices

* handle multiple devices

* handle multiple devices

* handle multiple devices

* handle multiple devices

* code style

* code style

* code style

* code style

* code style

* remove unused import

* remove unused import

* use has_entity_name and add serial to device name

* use has_entity_name and add serial to device name

* use has_entity_name and add serial to device name

* use has_entity_name and add serial to device name

* use has_entity_name and add serial to device name

* remove unused constant

* Update const.py

* Update binary_sensor.py

* change format

* change format

* fix line duplication

* fix line duplication

* change format

* fix typo

* use serial in device name if multiple devices are found

* add common base class

* use base class

* Update __init__.py

* Update __init__.py

* Update __init__.py

* Update sensor.py

* Update binary_sensor.py

* correct import

* use base class

* fix cdestyle findings

* fix pylint findings

* fix mypy findings

* fix codestyle finidings

* move has_entity_name to base class

* Revert "fix mypy findings"

This reverts commit 2d78801a69.

* fix type issue

* move multiple device handling

* fix import

* remove special handling for device name

* extract api getter

* Update __init__.py

* Update __init__.py

* Update entity.py

* Update button.py

* Update binary_sensor.py

* Update climate.py

* Update sensor.py

* Update water_heater.py

* Apply suggestions from code review

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update __init__.py

* fix mypy & black

* move get_device to utils

* rename const

* Apply suggestions from code review

Co-authored-by: Robert Resch <robert@resch.dev>

* store device in config entry

* extract types

* fix diagnostics

* handle new platform

* handle api rate limit

* add types

* add types

* rename

* add types

* ignore gateways for now

* Update .coveragerc

* adjust types

* fix merge issues

* rename

* Update types.py

* fix type

* add test method

* simplify

* ignore unused devices

* Apply suggestions from code review

Co-authored-by: Robert Resch <robert@resch.dev>

* fix findings

* handle unsupported devices

* Apply suggestions from code review

Co-authored-by: Robert Resch <robert@resch.dev>

* Update types.py

* fix format

* adjust variable naming

* Update conftest.py

* Update conftest.py

* remove kw_only

* Apply suggestions from code review

* Update __init__.py

* Update binary_sensor.py

* Update button.py

* Update climate.py

* Update const.py

* Update diagnostics.py

* Update number.py

* Update sensor.py

* Update types.py

* Update water_heater.py

* fix comment

* Apply suggestions from code review

Co-authored-by: Erik Montnemery <erik@montnemery.com>

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Robert Resch <robert@resch.dev>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
This commit is contained in:
Christopher Fenner 2024-02-15 13:58:00 +01:00 committed by GitHub
parent fd0f093299
commit 47cbe8f00c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 153 additions and 136 deletions

View file

@ -1535,6 +1535,7 @@ omit =
homeassistant/components/vicare/entity.py
homeassistant/components/vicare/number.py
homeassistant/components/vicare/sensor.py
homeassistant/components/vicare/types.py
homeassistant/components/vicare/utils.py
homeassistant/components/vicare/water_heater.py
homeassistant/components/vilfo/__init__.py

View file

@ -1,15 +1,13 @@
"""The ViCare integration."""
from __future__ import annotations
from collections.abc import Callable, Mapping
from collections.abc import Mapping
from contextlib import suppress
from dataclasses import dataclass
import logging
import os
from typing import Any
from PyViCare.PyViCare import PyViCare
from PyViCare.PyViCareDevice import Device
from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig
from PyViCare.PyViCareUtils import (
PyViCareInvalidConfigurationError,
@ -22,36 +20,14 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.storage import STORAGE_DIR
from .const import (
CONF_HEATING_TYPE,
DEFAULT_SCAN_INTERVAL,
DOMAIN,
HEATING_TYPE_TO_CREATOR_METHOD,
PLATFORMS,
VICARE_API,
VICARE_DEVICE_CONFIG,
VICARE_DEVICE_CONFIG_LIST,
HeatingType,
)
from .const import DEFAULT_CACHE_DURATION, DEVICE_LIST, DOMAIN, PLATFORMS
from .types import ViCareDevice
from .utils import get_device
_LOGGER = logging.getLogger(__name__)
_TOKEN_FILENAME = "vicare_token.save"
@dataclass(frozen=True)
class ViCareRequiredKeysMixin:
"""Mixin for required keys."""
value_getter: Callable[[Device], Any]
@dataclass(frozen=True)
class ViCareRequiredKeysMixinWithSet(ViCareRequiredKeysMixin):
"""Mixin for required keys with setter."""
value_setter: Callable[[Device], bool]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up from config entry."""
_LOGGER.debug("Setting up ViCare component")
@ -69,10 +45,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
def vicare_login(hass: HomeAssistant, entry_data: Mapping[str, Any]) -> PyViCare:
def vicare_login(
hass: HomeAssistant,
entry_data: Mapping[str, Any],
cache_duration=DEFAULT_CACHE_DURATION,
) -> PyViCare:
"""Login via PyVicare API."""
vicare_api = PyViCare()
vicare_api.setCacheDuration(DEFAULT_SCAN_INTERVAL)
vicare_api.setCacheDuration(cache_duration)
vicare_api.initWithCredentials(
entry_data[CONF_USERNAME],
entry_data[CONF_PASSWORD],
@ -87,20 +67,25 @@ def setup_vicare_api(hass: HomeAssistant, entry: ConfigEntry) -> None:
vicare_api = vicare_login(hass, entry.data)
device_config_list = get_supported_devices(vicare_api.devices)
if (number_of_devices := len(device_config_list)) > 1:
cache_duration = DEFAULT_CACHE_DURATION * number_of_devices
_LOGGER.debug(
"Found %s devices, adjusting cache duration to %s",
number_of_devices,
cache_duration,
)
vicare_api = vicare_login(hass, entry.data, cache_duration)
device_config_list = get_supported_devices(vicare_api.devices)
for device in device_config_list:
_LOGGER.debug(
"Found device: %s (online: %s)", device.getModel(), str(device.isOnline())
)
# Currently we only support a single device
device = device_config_list[0]
hass.data[DOMAIN][entry.entry_id][VICARE_DEVICE_CONFIG_LIST] = device_config_list
hass.data[DOMAIN][entry.entry_id][VICARE_DEVICE_CONFIG] = device
hass.data[DOMAIN][entry.entry_id][VICARE_API] = getattr(
device,
HEATING_TYPE_TO_CREATOR_METHOD[HeatingType(entry.data[CONF_HEATING_TYPE])],
)()
hass.data[DOMAIN][entry.entry_id][DEVICE_LIST] = [
ViCareDevice(config=device_config, api=get_device(entry, device_config))
for device_config in device_config_list
]
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:

View file

@ -27,9 +27,9 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import ViCareRequiredKeysMixin
from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG
from .const import DEVICE_LIST, DOMAIN
from .entity import ViCareEntity
from .types import ViCareDevice, ViCareRequiredKeysMixin
from .utils import get_burners, get_circuits, get_compressors, is_supported
_LOGGER = logging.getLogger(__name__)
@ -111,29 +111,28 @@ GLOBAL_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = (
def _build_entities(
device: PyViCareDevice,
device_config: PyViCareDeviceConfig,
device_list: list[ViCareDevice],
) -> list[ViCareBinarySensor]:
"""Create ViCare binary sensor entities for a device."""
entities: list[ViCareBinarySensor] = _build_entities_for_device(
device, device_config
)
entities.extend(
_build_entities_for_component(
get_circuits(device), device_config, CIRCUIT_SENSORS
entities: list[ViCareBinarySensor] = []
for device in device_list:
entities.extend(_build_entities_for_device(device.api, device.config))
entities.extend(
_build_entities_for_component(
get_circuits(device.api), device.config, CIRCUIT_SENSORS
)
)
)
entities.extend(
_build_entities_for_component(
get_burners(device), device_config, BURNER_SENSORS
entities.extend(
_build_entities_for_component(
get_burners(device.api), device.config, BURNER_SENSORS
)
)
)
entities.extend(
_build_entities_for_component(
get_compressors(device), device_config, COMPRESSOR_SENSORS
entities.extend(
_build_entities_for_component(
get_compressors(device.api), device.config, COMPRESSOR_SENSORS
)
)
)
return entities
@ -179,14 +178,12 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Create the ViCare binary sensor devices."""
api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API]
device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG]
device_list = hass.data[DOMAIN][config_entry.entry_id][DEVICE_LIST]
async_add_entities(
await hass.async_add_executor_job(
_build_entities,
api,
device_config,
device_list,
)
)

View file

@ -20,9 +20,9 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import ViCareRequiredKeysMixinWithSet
from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG
from .const import DEVICE_LIST, DOMAIN
from .entity import ViCareEntity
from .types import ViCareDevice, ViCareRequiredKeysMixinWithSet
from .utils import is_supported
_LOGGER = logging.getLogger(__name__)
@ -48,19 +48,19 @@ BUTTON_DESCRIPTIONS: tuple[ViCareButtonEntityDescription, ...] = (
def _build_entities(
api: PyViCareDevice,
device_config: PyViCareDeviceConfig,
device_list: list[ViCareDevice],
) -> list[ViCareButton]:
"""Create ViCare button entities for a device."""
return [
ViCareButton(
api,
device_config,
device.api,
device.config,
description,
)
for device in device_list
for description in BUTTON_DESCRIPTIONS
if is_supported(description.key, description, api)
if is_supported(description.key, description, device.api)
]
@ -70,14 +70,12 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Create the ViCare button entities."""
api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API]
device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG]
device_list = hass.data[DOMAIN][config_entry.entry_id][DEVICE_LIST]
async_add_entities(
await hass.async_add_executor_job(
_build_entities,
api,
device_config,
device_list,
)
)

View file

@ -40,8 +40,9 @@ from homeassistant.helpers import entity_platform
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG
from .const import DEVICE_LIST, DOMAIN
from .entity import ViCareEntity
from .types import ViCareDevice
from .utils import get_burners, get_circuits, get_compressors
_LOGGER = logging.getLogger(__name__)
@ -99,18 +100,18 @@ HA_TO_VICARE_PRESET_HEATING = {
def _build_entities(
api: PyViCareDevice,
device_config: PyViCareDeviceConfig,
device_list: list[ViCareDevice],
) -> list[ViCareClimate]:
"""Create ViCare climate entities for a device."""
return [
ViCareClimate(
api,
device.api,
circuit,
device_config,
device.config,
"heating",
)
for circuit in get_circuits(api)
for device in device_list
for circuit in get_circuits(device.api)
]
@ -120,8 +121,6 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the ViCare climate platform."""
api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API]
device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG]
platform = entity_platform.async_get_current_platform()
@ -131,11 +130,12 @@ async def async_setup_entry(
"set_vicare_mode",
)
device_list = hass.data[DOMAIN][config_entry.entry_id][DEVICE_LIST]
async_add_entities(
await hass.async_add_executor_job(
_build_entities,
api,
device_config,
device_list,
)
)

View file

@ -14,15 +14,13 @@ PLATFORMS = [
Platform.WATER_HEATER,
]
VICARE_DEVICE_CONFIG = "device_conf"
VICARE_DEVICE_CONFIG_LIST = "device_config_list"
VICARE_API = "api"
DEVICE_LIST = "device_list"
VICARE_NAME = "ViCare"
CONF_CIRCUIT = "circuit"
CONF_HEATING_TYPE = "heating_type"
DEFAULT_SCAN_INTERVAL = 60
DEFAULT_CACHE_DURATION = 60
VICARE_CUBIC_METER = "cubicMeter"
VICARE_KWH = "kilowattHour"

View file

@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from .const import DOMAIN, VICARE_DEVICE_CONFIG_LIST
from .const import DEVICE_LIST, DOMAIN
TO_REDACT = {CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME}
@ -18,10 +18,11 @@ async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
# Currently we only support a single device
data = []
for device in hass.data[DOMAIN][entry.entry_id][VICARE_DEVICE_CONFIG_LIST]:
data.append(json.loads(await hass.async_add_executor_job(device.dump_secure)))
for device in hass.data[DOMAIN][entry.entry_id][DEVICE_LIST]:
data.append(
json.loads(await hass.async_add_executor_job(device.config.dump_secure))
)
return {
"entry": async_redact_data(entry.as_dict(), TO_REDACT),
"data": data,

View file

@ -29,9 +29,9 @@ from homeassistant.const import EntityCategory, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import ViCareRequiredKeysMixin
from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG
from .const import DEVICE_LIST, DOMAIN
from .entity import ViCareEntity
from .types import ViCareDevice, ViCareRequiredKeysMixin
from .utils import get_circuits, is_supported
_LOGGER = logging.getLogger(__name__)
@ -123,18 +123,18 @@ CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = (
def _build_entities(
api: PyViCareDevice,
device_config: PyViCareDeviceConfig,
device_list: list[ViCareDevice],
) -> list[ViCareNumber]:
"""Create ViCare number entities for a component."""
"""Create ViCare number entities for a device."""
return [
ViCareNumber(
circuit,
device_config,
device.config,
description,
)
for circuit in get_circuits(api)
for device in device_list
for circuit in get_circuits(device.api)
for description in CIRCUIT_ENTITY_DESCRIPTIONS
if is_supported(description.key, description, circuit)
]
@ -146,14 +146,12 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Create the ViCare number devices."""
api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API]
device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG]
device_list = hass.data[DOMAIN][config_entry.entry_id][DEVICE_LIST]
async_add_entities(
await hass.async_add_executor_job(
_build_entities,
api,
device_config,
device_list,
)
)

View file

@ -38,16 +38,15 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import ViCareRequiredKeysMixin
from .const import (
DEVICE_LIST,
DOMAIN,
VICARE_API,
VICARE_CUBIC_METER,
VICARE_DEVICE_CONFIG,
VICARE_KWH,
VICARE_UNIT_TO_UNIT_OF_MEASUREMENT,
)
from .entity import ViCareEntity
from .types import ViCareDevice, ViCareRequiredKeysMixin
from .utils import get_burners, get_circuits, get_compressors, is_supported
_LOGGER = logging.getLogger(__name__)
@ -693,27 +692,28 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
def _build_entities(
device: PyViCareDevice,
device_config: PyViCareDeviceConfig,
device_list: list[ViCareDevice],
) -> list[ViCareSensor]:
"""Create ViCare sensor entities for a device."""
entities: list[ViCareSensor] = _build_entities_for_device(device, device_config)
entities.extend(
_build_entities_for_component(
get_circuits(device), device_config, CIRCUIT_SENSORS
entities: list[ViCareSensor] = []
for device in device_list:
entities.extend(_build_entities_for_device(device.api, device.config))
entities.extend(
_build_entities_for_component(
get_circuits(device.api), device.config, CIRCUIT_SENSORS
)
)
)
entities.extend(
_build_entities_for_component(
get_burners(device), device_config, BURNER_SENSORS
entities.extend(
_build_entities_for_component(
get_burners(device.api), device.config, BURNER_SENSORS
)
)
)
entities.extend(
_build_entities_for_component(
get_compressors(device), device_config, COMPRESSOR_SENSORS
entities.extend(
_build_entities_for_component(
get_compressors(device.api), device.config, COMPRESSOR_SENSORS
)
)
)
return entities
@ -759,16 +759,12 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Create the ViCare sensor devices."""
api: PyViCareDevice = hass.data[DOMAIN][config_entry.entry_id][VICARE_API]
device_config: PyViCareDeviceConfig = hass.data[DOMAIN][config_entry.entry_id][
VICARE_DEVICE_CONFIG
]
device_list = hass.data[DOMAIN][config_entry.entry_id][DEVICE_LIST]
async_add_entities(
await hass.async_add_executor_job(
_build_entities,
api,
device_config,
device_list,
)
)

View file

@ -0,0 +1,29 @@
"""Types for the ViCare integration."""
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from PyViCare.PyViCareDevice import Device as PyViCareDevice
from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig
@dataclass(frozen=True)
class ViCareDevice:
"""Dataclass holding the device api and config."""
config: PyViCareDeviceConfig
api: PyViCareDevice
@dataclass(frozen=True)
class ViCareRequiredKeysMixin:
"""Mixin for required keys."""
value_getter: Callable[[PyViCareDevice], Any]
@dataclass(frozen=True)
class ViCareRequiredKeysMixinWithSet(ViCareRequiredKeysMixin):
"""Mixin for required keys with setter."""
value_setter: Callable[[PyViCareDevice], bool]

View file

@ -2,16 +2,30 @@
import logging
from PyViCare.PyViCareDevice import Device as PyViCareDevice
from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig
from PyViCare.PyViCareHeatingDevice import (
HeatingDeviceWithComponent as PyViCareHeatingDeviceComponent,
)
from PyViCare.PyViCareUtils import PyViCareNotSupportedFeatureError
from . import ViCareRequiredKeysMixin
from homeassistant.config_entries import ConfigEntry
from .const import CONF_HEATING_TYPE, HEATING_TYPE_TO_CREATOR_METHOD, HeatingType
from .types import ViCareRequiredKeysMixin
_LOGGER = logging.getLogger(__name__)
def get_device(
entry: ConfigEntry, device_config: PyViCareDeviceConfig
) -> PyViCareDevice:
"""Get device for device config."""
return getattr(
device_config,
HEATING_TYPE_TO_CREATOR_METHOD[HeatingType(entry.data[CONF_HEATING_TYPE])],
)()
def is_supported(
name: str,
entity_description: ViCareRequiredKeysMixin,

View file

@ -24,8 +24,9 @@ from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemper
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG
from .const import DEVICE_LIST, DOMAIN
from .entity import ViCareEntity
from .types import ViCareDevice
from .utils import get_circuits
_LOGGER = logging.getLogger(__name__)
@ -61,18 +62,19 @@ HA_TO_VICARE_HVAC_DHW = {
def _build_entities(
api: PyViCareDevice,
device_config: PyViCareDeviceConfig,
device_list: list[ViCareDevice],
) -> list[ViCareWater]:
"""Create ViCare domestic hot water entities for a device."""
return [
ViCareWater(
api,
device.api,
circuit,
device_config,
device.config,
"domestic_hot_water",
)
for circuit in get_circuits(api)
for device in device_list
for circuit in get_circuits(device.api)
]
@ -82,14 +84,12 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the ViCare water heater platform."""
api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API]
device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG]
device_list = hass.data[DOMAIN][config_entry.entry_id][DEVICE_LIST]
async_add_entities(
await hass.async_add_executor_job(
_build_entities,
api,
device_config,
device_list,
)
)