* Add logs to diagnose the case * Issue #552 (#608) Co-authored-by: Jean-Marc Collin <jean-marc.collin-extern@renault.com> * Fix typo (#607) * - Force writing state when entity is removed - Fix bug with issue #552 on CONF_USE_CENTRAL_BOILER_FEATURE which should be proposed on a central configuration - Improve reload of entity to avoid reloading all VTherm. Only the reconfigured one will be reloaded --------- Co-authored-by: Jean-Marc Collin <jean-marc.collin-extern@renault.com> Co-authored-by: Ludovic BOUÉ <lboue@users.noreply.github.com>
257 lines
8.3 KiB
Python
257 lines
8.3 KiB
Python
""" Some usefull commons class """
|
|
|
|
# pylint: disable=line-too-long
|
|
|
|
import logging
|
|
from datetime import timedelta, datetime
|
|
from homeassistant.core import HomeAssistant, callback, Event
|
|
from homeassistant.components.climate import ClimateEntity, DOMAIN as CLIMATE_DOMAIN
|
|
from homeassistant.helpers.entity_component import EntityComponent
|
|
from homeassistant.helpers.entity import Entity
|
|
from homeassistant.helpers.device_registry import DeviceInfo, DeviceEntryType
|
|
from homeassistant.helpers.event import async_track_state_change_event, async_call_later
|
|
from homeassistant.util import dt as dt_util
|
|
|
|
from .base_thermostat import BaseThermostat
|
|
from .const import DOMAIN, DEVICE_MANUFACTURER, ServiceConfigurationError
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
def get_tz(hass: HomeAssistant):
|
|
"""Get the current timezone"""
|
|
|
|
return dt_util.get_time_zone(hass.config.time_zone)
|
|
|
|
|
|
class NowClass:
|
|
"""For testing purpose only"""
|
|
|
|
@staticmethod
|
|
def get_now(hass: HomeAssistant) -> datetime:
|
|
"""A test function to get the now.
|
|
For testing purpose this method can be overriden to get a specific
|
|
timestamp.
|
|
"""
|
|
return datetime.now(get_tz(hass))
|
|
|
|
|
|
def round_to_nearest(n: float, x: float) -> float:
|
|
"""Round a number to the nearest x (which should be decimal but not null)
|
|
Example:
|
|
nombre1 = 3.2
|
|
nombre2 = 4.7
|
|
x = 0.3
|
|
|
|
nombre_arrondi1 = round_to_nearest(nombre1, x)
|
|
nombre_arrondi2 = round_to_nearest(nombre2, x)
|
|
|
|
print(nombre_arrondi1) # Output: 3.3
|
|
print(nombre_arrondi2) # Output: 4.6
|
|
"""
|
|
assert x > 0
|
|
return round(n * (1 / x)) / (1 / x)
|
|
|
|
|
|
def check_and_extract_service_configuration(service_config) -> dict:
|
|
"""Raise a ServiceConfigurationError. In return you have a dict formatted like follows.
|
|
Example if you call with 'climate.central_boiler/climate.set_temperature/temperature:10':
|
|
{
|
|
"service_domain": "climate",
|
|
"service_name": "set_temperature",
|
|
"entity_id": "climate.central_boiler",
|
|
"entity_domain": "climate",
|
|
"entity_name": "central_boiler",
|
|
"data": {
|
|
"temperature": "10"
|
|
},
|
|
"attribute_name": "temperature",
|
|
"attribute_value: "10"
|
|
}
|
|
|
|
For this example 'switch.central_boiler/switch.turn_off' you will have this:
|
|
{
|
|
"service_domain": "switch",
|
|
"service_name": "turn_off",
|
|
"entity_id": "switch.central_boiler",
|
|
"entity_domain": "switch",
|
|
"entity_name": "central_boiler",
|
|
"data": { },
|
|
}
|
|
|
|
All values are striped (white space are removed) and are string
|
|
"""
|
|
|
|
ret = {}
|
|
|
|
if service_config is None:
|
|
return ret
|
|
|
|
parties = service_config.split("/")
|
|
if len(parties) < 2:
|
|
raise ServiceConfigurationError(
|
|
f"Incorrect service configuration. Service {service_config} should be formatted with: 'entity_name/service_name[/data]'. See README for more information."
|
|
)
|
|
entity_id = parties[0]
|
|
service_name = parties[1]
|
|
|
|
service_infos = service_name.split(".")
|
|
if len(service_infos) != 2:
|
|
raise ServiceConfigurationError(
|
|
f"Incorrect service configuration. The service {service_config} should be formatted like: 'domain.service_name' (ex: 'switch.turn_on'). See README for more information."
|
|
)
|
|
|
|
ret.update(
|
|
{
|
|
"service_domain": service_infos[0].strip(),
|
|
"service_name": service_infos[1].strip(),
|
|
}
|
|
)
|
|
|
|
entity_infos = entity_id.split(".")
|
|
if len(entity_infos) != 2:
|
|
raise ServiceConfigurationError(
|
|
f"Incorrect service configuration. The entity_id {entity_id} should be formatted like: 'domain.entity_name' (ex: 'switch.central_boiler_switch'). See README for more information."
|
|
)
|
|
|
|
ret.update(
|
|
{
|
|
"entity_domain": entity_infos[0].strip(),
|
|
"entity_name": entity_infos[1].strip(),
|
|
"entity_id": entity_id.strip(),
|
|
}
|
|
)
|
|
|
|
if len(parties) == 3:
|
|
data = parties[2]
|
|
if len(data) > 0:
|
|
data_infos = None
|
|
data_infos = data.split(":")
|
|
if (
|
|
len(data_infos) != 2
|
|
or len(data_infos[0]) <= 0
|
|
or len(data_infos[1]) <= 0
|
|
):
|
|
raise ServiceConfigurationError(
|
|
f"Incorrect service configuration. The data {data} should be formatted like: 'attribute:value' (ex: 'value:25'). See README for more information."
|
|
)
|
|
|
|
ret.update(
|
|
{
|
|
"attribute_name": data_infos[0].strip(),
|
|
"attribute_value": data_infos[1].strip(),
|
|
"data": {data_infos[0].strip(): data_infos[1].strip()},
|
|
}
|
|
)
|
|
else:
|
|
raise ServiceConfigurationError(
|
|
f"Incorrect service configuration. The data {data} should be formatted like: 'attribute:value' (ex: 'value:25'). See README for more information."
|
|
)
|
|
else:
|
|
ret.update({"data": {}})
|
|
|
|
_LOGGER.debug(
|
|
"check_and_extract_service_configuration(%s) gives '%s'", service_config, ret
|
|
)
|
|
return ret
|
|
|
|
|
|
class VersatileThermostatBaseEntity(Entity):
|
|
"""A base class for all entities"""
|
|
|
|
_my_climate: BaseThermostat
|
|
hass: HomeAssistant
|
|
_config_id: str
|
|
_device_name: str
|
|
|
|
def __init__(self, hass: HomeAssistant, config_id, device_name) -> None:
|
|
"""The CTOR"""
|
|
self.hass = hass
|
|
self._config_id = config_id
|
|
self._device_name = device_name
|
|
self._my_climate = None
|
|
self._cancel_call = None
|
|
self._attr_has_entity_name = True
|
|
|
|
@property
|
|
def should_poll(self) -> bool:
|
|
"""Do not poll for those entities"""
|
|
return False
|
|
|
|
@property
|
|
def my_climate(self) -> BaseThermostat | None:
|
|
"""Returns my climate if found"""
|
|
if not self._my_climate:
|
|
self._my_climate = self.find_my_versatile_thermostat()
|
|
if self._my_climate:
|
|
# Only the first time
|
|
self.my_climate_is_initialized()
|
|
return self._my_climate
|
|
|
|
@property
|
|
def device_info(self) -> DeviceInfo:
|
|
"""Return the device info."""
|
|
return DeviceInfo(
|
|
entry_type=DeviceEntryType.SERVICE,
|
|
identifiers={(DOMAIN, self._config_id)},
|
|
name=self._device_name,
|
|
manufacturer=DEVICE_MANUFACTURER,
|
|
model=DOMAIN,
|
|
)
|
|
|
|
def find_my_versatile_thermostat(self) -> BaseThermostat:
|
|
"""Find the underlying climate entity"""
|
|
try:
|
|
component: EntityComponent[ClimateEntity] = self.hass.data[CLIMATE_DOMAIN]
|
|
for entity in component.entities:
|
|
# _LOGGER.debug("Device_info is %s", entity.device_info)
|
|
if entity.device_info == self.device_info:
|
|
_LOGGER.debug("Found %s!", entity)
|
|
return entity
|
|
except KeyError:
|
|
pass
|
|
|
|
return None
|
|
|
|
@callback
|
|
async def async_added_to_hass(self):
|
|
"""Listen to my climate state change"""
|
|
|
|
# Check delay condition
|
|
async def try_find_climate(_):
|
|
_LOGGER.debug(
|
|
"%s - Calling VersatileThermostatBaseEntity.async_added_to_hass", self
|
|
)
|
|
mcl = self.my_climate
|
|
if mcl:
|
|
if self._cancel_call:
|
|
self._cancel_call()
|
|
self._cancel_call = None
|
|
self.async_on_remove(
|
|
async_track_state_change_event(
|
|
self.hass,
|
|
[mcl.entity_id],
|
|
self.async_my_climate_changed,
|
|
)
|
|
)
|
|
else:
|
|
_LOGGER.debug("%s - no entity to listen. Try later", self)
|
|
self._cancel_call = async_call_later(
|
|
self.hass, timedelta(seconds=1), try_find_climate
|
|
)
|
|
|
|
await try_find_climate(None)
|
|
|
|
@callback
|
|
def my_climate_is_initialized(self):
|
|
"""Called when the associated climate is initialized"""
|
|
return
|
|
|
|
@callback
|
|
async def async_my_climate_changed(
|
|
self, event: Event
|
|
): # pylint: disable=unused-argument
|
|
"""Called when my climate have change
|
|
This method aims to be overriden to take the status change
|
|
"""
|
|
return
|