From 357bafca85736193bb7b4d7503abf6d050816080 Mon Sep 17 00:00:00 2001 From: Alexander Ryazanov Date: Sat, 19 Jun 2021 16:04:37 +0300 Subject: [PATCH] feat: initial commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(config): Установка посредством HACS feat(config): Конфигурация посредством YAML и меню "Интеграции" feat(entities): Добавлены объекты `лицевой счёт`, `счётчик` и `последний платёж` feat(services): Добавлена возможность получения перечней платежей и показаний feat(services): Добавлена возможность передачи показаний feat(lang): Перевод (русский, английский) --- custom_components/tns_energo/__init__.py | 337 +++++++++ custom_components/tns_energo/_base.py | 573 +++++++++++++++ custom_components/tns_energo/_encoders.py | 128 ++++ custom_components/tns_energo/_schema.py | 126 ++++ custom_components/tns_energo/_util.py | 69 ++ custom_components/tns_energo/config_flow.py | 454 ++++++++++++ custom_components/tns_energo/const.py | 121 ++++ custom_components/tns_energo/manifest.json | 14 + custom_components/tns_energo/sensor.py | 668 ++++++++++++++++++ custom_components/tns_energo/services.yaml | 141 ++++ .../tns_energo/translations/en.json | 40 ++ .../tns_energo/translations/ru.json | 39 + hacs.json | 12 + 13 files changed, 2722 insertions(+) create mode 100644 custom_components/tns_energo/__init__.py create mode 100644 custom_components/tns_energo/_base.py create mode 100644 custom_components/tns_energo/_encoders.py create mode 100644 custom_components/tns_energo/_schema.py create mode 100644 custom_components/tns_energo/_util.py create mode 100644 custom_components/tns_energo/config_flow.py create mode 100644 custom_components/tns_energo/const.py create mode 100644 custom_components/tns_energo/manifest.json create mode 100644 custom_components/tns_energo/sensor.py create mode 100644 custom_components/tns_energo/services.yaml create mode 100644 custom_components/tns_energo/translations/en.json create mode 100644 custom_components/tns_energo/translations/ru.json create mode 100644 hacs.json diff --git a/custom_components/tns_energo/__init__.py b/custom_components/tns_energo/__init__.py new file mode 100644 index 0000000..f30621f --- /dev/null +++ b/custom_components/tns_energo/__init__.py @@ -0,0 +1,337 @@ +"""Energosbyt API""" +__all__ = ( + "CONFIG_SCHEMA", + "async_unload_entry", + "async_reload_entry", + "async_setup", + "async_setup_entry", + "config_flow", + "const", + "sensor", + "DOMAIN", +) + +import asyncio +import logging +from typing import Any, Dict, List, Mapping, Optional, Tuple + +import voluptuous as vol +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +from custom_components.tns_energo._base import UpdateDelegatorsDataType +from custom_components.tns_energo._schema import CONFIG_ENTRY_SCHEMA +from custom_components.tns_energo._util import ( + IS_IN_RUSSIA, + _find_existing_entry, + _make_log_prefix, + mask_username, +) +from custom_components.tns_energo.const import ( + CONF_USER_AGENT, + DATA_API_OBJECTS, + DATA_ENTITIES, + DATA_FINAL_CONFIG, + DATA_UPDATE_DELEGATORS, + DATA_UPDATE_LISTENERS, + DATA_YAML_CONFIG, + DOMAIN, + SUPPORTED_PLATFORMS, +) + +_LOGGER = logging.getLogger(__name__) + + +def _unique_entries(value: List[Mapping[str, Any]]) -> List[Mapping[str, Any]]: + users: Dict[Tuple[str, str], Optional[int]] = {} + + errors = [] + for i, config in enumerate(value): + user = config[CONF_USERNAME] + if user in users: + if users[user] is not None: + errors.append( + vol.Invalid("duplicate unique key, first encounter", path=[users[user]]) + ) + users[user] = None + errors.append(vol.Invalid("duplicate unique key, subsequent encounter", path=[i])) + else: + users[user] = i + + if errors: + if len(errors) > 1: + raise vol.MultipleInvalid(errors) + raise next(iter(errors)) + + return value + + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Any( + vol.Equal({}), + vol.All(cv.ensure_list, vol.Length(min=1), [CONFIG_ENTRY_SCHEMA], _unique_entries), + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass: HomeAssistantType, config: ConfigType): + """Set up the TNS Energo component.""" + domain_config = config.get(DOMAIN) + if not domain_config: + return True + + domain_data = {} + hass.data[DOMAIN] = domain_data + + yaml_config = {} + hass.data[DATA_YAML_CONFIG] = yaml_config + + for user_cfg in domain_config: + if not user_cfg: + continue + + username: str = user_cfg[CONF_USERNAME] + + key = username + log_prefix = f"[{mask_username(username)}] " + + _LOGGER.debug( + log_prefix + + ( + "Получена конфигурация из YAML" + if IS_IN_RUSSIA + else "YAML configuration encountered" + ) + ) + + existing_entry = _find_existing_entry(hass, username) + if existing_entry: + if existing_entry.source == config_entries.SOURCE_IMPORT: + yaml_config[key] = user_cfg + _LOGGER.debug( + log_prefix + + ( + "Соответствующая конфигурационная запись существует" + if IS_IN_RUSSIA + else "Matching config entry exists" + ) + ) + else: + _LOGGER.warning( + log_prefix + + ( + "Конфигурация из YAML переопределена другой конфигурацией!" + if IS_IN_RUSSIA + else "YAML config is overridden by another entry!" + ) + ) + continue + + # Save YAML configuration + yaml_config[key] = user_cfg + + _LOGGER.warning( + log_prefix + + ( + "Создание новой конфигурационной записи" + if IS_IN_RUSSIA + else "Creating new config entry" + ) + ) + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_USERNAME: username}, + ) + ) + + if not yaml_config: + _LOGGER.debug( + "Конфигурация из YAML не обнаружена" if IS_IN_RUSSIA else "YAML configuration not found" + ) + + return True + + +async def async_setup_entry(hass: HomeAssistantType, config_entry: config_entries.ConfigEntry): + username = config_entry.data[CONF_USERNAME] + unique_key = username + entry_id = config_entry.entry_id + log_prefix = f"[{mask_username(username)}] " + hass_data = hass.data + + _LOGGER.debug(log_prefix + "Setting up config entry") + + # Source full configuration + if config_entry.source == config_entries.SOURCE_IMPORT: + # Source configuration from YAML + yaml_config = hass_data.get(DATA_YAML_CONFIG) + + if not yaml_config or unique_key not in yaml_config: + _LOGGER.info( + log_prefix + + ( + f"Удаление записи {entry_id} после удаления из конфигурации YAML" + if IS_IN_RUSSIA + else f"Removing entry {entry_id} after removal from YAML configuration" + ) + ) + hass.async_create_task(hass.config_entries.async_remove(entry_id)) + return False + + user_cfg = yaml_config[unique_key] + + else: + # Source and convert configuration from input post_fields + all_cfg = {**config_entry.data} + + if config_entry.options: + all_cfg.update(config_entry.options) + + try: + user_cfg = CONFIG_ENTRY_SCHEMA(all_cfg) + except vol.Invalid as e: + _LOGGER.error( + log_prefix + + ( + "Сохранённая конфигурация повреждена" + if IS_IN_RUSSIA + else "Configuration invalid" + ) + + ": " + + repr(e) + ) + return False + + _LOGGER.info( + log_prefix + + ("Применение конфигурационной записи" if IS_IN_RUSSIA else "Applying configuration entry") + ) + + from tns_energo_api import TNSEnergoAPI + from tns_energo_api.exceptions import TNSEnergoException + + try: + api_object = TNSEnergoAPI( + username=username, + password=user_cfg[CONF_PASSWORD], + ) + + await api_object.async_authenticate() + + # Fetch all accounts + accounts = await api_object.async_get_accounts_list() + + except TNSEnergoException as e: + _LOGGER.error( + log_prefix + + ("Невозможно выполнить авторизацию" if IS_IN_RUSSIA else "Error authenticating") + + ": " + + repr(e) + ) + raise ConfigEntryNotReady + + if not accounts: + # Cancel setup because no accounts provided + _LOGGER.warning( + log_prefix + ("Лицевые счета не найдены" if IS_IN_RUSSIA else "No accounts found") + ) + return False + + _LOGGER.debug( + log_prefix + + ( + f"Найдено {len(accounts)} лицевых счетов" + if IS_IN_RUSSIA + else f"Found {len(accounts)} accounts" + ) + ) + + api_objects: Dict[str, "TNSEnergoAPI"] = hass_data.setdefault(DATA_API_OBJECTS, {}) + + # Create placeholders + api_objects[entry_id] = api_object + hass_data.setdefault(DATA_ENTITIES, {})[entry_id] = {} + hass_data.setdefault(DATA_FINAL_CONFIG, {})[entry_id] = user_cfg + hass.data.setdefault(DATA_UPDATE_DELEGATORS, {})[entry_id] = {} + + # Forward entry setup to sensor platform + for domain in SUPPORTED_PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup( + config_entry, + domain, + ) + ) + + # Create options update listener + update_listener = config_entry.add_update_listener(async_reload_entry) + hass_data.setdefault(DATA_UPDATE_LISTENERS, {})[entry_id] = update_listener + + _LOGGER.debug( + log_prefix + ("Применение конфигурации успешно" if IS_IN_RUSSIA else "Setup successful") + ) + return True + + +async def async_reload_entry( + hass: HomeAssistantType, + config_entry: config_entries.ConfigEntry, +) -> None: + """Reload Lkcomu TNS Energo entry""" + log_prefix = _make_log_prefix(config_entry, "setup") + _LOGGER.info( + log_prefix + + ("Перезагрузка интеграции" if IS_IN_RUSSIA else "Reloading configuration entry") + ) + await hass.config_entries.async_reload(config_entry.entry_id) + + +async def async_unload_entry( + hass: HomeAssistantType, + config_entry: config_entries.ConfigEntry, +) -> bool: + """Unload Lkcomu TNS Energo entry""" + log_prefix = _make_log_prefix(config_entry, "setup") + entry_id = config_entry.entry_id + + update_delegators: UpdateDelegatorsDataType = hass.data[DATA_UPDATE_DELEGATORS].pop(entry_id) + + tasks = [ + hass.config_entries.async_forward_entry_unload(config_entry, domain) + for domain in update_delegators.keys() + ] + + unload_ok = all(await asyncio.gather(*tasks)) + + if unload_ok: + hass.data[DATA_API_OBJECTS].pop(entry_id) + hass.data[DATA_FINAL_CONFIG].pop(entry_id) + + cancel_listener = hass.data[DATA_UPDATE_LISTENERS].pop(entry_id) + cancel_listener() + + _LOGGER.info( + log_prefix + + ("Интеграция выгружена" if IS_IN_RUSSIA else "Unloaded configuration entry") + ) + + else: + _LOGGER.warning( + log_prefix + + ( + "При выгрузке конфигурации произошла ошибка" + if IS_IN_RUSSIA + else "Failed to unload configuration entry" + ) + ) + + return unload_ok diff --git a/custom_components/tns_energo/_base.py b/custom_components/tns_energo/_base.py new file mode 100644 index 0000000..6700c2f --- /dev/null +++ b/custom_components/tns_energo/_base.py @@ -0,0 +1,573 @@ +__all__ = ( + "make_common_async_setup_entry", + "TNSEnergoEntity", + "async_refresh_api_data", + "async_register_update_delegator", + "UpdateDelegatorsDataType", + "EntitiesDataType", + "SupportedServicesType", +) + +import asyncio +import logging +import re +from abc import abstractmethod +from datetime import timedelta +from typing import ( + Any, + Callable, + ClassVar, + Dict, + Generic, + Hashable, + Iterable, + List, + Mapping, + MutableMapping, + Optional, + Set, + SupportsInt, + TYPE_CHECKING, + Tuple, + Type, + TypeVar, + Union, +) +from urllib.parse import urlparse + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ATTRIBUTION, CONF_DEFAULT, CONF_SCAN_INTERVAL, CONF_USERNAME +from homeassistant.helpers import entity_platform +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import ConfigType, HomeAssistantType, StateType +from homeassistant.util import as_local, utcnow + +from custom_components.tns_energo._util import IS_IN_RUSSIA, mask_username, with_auto_auth +from custom_components.tns_energo.const import ( + ATTRIBUTION_EN, + ATTRIBUTION_RU, + ATTR_ACCOUNT_CODE, + ATTR_ACCOUNT_ID, + CONF_ACCOUNTS, + CONF_DEV_PRESENTATION, + CONF_NAME_FORMAT, + DATA_API_OBJECTS, + DATA_ENTITIES, + DATA_FINAL_CONFIG, + DATA_UPDATE_DELEGATORS, + FORMAT_VAR_ACCOUNT_CODE, + FORMAT_VAR_ACCOUNT_ID, + FORMAT_VAR_CODE, + FORMAT_VAR_ID, + SUPPORTED_PLATFORMS, +) +from tns_energo_api.exceptions import TNSEnergoException + +if TYPE_CHECKING: + from tns_energo_api import Account, TNSEnergoAPI + + from homeassistant.helpers.entity_registry import RegistryEntry + +_LOGGER = logging.getLogger(__name__) + +_TTNSEnergoEntity = TypeVar("_TTNSEnergoEntity", bound="TNSEnergoEntity") + +AddEntitiesCallType = Callable[[List["MESEntity"], bool], Any] +UpdateDelegatorsDataType = Dict[str, Tuple[AddEntitiesCallType, Set[Type["MESEntity"]]]] +EntitiesDataType = Dict[Type["TNSEnergoEntity"], Dict[Hashable, "TNSEnergoEntity"]] + + +def make_common_async_setup_entry( + entity_cls: Type["TNSEnergoEntity"], *args: Type["TNSEnergoEntity"] +): + async def _async_setup_entry( + hass: HomeAssistantType, + config_entry: ConfigEntry, + async_add_devices, + ): + current_entity_platform = entity_platform.current_platform.get() + + log_prefix = ( + f"[{mask_username(config_entry.data[CONF_USERNAME])}]" + f"[{current_entity_platform.domain}][setup] " + ) + _LOGGER.debug( + log_prefix + + ( + "Регистрация делегата обновлений" + if IS_IN_RUSSIA + else "Registering update delegator" + ) + ) + + await async_register_update_delegator( + hass, + config_entry, + current_entity_platform.domain, + async_add_devices, + entity_cls, + *args, + ) + + _async_setup_entry.__name__ = "async_setup_entry" + + return _async_setup_entry + + +async def async_register_update_delegator( + hass: HomeAssistantType, + config_entry: ConfigEntry, + platform: str, + async_add_entities: AddEntitiesCallType, + entity_cls: Type["TNSEnergoEntity"], + *args: Type["TNSEnergoEntity"], + update_after_complete: bool = True, +): + entry_id = config_entry.entry_id + + update_delegators: UpdateDelegatorsDataType = hass.data[DATA_UPDATE_DELEGATORS][entry_id] + update_delegators[platform] = (async_add_entities, {entity_cls, *args}) + + if update_after_complete: + if len(update_delegators) != len(SUPPORTED_PLATFORMS): + return + + await async_refresh_api_data(hass, config_entry) + + +DEV_CLASSES_PROCESSED = set() + + +async def async_refresh_api_data(hass: HomeAssistantType, config_entry: ConfigEntry): + entry_id = config_entry.entry_id + api: "TNSEnergoAPI" = hass.data[DATA_API_OBJECTS][entry_id] + + accounts = await with_auto_auth(api, api.async_get_accounts_list) + + update_delegators: UpdateDelegatorsDataType = hass.data[DATA_UPDATE_DELEGATORS][entry_id] + + log_prefix_base = f"[{mask_username(config_entry.data[CONF_USERNAME])}]" + refresh_log_prefix = log_prefix_base + "[refresh] " + + _LOGGER.info( + refresh_log_prefix + + ( + "Запуск обновления связанных с профилем данных" + if IS_IN_RUSSIA + else "Beginning profile-related data update" + ) + ) + + if not update_delegators: + return + + entities: EntitiesDataType = hass.data[DATA_ENTITIES][entry_id] + final_config: ConfigType = dict(hass.data[DATA_FINAL_CONFIG][entry_id]) + + dev_presentation = final_config.get(CONF_DEV_PRESENTATION) + dev_log_prefix = log_prefix_base + "[dev] " + + if dev_presentation: + from pprint import pformat + + _LOGGER.debug( + dev_log_prefix + + ("Конечная конфигурация:" if IS_IN_RUSSIA else "Final configuration:") + + "\n" + + pformat(final_config) + ) + + tasks = [] + + async def _wrap_update_task(update_task): + try: + return await update_task + except BaseException as task_exception: + _LOGGER.exception( + f"Error occurred during task execution: {repr(task_exception)}", + exc_info=task_exception, + ) + return None + + accounts_config = final_config.get(CONF_ACCOUNTS) or {} + account_default_config = final_config[CONF_DEFAULT] + + for account in accounts: + account_config = accounts_config.get(account.code) + account_log_prefix_base = refresh_log_prefix + f"[{mask_username(account.code)}]" + + if account_config is None: + account_config = account_default_config + + if account_config is False: + continue + + for platform, (async_add_entities, entity_classes) in update_delegators.items(): + platform_log_prefix_base = account_log_prefix_base + f"[{platform}]" + for entity_cls in entity_classes: + cls_log_prefix_base = platform_log_prefix_base + f"[{entity_cls.__name__}]" + if account_config[entity_cls.config_key] is False: + _LOGGER.debug( + log_prefix_base + + " " + + ( + f"Лицевой счёт пропущен согласно фильтрации" + if IS_IN_RUSSIA + else f"Account skipped due to filtering" + ) + ) + continue + + if dev_presentation: + dev_key = (entity_cls, account.provider_type) + if dev_key in DEV_CLASSES_PROCESSED: + _LOGGER.debug( + cls_log_prefix_base + + "[dev] " + + ( + f"Пропущен лицевой счёт ({mask_username(account.code)}) " + f"по уникальности типа" + if IS_IN_RUSSIA + else f"Account skipped ({mask_username(account.code)}) " + f"due to type uniqueness" + ) + ) + continue + + DEV_CLASSES_PROCESSED.add(dev_key) + + current_entities = entities.setdefault(entity_cls, {}) + + _LOGGER.debug( + cls_log_prefix_base + + "[update] " + + ( + "Планирование процедуры обновления" + if IS_IN_RUSSIA + else "Planning update procedure" + ) + ) + + tasks.append( + hass.async_create_task( + _wrap_update_task( + entity_cls.async_refresh_accounts( + current_entities, + account, + config_entry, + account_config, + async_add_entities, + ) + ) + ) + ) + + if tasks: + _LOGGER.info( + refresh_log_prefix + + ( + "Выполняется действий по обновлению" + if IS_IN_RUSSIA + else "Performing update operations" + ) + + ": " + + str(len(tasks)) + ) + await asyncio.wait(tasks, return_when=asyncio.ALL_COMPLETED) + + else: + _LOGGER.warning( + refresh_log_prefix + + ( + "Отсутствуют подходящие платформы для конфигурации" + if IS_IN_RUSSIA + else "Missing suitable platforms for configuration" + ) + ) + + +class NameFormatDict(dict): + def __missing__(self, key: str): + if key.endswith("_upper") and key[:-6] in self: + return str(self[key[:-6]]).upper() + if key.endswith("_cap") and key[:-4] in self: + return str(self[key[:-4]]).capitalize() + if key.endswith("_title") and key[:-6] in self: + return str(self[key[:-6]]).title() + return "{{" + str(key) + "}}" + + +_TData = TypeVar("_TData") +_TAccount = TypeVar("_TAccount", bound="Account") + + +SupportedServicesType = Mapping[ + Optional[Tuple[type, SupportsInt]], + Mapping[str, Union[dict, Callable[[dict], dict]]], +] + + +class TNSEnergoEntity(Entity, Generic[_TAccount]): + config_key: ClassVar[str] = NotImplemented + + _supported_services: ClassVar[SupportedServicesType] = {} + + @property + def entity_id_prefix(self) -> str: + return f"tns_{self._account.api.region}_{self._account.code}" + + def __init__( + self, + account: _TAccount, + account_config: ConfigType, + ) -> None: + self._account: _TAccount = account + self._account_config: ConfigType = account_config + self._entity_updater = None + + def _handle_dev_presentation( + self, + mapping: MutableMapping[str, Any], + filter_vars: Iterable[str], + blackout_vars: Optional[Iterable[str]] = None, + ) -> None: + if self._account_config[CONF_DEV_PRESENTATION]: + filter_vars = set(filter_vars) + if blackout_vars is not None: + blackout_vars = set(blackout_vars) + filter_vars.difference_update(blackout_vars) + + for attr in blackout_vars: + value = mapping.get(attr) + if value is not None: + if isinstance(value, float): + value = "#####.###" + elif isinstance(value, int): + value = "#####" + elif isinstance(value, str): + value = "XXXXX" + else: + value = "*****" + mapping[attr] = value + + for attr in filter_vars: + value = mapping.get(attr) + if value is not None: + value = re.sub(r"[A-Za-z]", "X", str(value)) + value = re.sub(r"[0-9]", "#", value) + value = re.sub(r"\w+", "*", value) + mapping[attr] = value + + ################################################################################# + # Config getter helpers + ################################################################################# + + @property + def scan_interval(self) -> timedelta: + return self._account_config[CONF_SCAN_INTERVAL][self.config_key] + + @property + def name_format(self) -> str: + return self._account_config[CONF_NAME_FORMAT][self.config_key] + + ################################################################################# + # Base overrides + ################################################################################# + + @property + def should_poll(self) -> bool: + """Return True if entity has to be polled for state. + + False if entity pushes its state to HA. + """ + return False + + @property + def device_state_attributes(self): + """Return the attribute(s) of the sensor""" + + attributes = { + ATTR_ATTRIBUTION: (ATTRIBUTION_RU if IS_IN_RUSSIA else ATTRIBUTION_EN) + % urlparse(self._account.api.lk_region_url).netloc, + **(self.sensor_related_attributes or {}), + } + + if ATTR_ACCOUNT_CODE not in attributes: + attributes[ATTR_ACCOUNT_CODE] = self._account.code + + self._handle_dev_presentation( + attributes, + (ATTR_ACCOUNT_CODE, ATTR_ACCOUNT_ID), + ) + + return attributes + + @property + def name(self) -> Optional[str]: + name_format_values = { + key: ("" if value is None else str(value)) + for key, value in self.name_format_values.items() + } + + if FORMAT_VAR_CODE not in name_format_values: + name_format_values[FORMAT_VAR_CODE] = self.code + + if FORMAT_VAR_ACCOUNT_CODE not in name_format_values: + name_format_values[FORMAT_VAR_ACCOUNT_CODE] = self._account.code + + self._handle_dev_presentation( + name_format_values, + (FORMAT_VAR_CODE, FORMAT_VAR_ACCOUNT_CODE), + (FORMAT_VAR_ACCOUNT_ID, FORMAT_VAR_ID), + ) + + return self.name_format.format_map(NameFormatDict(name_format_values)) + + ################################################################################# + # Hooks for adding entity to internal registry + ################################################################################# + + async def async_added_to_hass(self) -> None: + _LOGGER.info(self.log_prefix + "Adding to HomeAssistant") + self.updater_restart() + + async def async_will_remove_from_hass(self) -> None: + _LOGGER.info(self.log_prefix + "Removing from HomeAssistant") + self.updater_stop() + + registry_entry: Optional["RegistryEntry"] = self.registry_entry + if registry_entry: + entry_id: Optional[str] = registry_entry.config_entry_id + if entry_id: + data_entities: EntitiesDataType = self.hass.data[DATA_ENTITIES][entry_id] + cls_entities = data_entities.get(self.__class__) + if cls_entities: + remove_indices = [] + for idx, entity in enumerate(cls_entities): + if self is entity: + remove_indices.append(idx) + for idx in remove_indices: + cls_entities.pop(idx) + + ################################################################################# + # Updater management API + ################################################################################# + + @property + def log_prefix(self) -> str: + return f"[{self.config_key}][{self.entity_id or ''}] " + + def updater_stop(self) -> None: + if self._entity_updater is not None: + _LOGGER.debug(self.log_prefix + "Stopping updater") + self._entity_updater() + self._entity_updater = None + + def updater_restart(self) -> None: + log_prefix = self.log_prefix + scan_interval = self.scan_interval + + self.updater_stop() + + async def _update_entity(*_): + nonlocal self + _LOGGER.debug(log_prefix + f"Executing updater on interval") + await self.async_update_ha_state(force_refresh=True) + + _LOGGER.debug( + log_prefix + f"Starting updater " + f"(interval: {scan_interval.total_seconds()} seconds, " + f"next call: {as_local(utcnow()) + scan_interval})" + ) + self._entity_updater = async_track_time_interval( + self.hass, + _update_entity, + scan_interval, + ) + + async def updater_execute(self) -> None: + self.updater_stop() + try: + await self.async_update_ha_state(force_refresh=True) + finally: + self.updater_restart() + + async def async_update(self) -> None: + await with_auto_auth( + self._account.api, + self.async_update_internal, + ) + + ################################################################################# + # Functional base for inherent classes + ################################################################################# + + @classmethod + @abstractmethod + async def async_refresh_accounts( + cls: Type[_TTNSEnergoEntity], + entities: Dict[Hashable, _TTNSEnergoEntity], + account: "Account", + config_entry: ConfigEntry, + account_config: ConfigType, + async_add_entities: Callable[[List[_TTNSEnergoEntity], bool], Any], + ): + raise NotImplementedError + + ################################################################################# + # Data-oriented base for inherent classes + ################################################################################# + + @abstractmethod + async def async_update_internal(self) -> None: + raise NotImplementedError + + @property + @abstractmethod + def code(self) -> str: + raise NotImplementedError + + @property + @abstractmethod + def state(self) -> StateType: + raise NotImplementedError + + @property + @abstractmethod + def icon(self) -> str: + raise NotImplementedError + + @property + @abstractmethod + def sensor_related_attributes(self) -> Optional[Mapping[str, Any]]: + raise NotImplementedError + + @property + @abstractmethod + def name_format_values(self) -> Mapping[str, Any]: + raise NotImplementedError + + @property + @abstractmethod + def unique_id(self) -> str: + raise NotImplementedError + + @property + @abstractmethod + def device_class(self) -> Optional[str]: + raise NotImplementedError + + def register_supported_services(self, for_object: Optional[Any] = None) -> None: + for type_feature, services in self._supported_services.items(): + result, features = ( + (True, None) + if type_feature is None + else (isinstance(for_object, type_feature[0]), (int(type_feature[1]),)) + ) + + if result: + for service, schema in services.items(): + self.platform.async_register_entity_service( + service, schema, "async_service_" + service, features + ) diff --git a/custom_components/tns_energo/_encoders.py b/custom_components/tns_energo/_encoders.py new file mode 100644 index 0000000..211ae61 --- /dev/null +++ b/custom_components/tns_energo/_encoders.py @@ -0,0 +1,128 @@ +from typing import TYPE_CHECKING + +from homeassistant.const import ATTR_CODE + +from custom_components.tns_energo.const import ( + ATTR_ACCOUNT_CODE, + ATTR_ADDRESS, + ATTR_AMOUNT, + ATTR_CHECKUP_DATE, + ATTR_CHECKUP_STATUS, + ATTR_CHECKUP_URL, + ATTR_CONTROLLED_BY_CODE, + ATTR_DIGITAL_INVOICES_EMAIL, + ATTR_DIGITAL_INVOICES_EMAIL_COMMENT, + ATTR_DIGITAL_INVOICES_ENABLED, + ATTR_DIGITAL_INVOICES_IGNORED, + ATTR_EMAIL, + ATTR_INSTALL_LOCATION, + ATTR_IS_CONTROLLED, + ATTR_IS_CONTROLLING, + ATTR_LAST_CHECKUP_DATE, + ATTR_LAST_INDICATIONS_DATE, + ATTR_METER_CODE, + ATTR_METER_ID, + ATTR_MODEL, + ATTR_PAID_AT, + ATTR_PRECISION, + ATTR_SERVICE_NAME, + ATTR_SERVICE_TYPE, + ATTR_SOURCE, + ATTR_STATUS, + ATTR_TAKEN_ON, + ATTR_TRANSACTION_ID, + ATTR_TRANSMISSION_COEFFICIENT, + ATTR_TYPE, + ATTR_ZONES, +) + +if TYPE_CHECKING: + from tns_energo_api import Account, Indication, Meter, Payment + + +def payment_to_attrs(payment: "Payment"): + return { + ATTR_AMOUNT: payment.amount, + ATTR_PAID_AT: payment.paid_at.isoformat(), + ATTR_TRANSACTION_ID: payment.transaction_id, + ATTR_SOURCE: payment.source, + } + + +def account_to_attrs(account: "Account"): + attributes = { + ATTR_ADDRESS: account.address, + ATTR_CODE: account.code, + ATTR_EMAIL: account.email, + ATTR_IS_CONTROLLED: account.is_controlled, + ATTR_IS_CONTROLLING: account.is_controlling, + ATTR_DIGITAL_INVOICES_IGNORED: account.digital_invoices_ignored, + ATTR_DIGITAL_INVOICES_EMAIL: account.digital_invoices_email, + ATTR_DIGITAL_INVOICES_ENABLED: account.digital_invoices_enabled, + ATTR_DIGITAL_INVOICES_EMAIL_COMMENT: account.digital_invoices_email_comment, + } + + if account.is_controlled: + attributes[ATTR_CONTROLLED_BY_CODE] = account.controlled_by_code + + return attributes + + +def meter_to_attrs(meter: "Meter"): + last_checkup_date = meter.last_checkup_date + if last_checkup_date is not None: + last_checkup_date = last_checkup_date.isoformat() + + checkup_date = meter.checkup_date + if checkup_date is not None: + checkup_date = checkup_date.isoformat() + + attributes = { + ATTR_METER_CODE: meter.code, + ATTR_ACCOUNT_CODE: meter.account.code, + ATTR_LAST_INDICATIONS_DATE: meter.last_indications_date, + ATTR_MODEL: meter.model, + ATTR_SERVICE_NAME: meter.service_name, + ATTR_SERVICE_TYPE: meter.service_number, + ATTR_STATUS: meter.status, + ATTR_TRANSMISSION_COEFFICIENT: meter.transmission_coefficient, + ATTR_INSTALL_LOCATION: meter.install_location, + ATTR_PRECISION: meter.precision, + ATTR_CHECKUP_STATUS: meter.checkup_status, + ATTR_CHECKUP_DATE: checkup_date, + ATTR_LAST_CHECKUP_DATE: last_checkup_date, + ATTR_CHECKUP_URL: meter.checkup_url, + ATTR_TYPE: meter.type, + } + + # Installation date attribute + + last_indications_date = meter.last_indications_date + attributes[ATTR_LAST_INDICATIONS_DATE] = ( + None if last_indications_date is None else last_indications_date.isoformat() + ) + + # Add zone information + for zone_id, zone_def in meter.zones.items(): + iterator = [ + ("name", zone_def.name), + ("label", zone_def.label), + ("last_indication", zone_def.last_indication or 0), + ("max_difference", zone_def.max_indication_difference), + ("identifier", zone_def.identifier), + ] + + for attribute, value in iterator: + attributes[f"zone_{zone_id}_{attribute}"] = value + + return attributes + + +def indication_to_attrs(indication: "Indication"): + return { + ATTR_METER_ID: indication.meter_identifier, + ATTR_TAKEN_ON: indication.taken_on.isoformat(), + ATTR_METER_CODE: indication.meter_code, + ATTR_STATUS: indication.status, + ATTR_ZONES: indication.zones, + } diff --git a/custom_components/tns_energo/_schema.py b/custom_components/tns_energo/_schema.py new file mode 100644 index 0000000..47b640d --- /dev/null +++ b/custom_components/tns_energo/_schema.py @@ -0,0 +1,126 @@ +__all__ = ("CONFIG_ENTRY_SCHEMA",) + +from datetime import timedelta + +import voluptuous as vol +from homeassistant.const import ( + CONF_DEFAULT, + CONF_PASSWORD, + CONF_SCAN_INTERVAL, + CONF_USERNAME, +) +from homeassistant.helpers import config_validation as cv + +from custom_components.tns_energo._util import IS_IN_RUSSIA +from custom_components.tns_energo.const import ( + CONF_ACCOUNTS, + CONF_DEV_PRESENTATION, + CONF_LAST_PAYMENT, + CONF_METERS, + CONF_NAME_FORMAT, + DEFAULT_NAME_FORMAT_EN_ACCOUNTS, + DEFAULT_NAME_FORMAT_EN_LAST_PAYMENT, + DEFAULT_NAME_FORMAT_EN_METERS, + DEFAULT_NAME_FORMAT_RU_ACCOUNTS, + DEFAULT_NAME_FORMAT_RU_LAST_PAYMENT, + DEFAULT_NAME_FORMAT_RU_METERS, + DEFAULT_SCAN_INTERVAL, +) + +MIN_SCAN_INTERVAL = timedelta(seconds=60) + + +(default_name_format_accounts, default_name_format_meters, default_name_format_last_payment,) = ( + ( + DEFAULT_NAME_FORMAT_RU_ACCOUNTS, + DEFAULT_NAME_FORMAT_RU_METERS, + DEFAULT_NAME_FORMAT_RU_LAST_PAYMENT, + ) + if IS_IN_RUSSIA + else ( + DEFAULT_NAME_FORMAT_EN_ACCOUNTS, + DEFAULT_NAME_FORMAT_EN_METERS, + DEFAULT_NAME_FORMAT_EN_LAST_PAYMENT, + ) +) + + +NAME_FORMAT_SCHEMA = vol.Schema( + { + vol.Optional(CONF_ACCOUNTS, default=default_name_format_accounts): cv.string, + vol.Optional(CONF_METERS, default=default_name_format_meters): cv.string, + vol.Optional(CONF_LAST_PAYMENT, default=default_name_format_last_payment): cv.string, + }, + extra=vol.PREVENT_EXTRA, +) + + +SCAN_INTERVAL_SCHEMA = vol.Schema( + { + vol.Optional(CONF_ACCOUNTS, default=DEFAULT_SCAN_INTERVAL): cv.positive_time_period, + vol.Optional(CONF_METERS, default=DEFAULT_SCAN_INTERVAL): cv.positive_time_period, + vol.Optional(CONF_LAST_PAYMENT, default=DEFAULT_SCAN_INTERVAL): cv.positive_time_period, + } +) + + +def _validator_name_format_schema(schema): + return vol.Any( + vol.All(cv.string, lambda x: {CONF_ACCOUNTS: x}, schema), + schema, + ) + + +GENERIC_ACCOUNT_SCHEMA = vol.Schema( + { + vol.Optional(CONF_ACCOUNTS, default=True): cv.boolean, + vol.Optional(CONF_METERS, default=True): cv.boolean, + vol.Optional(CONF_LAST_PAYMENT, default=True): cv.boolean, + vol.Optional(CONF_DEV_PRESENTATION, default=False): cv.boolean, + vol.Optional(CONF_NAME_FORMAT, default=lambda: NAME_FORMAT_SCHEMA({})): vol.Any( + vol.All(cv.string, lambda x: {CONF_ACCOUNTS: x}, NAME_FORMAT_SCHEMA), + NAME_FORMAT_SCHEMA, + ), + vol.Optional(CONF_SCAN_INTERVAL, default=lambda: SCAN_INTERVAL_SCHEMA({})): vol.Any( + vol.All( + cv.positive_time_period, + lambda x: dict.fromkeys((CONF_ACCOUNTS, CONF_METERS, CONF_LAST_PAYMENT), x), + SCAN_INTERVAL_SCHEMA, + ), + SCAN_INTERVAL_SCHEMA, + ), + }, + extra=vol.PREVENT_EXTRA, +) + + +def _make_account_validator(account_schema): + return vol.Any( + vol.Equal(False), # For disabling + vol.All(vol.Equal(True), lambda _: account_schema({})), # For default + account_schema, # For custom + ) + + +GENERIC_ACCOUNT_VALIDATOR = _make_account_validator(GENERIC_ACCOUNT_SCHEMA) + + +CONFIG_ENTRY_SCHEMA = vol.Schema( + { + # Primary API configuration + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_DEV_PRESENTATION, default=False): cv.boolean, + # Additional API configuration + vol.Optional( + CONF_DEFAULT, default=lambda: GENERIC_ACCOUNT_SCHEMA({}) + ): GENERIC_ACCOUNT_VALIDATOR, + vol.Optional(CONF_ACCOUNTS): vol.Any( + vol.All( + cv.ensure_list, [cv.string], lambda x: {y: GENERIC_ACCOUNT_SCHEMA({}) for y in x} + ), + vol.Schema({cv.string: GENERIC_ACCOUNT_VALIDATOR}), + ), + }, + extra=vol.PREVENT_EXTRA, +) diff --git a/custom_components/tns_energo/_util.py b/custom_components/tns_energo/_util.py new file mode 100644 index 0000000..af304b3 --- /dev/null +++ b/custom_components/tns_energo/_util.py @@ -0,0 +1,69 @@ +import datetime +import re +from datetime import timedelta +from typing import Any, Callable, Coroutine, Optional, TypeVar, Union + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_USERNAME +from homeassistant.core import callback +from homeassistant.helpers.entity_platform import EntityPlatform +from homeassistant.helpers.typing import HomeAssistantType + +from custom_components.tns_energo.const import DOMAIN +from tns_energo_api import TNSEnergoAPI, TNSEnergoException + + +def _make_log_prefix( + config_entry: Union[Any, ConfigEntry], domain: Union[Any, EntityPlatform], *args +): + join_args = [ + ( + config_entry.entry_id[-6:] + if isinstance(config_entry, ConfigEntry) + else str(config_entry) + ), + (domain.domain if isinstance(domain, EntityPlatform) else str(domain)), + ] + if args: + join_args.extend(map(str, args)) + + return "[" + "][".join(join_args) + "] " + + +@callback +def _find_existing_entry( + hass: HomeAssistantType, username: str +) -> Optional[config_entries.ConfigEntry]: + existing_entries = hass.config_entries.async_entries(DOMAIN) + for config_entry in existing_entries: + if config_entry.data[CONF_USERNAME] == username: + return config_entry + + +_RE_USERNAME_MASK = re.compile(r"^(\W*)(.).*(.)$") + + +def mask_username(username: str): + parts = username.split("@") + return "@".join(map(lambda x: _RE_USERNAME_MASK.sub(r"\1\2***\3", x), parts)) + + +LOCAL_TIMEZONE = datetime.datetime.now(datetime.timezone.utc).astimezone().tzinfo + +# Kaliningrad is excluded as it is not supported +IS_IN_RUSSIA = timedelta(hours=3) <= LOCAL_TIMEZONE.utcoffset(None) <= timedelta(hours=12) + + +_T = TypeVar("_T") +_RT = TypeVar("_RT") + + +async def with_auto_auth( + api: "TNSEnergoAPI", async_getter: Callable[..., Coroutine[Any, Any, _RT]], *args, **kwargs +) -> _RT: + try: + return await async_getter(*args, **kwargs) + except TNSEnergoException: + await api.async_authenticate() + return await async_getter(*args, **kwargs) diff --git a/custom_components/tns_energo/config_flow.py b/custom_components/tns_energo/config_flow.py new file mode 100644 index 0000000..702f28c --- /dev/null +++ b/custom_components/tns_energo/config_flow.py @@ -0,0 +1,454 @@ +"""Inter RAO integration config and option flow handlers""" +import asyncio +import logging +from collections import OrderedDict +from datetime import timedelta +from functools import partial +from typing import ( + Any, + ClassVar, + Dict, + Iterable, + List, + Mapping, + Optional, + TYPE_CHECKING, + Type, + Union, +) + +import voluptuous as vol +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow +from homeassistant.const import ( + CONF_DEFAULT, + CONF_ENTITIES, + CONF_PASSWORD, + CONF_SCAN_INTERVAL, + CONF_USERNAME, +) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType + +from custom_components.tns_energo.const import ( + CONF_ACCOUNTS, + CONF_LAST_INVOICE, + CONF_METERS, + CONF_NAME_FORMAT, + CONF_USER_AGENT, + DATA_API_OBJECTS, + DATA_ENTITIES, + DOMAIN, +) +from tns_energo_api import Account, TNSEnergoAPI, TNSEnergoException + +if TYPE_CHECKING: + from custom_components.tns_energo._base import TNSEnergoEntity + +_LOGGER = logging.getLogger(__name__) + +CONF_DISABLE_ENTITIES = "disable_entities" + + +def _flatten(conf: Any): + if isinstance(conf, timedelta): + return conf.total_seconds() + if isinstance(conf, Mapping): + return dict(zip(conf.keys(), map(_flatten, conf.values()))) + if isinstance(conf, (list, tuple)): + return list(map(_flatten, conf)) + return conf + + +class TNSEnergoConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Inter RAO config entries.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + CACHED_API_TYPE_NAMES: ClassVar[Optional[Dict[str, Any]]] = {} + + def __init__(self): + """Instantiate config flow.""" + self._current_type = None + self._current_config: Optional[ConfigType] = None + self._devices_info = None + self._accounts: Optional[Mapping[int, "Account"]] = None + + self.schema_user = None + + async def _check_entry_exists(self, username: str): + current_entries = self._async_current_entries() + + for config_entry in current_entries: + if config_entry.data[CONF_USERNAME] == username: + return True + + return False + + # Initial step for user interaction + async def async_step_user(self, user_input: Optional[ConfigType] = None) -> Dict[str, Any]: + """Handle a flow start.""" + if self.schema_user is None: + schema_user = OrderedDict() + schema_user[vol.Required(CONF_USERNAME)] = str + schema_user[vol.Required(CONF_PASSWORD)] = str + self.schema_user = vol.Schema(schema_user) + + if user_input is None: + return self.async_show_form(step_id="user", data_schema=self.schema_user) + + username = user_input[CONF_USERNAME] + + if await self._check_entry_exists(username): + return self.async_abort(reason="already_configured_service") + + async with TNSEnergoAPI( + username=username, + password=user_input[CONF_PASSWORD], + ) as api: + try: + await api.async_authenticate() + + except TNSEnergoException as e: + _LOGGER.error(f"Authentication error: {repr(e)}") + return self.async_show_form( + step_id="user", + data_schema=self.schema_user, + errors={"base": "authentication_error"}, + ) + + try: + self._accounts = await api.async_get_accounts_list() + + except TNSEnergoException as e: + _LOGGER.error(f"Request error: {repr(e)}") + return self.async_show_form( + step_id="user", + data_schema=self.schema_user, + errors={"base": "update_accounts_error"}, + ) + + self._current_config = user_input + + return await self.async_step_select() + + async def async_step_select(self, user_input: Optional[ConfigType] = None) -> Dict[str, Any]: + accounts, current_config = self._accounts, self._current_config + if user_input is None: + if accounts is None or current_config is None: + return await self.async_step_user() + + return self.async_show_form( + step_id="select", + data_schema=vol.Schema( + { + vol.Optional(CONF_ACCOUNTS): cv.multi_select( + {account.code: account.code for account in self._accounts} + ) + } + ), + ) + + if user_input[CONF_ACCOUNTS]: + current_config[CONF_DEFAULT] = False + current_config[CONF_ACCOUNTS] = dict.fromkeys(user_input[CONF_ACCOUNTS], True) + + return self.async_create_entry( + title=current_config[CONF_USERNAME], + data=_flatten(current_config), + ) + + async def async_step_import(self, user_input: Optional[ConfigType] = None) -> Dict[str, Any]: + _LOGGER.debug("Executing import step: %s", user_input) + + if user_input is None: + return self.async_abort(reason="unknown_error") + + username = user_input[CONF_USERNAME] + + if await self._check_entry_exists(username): + return self.async_abort(reason="already_exists") + + return self.async_create_entry( + title=username, + data={CONF_USERNAME: username}, + ) + + # @staticmethod + # @callback + # def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + # return Inter RAOOptionsFlow(config_entry) + + +CONF_DISABLE_ACCOUNTS = "disable_" + CONF_ACCOUNTS +CONF_DISABLE_METERS = "disable_" + CONF_METERS +CONF_DISABLE_INVOICES = "disable_" + CONF_LAST_INVOICE +CONF_USE_TEXT_FIELDS = "use_text_fields" + + +class TNSEnergoOptionsFlow(OptionsFlow): + """Handler for Inter RAO options""" + + def __init__(self, config_entry: ConfigEntry): + self.config_entry = config_entry + self.use_text_fields = False + self.config_codes: Optional[Dict[str, List[str]]] = None + + async def async_fetch_config_codes(self): + api: "BaseEnergosbytAPI" = self.hass.data[DATA_API_OBJECTS][self.config_entry.entry_id] + accounts = await api.async_update_accounts(with_related=True) + account_codes = {account.code for account in accounts.values() if account.code is not None} + + aws = ( + account.async_get_meters() + for account in accounts + if isinstance(account, AbstractAccountWithMeters) + ) + + meters_maps: Iterable[Mapping[int, "AbstractMeter"]] = await asyncio.gather(*aws) + meter_codes = set() + + for meters_map in meters_maps: + meter_codes.update( + [meter.code for meter in meters_map.values() if meter.code is not None] + ) + + return { + CONF_ACCOUNTS: sorted(account_codes), + CONF_LAST_INVOICE: sorted(account_codes), + CONF_METERS: sorted(meter_codes), + } + + async def async_get_options_multiselect(self, config_key: str) -> Dict[str, str]: + if self.config_codes is None: + try: + self.config_codes = await self.async_fetch_config_codes() + config_codes = self.config_codes + except EnergosbytException: + self.use_text_fields = True + config_codes = {} + + else: + config_codes = self.config_codes + + options = OrderedDict() + + entities: List["TNSEnergoEntity"] = ( + self.hass.data.get(DATA_ENTITIES, {}) + .get(self.config_entry.entry_id, {}) + .get(config_key, []) + ) + + for code in sorted(config_codes.get(config_key, [])): + text = code + + for entity in entities: + if entity.code == code: + text += " (" + entity.entity_id + ")" + break + + options[code] = text + + return options + + async def async_generate_schema_dict( + self, user_input: Optional[ConfigType] = None + ) -> OrderedDict: + user_input = user_input or {} + + schema_dict = OrderedDict() + + all_cfg = {**self.config_entry.data} + + if self.config_entry.options: + all_cfg.update(self.config_entry.options) + + # Entity filtering + try: + option_entities = ENTITY_CONF_VALIDATORS[CONF_ENTITIES](all_cfg.get(CONF_ENTITIES, {})) + except vol.Invalid: + option_entities = ENTITY_CONF_VALIDATORS[CONF_ENTITIES]({}) + + async def _add_filter(config_key_: str): + filter_key = CONF_ENTITIES + "_" + config_key_ + blacklist_key = filter_key + "_blacklist" + + default_value = vol.UNDEFINED + blacklisted = True + + if filter_key in user_input: + default_value = user_input[filter_key] + + else: + options_value = option_entities.get(config_key_) + + if options_value: + blacklisted = options_value[CONF_DEFAULT] + + default_value = [ + key + for key, value in options_value.items() + if key != CONF_DEFAULT and value is not blacklisted + ] + + if self.use_text_fields: + # Validate text for text fields + validator = cv.string + + if default_value is not vol.UNDEFINED and isinstance(default_value, list): + default_value = ",".join(default_value) + else: + # Validate options for multi-select fields + select_options = await self.async_get_options_multiselect(config_key_) + + if default_value is not vol.UNDEFINED: + if isinstance(default_value, str): + default_value = list(map(str.strip, default_value.split(","))) + + for value in default_value: + if value not in select_options: + select_options[value] = value + + validator = cv.multi_select(select_options) + + schema_dict[vol.Optional(filter_key, default=default_value)] = validator + schema_dict[vol.Optional(blacklist_key, default=blacklisted)] = cv.boolean + + # Scan intervals + try: + option_scan_interval = ENTITY_CONF_VALIDATORS[CONF_SCAN_INTERVAL]( + all_cfg.get(CONF_SCAN_INTERVAL, {}) + ) + except vol.Invalid: + option_scan_interval = ENTITY_CONF_VALIDATORS[CONF_SCAN_INTERVAL]({}) + + async def _add_scan_interval(config_key_: str): + scan_interval_key = CONF_SCAN_INTERVAL + "_" + config_key_ + + if scan_interval_key in user_input: + default_value = user_input[scan_interval_key] + + else: + default_value = option_scan_interval[config_key_][CONF_DEFAULT] + + if isinstance(default_value, timedelta): + default_value = default_value.total_seconds() + + default_value = { + "seconds": default_value % 60, + "minutes": default_value % (60 * 60) // 60, + "hours": default_value % (60 * 60 * 24) // (60 * 60), + } + + schema_dict[ + vol.Optional(scan_interval_key, default=default_value) + ] = cv.positive_time_period_dict + + # Name formats + try: + option_name_format = ENTITY_CONF_VALIDATORS[CONF_NAME_FORMAT]( + all_cfg.get(CONF_NAME_FORMAT, {}) + ) + except vol.Invalid: + option_name_format = ENTITY_CONF_VALIDATORS[CONF_NAME_FORMAT]({}) + + async def _add_name_format(config_key_: str): + name_format_key = CONF_NAME_FORMAT + "_" + config_key_ + name_format_value = user_input.get(name_format_key) + + if name_format_value is None: + name_format_value = option_name_format[config_key_][CONF_DEFAULT] + + schema_dict[vol.Optional(name_format_key, default=name_format_value)] = cv.string + + for config_key in ENTITY_CODES_VALIDATORS.keys(): + await _add_filter(config_key) + await _add_scan_interval(config_key) + await _add_name_format(config_key) + + schema_dict[vol.Optional(CONF_USE_TEXT_FIELDS, default=self.use_text_fields)] = cv.boolean + + default_user_agent = all_cfg.get(CONF_USER_AGENT) or DEFAULT_USER_AGENT + schema_dict[vol.Optional(CONF_USER_AGENT, default=default_user_agent)] = cv.string + + return schema_dict + + async def async_step_init(self, user_input: Optional[ConfigType] = None) -> Dict[str, Any]: + if self.config_entry.source == config_entries.SOURCE_IMPORT: + return self.async_abort(reason="yaml_not_supported") + + errors = {} + if user_input: + use_text_fields = user_input.get(CONF_USE_TEXT_FIELDS, self.use_text_fields) + if use_text_fields == self.use_text_fields: + new_options = {} + + if CONF_USER_AGENT in user_input: + new_options[CONF_USER_AGENT] = user_input[CONF_USER_AGENT] + + def _save_filter(config_key_: str): + filter_key = CONF_ENTITIES + "_" + config_key_ + blacklist_key = filter_key + "_blacklist" + + value = user_input.get(filter_key) + + if value is None: + value = [] + elif isinstance(value, str): + value = list(filter(bool, map(str.strip, value.split(",")))) + + if CONF_DEFAULT in value: + errors[filter_key] = "value_default_not_valid" + return + + blacklisted = user_input[blacklist_key] + + try: + codes = list(map(validator, value)) + + except vol.Invalid as e: + _LOGGER.error("Error parsing options: %s", e) + errors[config_key_] = "invalid_code_format" + return + + else: + entities_options = new_options.setdefault(CONF_ENTITIES, {}) + entities_options[config_key_] = dict.fromkeys(codes, not blacklisted) + entities_options[config_key_][CONF_DEFAULT] = blacklisted + + def _save_scan_interval(config_key_: str): + scan_interval_key = CONF_SCAN_INTERVAL + "_" + config_key_ + scan_interval_value = user_input.get(scan_interval_key) + + if scan_interval_value is not None: + scan_interval_options = new_options.setdefault(CONF_SCAN_INTERVAL, {}) + scan_interval_options[config_key_] = int( + scan_interval_value.total_seconds() + ) + + def _save_name_format(config_key_: str): + name_format_key = CONF_NAME_FORMAT + "_" + config_key_ + name_format_value = user_input.get(name_format_key) + + if name_format_value is not None: + name_format_options = new_options.setdefault(CONF_NAME_FORMAT, {}) + name_format_options[config_key_] = str(name_format_value).strip() + + for config_key, validator in ENTITY_CODES_VALIDATORS.items(): + _save_filter(config_key) + _save_scan_interval(config_key) + _save_name_format(config_key) + + if not errors: + _LOGGER.debug("Saving options: %s", new_options) + return self.async_create_entry(title="", data=new_options) + + else: + self.use_text_fields = use_text_fields + + schema_dict = await self.async_generate_schema_dict(user_input) + + return self.async_show_form( + step_id="init", data_schema=vol.Schema(schema_dict), errors=errors or None + ) diff --git a/custom_components/tns_energo/const.py b/custom_components/tns_energo/const.py new file mode 100644 index 0000000..047415f --- /dev/null +++ b/custom_components/tns_energo/const.py @@ -0,0 +1,121 @@ +"""Constants for tns_energo integration""" +from typing import Final + +DOMAIN: Final = "tns_energo" + +ATTRIBUTION_EN: Final = "Data acquired from %s" +ATTRIBUTION_RU: Final = "Данные получены с %s" + +ATTR_ACCOUNT_CODE: Final = "account_code" +ATTR_ACCOUNT_ID: Final = "account_id" +ATTR_ADDRESS: Final = "address" +ATTR_AMOUNT: Final = "amount" +ATTR_CHECKUP_DATE: Final = "checkup_date" +ATTR_CHECKUP_STATUS: Final = "checkup_status" +ATTR_CHECKUP_URL: Final = "checkup_url" +ATTR_COMMENT: Final = "comment" +ATTR_CONTROLLED_BY_CODE: Final = "controlled_by_code" +ATTR_DIGITAL_INVOICES_EMAIL: Final = "digital_invoices_email" +ATTR_DIGITAL_INVOICES_EMAIL_COMMENT: Final = "digital_invoices_email_comment" +ATTR_DIGITAL_INVOICES_ENABLED: Final = "digital_invoices_enabled" +ATTR_DIGITAL_INVOICES_IGNORED: Final = "digital_invoices_ignored" +ATTR_EMAIL: Final = "email" +ATTR_END: Final = "end" +ATTR_FULL_NAME: Final = "full_name" +ATTR_GROUP: Final = "group" +ATTR_IGNORE_INDICATIONS: Final = "ignore_indications" +ATTR_IGNORE_PERIOD: Final = "ignore_period" +ATTR_INCREMENTAL: Final = "incremental" +ATTR_INDICATIONS: Final = "indications" +ATTR_INITIAL: Final = "initial" +ATTR_INSTALL_DATE: Final = "install_date" +ATTR_INSTALL_LOCATION: Final = "install_location" +ATTR_INSURANCE: Final = "insurance" +ATTR_INVOICE_ID: Final = "invoice_id" +ATTR_IS_CONTROLLED: Final = "is_controlled" +ATTR_IS_CONTROLLING: Final = "is_controlling" +ATTR_LAST_CHECKUP_DATE: Final = "last_checkup_date" +ATTR_LAST_INDICATIONS_DATE: Final = "last_indications_date" +ATTR_LAST_PAYMENT_AMOUNT: Final = "last_payment_amount" +ATTR_LAST_PAYMENT_DATE: Final = "last_payment_date" +ATTR_LAST_PAYMENT_STATUS: Final = "last_payment_status" +ATTR_LIVING_AREA: Final = "living_area" +ATTR_METER_CATEGORY: Final = "meter_category" +ATTR_METER_CODE: Final = "meter_codes" +ATTR_METER_ID: Final = "meter_id" +ATTR_METER_MODEL: Final = "meter_model" +ATTR_MODEL: Final = "model" +ATTR_NOTIFICATION: Final = "notification" +ATTR_PAID: Final = "paid" +ATTR_PAID_AT: Final = "paid_at" +ATTR_PENALTY: Final = "penalty" +ATTR_PERIOD: Final = "period" +ATTR_PRECISION: Final = "precision" +ATTR_PREVIOUS: Final = "previous" +ATTR_PROVIDER_NAME: Final = "provider_name" +ATTR_PROVIDER_TYPE: Final = "provider_type" +ATTR_REASON: Final = "reason" +ATTR_RECALCULATIONS: Final = "recalculations" +ATTR_REMAINING_DAYS: Final = "remaining_days" +ATTR_RESULT: Final = "result" +ATTR_SERVICE_NAME: Final = "service_name" +ATTR_SERVICE_TYPE: Final = "service_type" +ATTR_SOURCE: Final = "source" +ATTR_START: Final = "start" +ATTR_STATUS: Final = "status" +ATTR_SUBMIT_PERIOD_ACTIVE: Final = "submit_period_active" +ATTR_SUBMIT_PERIOD_END: Final = "submit_period_end" +ATTR_SUBMIT_PERIOD_START: Final = "submit_period_start" +ATTR_SUCCESS: Final = "success" +ATTR_SUM: Final = "sum" +ATTR_TAKEN_ON: Final = "taken_on" +ATTR_TOTAL: Final = "total" +ATTR_TOTAL_AREA: Final = "total_area" +ATTR_TRANSACTION_ID: Final = "transaction_id" +ATTR_TRANSMISSION_COEFFICIENT: Final = "transmission_coefficient" +ATTR_TYPE: Final = "type" +ATTR_UNIT: Final = "unit" +ATTR_ZONES: Final = "zones" + + +CONF_ACCOUNTS: Final = "accounts" +CONF_DEV_PRESENTATION: Final = "dev_presentation" +CONF_LAST_INVOICE: Final = "last_invoice" +CONF_LAST_PAYMENT: Final = "last_payment" +CONF_LOGOS: Final = "logos" +CONF_METERS: Final = "meters" +CONF_NAME_FORMAT: Final = "name_format" +CONF_USER_AGENT: Final = "user_agent" + +DATA_API_OBJECTS: Final = DOMAIN + "_api_objects" +DATA_ENTITIES: Final = DOMAIN + "_entities" +DATA_FINAL_CONFIG: Final = DOMAIN + "_final_config" +DATA_PROVIDER_LOGOS: Final = DOMAIN + "_provider_logos" +DATA_UPDATE_DELEGATORS: Final = DOMAIN + "_update_delegators" +DATA_UPDATE_LISTENERS: Final = DOMAIN + "_update_listeners" +DATA_YAML_CONFIG: Final = DOMAIN + "_yaml_config" + +DEFAULT_NAME_FORMAT_EN_ACCOUNTS: Final = "{account_code} {type_en_cap}" +DEFAULT_NAME_FORMAT_EN_METERS: Final = "{account_code} {type_en_cap} {code}" +DEFAULT_NAME_FORMAT_EN_LAST_INVOICE: Final = "{account_code} {type_en_cap}" +DEFAULT_NAME_FORMAT_EN_LAST_PAYMENT: Final = "{account_code} {type_en_cap}" + +DEFAULT_NAME_FORMAT_RU_ACCOUNTS: Final = "{account_code} {type_ru_cap}" +DEFAULT_NAME_FORMAT_RU_METERS: Final = "{account_code} {type_ru_cap} {code}" +DEFAULT_NAME_FORMAT_RU_LAST_INVOICE: Final = "{account_code} {type_ru_cap}" +DEFAULT_NAME_FORMAT_RU_LAST_PAYMENT: Final = "{account_code} {type_ru_cap}" + +DEFAULT_MAX_INDICATIONS: Final = 3 +DEFAULT_SCAN_INTERVAL: Final = 60 * 60 # 1 hour + + +SUPPORTED_PLATFORMS: Final = ("sensor",) + +FORMAT_VAR_ACCOUNT_CODE: Final = "account_code" +FORMAT_VAR_ACCOUNT_ID: Final = "account_id" +FORMAT_VAR_CODE: Final = "code" +FORMAT_VAR_ID: Final = "id" +FORMAT_VAR_PROVIDER_CODE: Final = "provider_code" +FORMAT_VAR_PROVIDER_NAME: Final = "provider_name" +FORMAT_VAR_TYPE_EN: Final = "type_en" +FORMAT_VAR_TYPE_RU: Final = "type_ru" diff --git a/custom_components/tns_energo/manifest.json b/custom_components/tns_energo/manifest.json new file mode 100644 index 0000000..18805ed --- /dev/null +++ b/custom_components/tns_energo/manifest.json @@ -0,0 +1,14 @@ +{ + "domain": "tns_energo", + "name": "TNS Energo Personal Cabinet", + "documentation": "https://github.com/alryaz/hass-tns-energo", + "issue_tracker": "https://github.com/alryaz/hass-tns-energo/issues", + "dependencies": [], + "version": "0.0.1", + "codeowners": ["@alryaz"], + "requirements": [ + "tns-energo-api==0.0.1" + ], + "config_flow": true, + "iot_class": "cloud_polling" +} diff --git a/custom_components/tns_energo/sensor.py b/custom_components/tns_energo/sensor.py new file mode 100644 index 0000000..ed4997d --- /dev/null +++ b/custom_components/tns_energo/sensor.py @@ -0,0 +1,668 @@ +""" +Sensor for Inter RAO cabinet. +Retrieves indications regarding current state of accounts. +""" +import logging +import re +from datetime import datetime +from typing import ( + Any, + Callable, + ClassVar, + Dict, + Final, + Hashable, + Iterable, + List, + Mapping, + Optional, + Type, + TypeVar, + Union, +) + +import homeassistant.helpers.config_validation as cv +import voluptuous as vol + +from homeassistant.components import persistent_notification +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_OK, + STATE_UNKNOWN, +) +from homeassistant.helpers.typing import ConfigType, StateType + +from custom_components.tns_energo._base import ( + SupportedServicesType, + TNSEnergoEntity, + make_common_async_setup_entry, +) +from custom_components.tns_energo._encoders import ( + account_to_attrs, + indication_to_attrs, + meter_to_attrs, + payment_to_attrs, +) +from custom_components.tns_energo._util import with_auto_auth +from custom_components.tns_energo.const import ( + ATTR_ACCOUNT_CODE, + ATTR_ADDRESS, + ATTR_AMOUNT, + ATTR_COMMENT, + ATTR_END, + ATTR_FULL_NAME, + ATTR_IGNORE_INDICATIONS, + ATTR_INCREMENTAL, + ATTR_INDICATIONS, + ATTR_LAST_INDICATIONS_DATE, + ATTR_LIVING_AREA, + ATTR_METER_CODE, + ATTR_METER_MODEL, + ATTR_NOTIFICATION, + ATTR_PAID_AT, + ATTR_RESULT, + ATTR_SOURCE, + ATTR_START, + ATTR_SUCCESS, + ATTR_SUM, + ATTR_TOTAL_AREA, + CONF_ACCOUNTS, + CONF_LAST_PAYMENT, + CONF_METERS, + DOMAIN, + FORMAT_VAR_ID, + FORMAT_VAR_TYPE_EN, + FORMAT_VAR_TYPE_RU, +) +from tns_energo_api import Account, Meter, Payment, process_start_end_arguments +from tns_energo_api.exceptions import TNSEnergoException + +_LOGGER = logging.getLogger(__name__) + +RE_HTML_TAGS = re.compile(r"<[^<]+?>") +RE_MULTI_SPACES = re.compile(r"\s{2,}") + + +INDICATIONS_MAPPING_SCHEMA = vol.Schema( + { + vol.Required(vol.Match(r"t\d+")): cv.positive_float, + } +) + +INDICATIONS_SEQUENCE_SCHEMA = vol.All( + vol.Any(vol.All(cv.positive_float, cv.ensure_list), [cv.positive_float]), + lambda x: dict(map(lambda y: ("t" + str(y[0]), y[1]), enumerate(x, start=1))), +) + + +CALCULATE_PUSH_INDICATIONS_SCHEMA = { + vol.Required(ATTR_INDICATIONS): vol.Any( + vol.All( + cv.string, lambda x: list(map(str.strip, x.split(","))), INDICATIONS_SEQUENCE_SCHEMA + ), + INDICATIONS_MAPPING_SCHEMA, + INDICATIONS_SEQUENCE_SCHEMA, + ), + vol.Optional(ATTR_IGNORE_INDICATIONS, default=False): cv.boolean, + vol.Optional(ATTR_INCREMENTAL, default=False): cv.boolean, + vol.Optional(ATTR_NOTIFICATION, default=False): vol.Any( + cv.boolean, + persistent_notification.SCHEMA_SERVICE_CREATE, + ), +} + +SERVICE_PUSH_INDICATIONS: Final = "push_indications" +SERVICE_PUSH_INDICATIONS_SCHEMA: Final = CALCULATE_PUSH_INDICATIONS_SCHEMA + +SERVICE_CALCULATE_INDICATIONS: Final = "calculate_indications" +SERVICE_CALCULATE_INDICATIONS_SCHEMA: Final = CALCULATE_PUSH_INDICATIONS_SCHEMA + +_SERVICE_SCHEMA_BASE_DATED: Final = { + vol.Optional(ATTR_START, default=None): vol.Any(vol.Equal(None), cv.datetime), + vol.Optional(ATTR_END, default=None): vol.Any(vol.Equal(None), cv.datetime), +} + +SERVICE_SET_DESCRIPTION: Final = "set_description" +SERVICE_GET_PAYMENTS: Final = "get_payments" +SERVICE_GET_INDICATIONS: Final = "get_indications" + +_TTNSEnergoEntity = TypeVar("_TTNSEnergoEntity", bound=TNSEnergoEntity) + + +def get_supported_features(from_services: SupportedServicesType, for_object: Any) -> int: + features = 0 + for type_feature, services in from_services.items(): + if type_feature is None: + continue + check_cls, feature = type_feature + if isinstance(for_object, check_cls): + features |= feature + + return features + + +ATTR_METER_CODES: Final = "meter_codes" + + +class TNSEnergoAccount(TNSEnergoEntity): + """The class for this sensor""" + + config_key: ClassVar[str] = CONF_ACCOUNTS + + _supported_services: ClassVar[SupportedServicesType] = { + None: { + SERVICE_GET_PAYMENTS: _SERVICE_SCHEMA_BASE_DATED, + }, + } + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, *kwargs) + + self.entity_id: Optional[str] = f"sensor." + self.entity_id_prefix + "_account" + + @property + def code(self) -> str: + return self._account.code + + @property + def device_class(self) -> Optional[str]: + return DOMAIN + "_account" + + @property + def unique_id(self) -> str: + """Return the unique ID of the sensor""" + acc = self._account + return f"{acc.api.__class__.__name__}_account_{acc.code}" + + @property + def state(self) -> Union[float, str]: + balance = self._account.balance + return STATE_UNKNOWN if balance is None else balance + + @property + def icon(self) -> str: + return "mdi:flash-circle" + + @property + def unit_of_measurement(self) -> Optional[str]: + return "руб." + + @property + def sensor_related_attributes(self) -> Optional[Mapping[str, Any]]: + account = self._account + + attributes = account_to_attrs(account) + + self._handle_dev_presentation( + attributes, + (), + ( + ATTR_FULL_NAME, + ATTR_ADDRESS, + ATTR_LIVING_AREA, + ATTR_TOTAL_AREA, + ATTR_METER_MODEL, + ATTR_METER_CODE, + ), + ) + + return attributes + + @property + def name_format_values(self) -> Mapping[str, Any]: + """Return the name of the sensor""" + account = self._account + return { + FORMAT_VAR_ID: str(account.code), + FORMAT_VAR_TYPE_EN: "account", + FORMAT_VAR_TYPE_RU: "лицевой счёт", + } + + ################################################################################# + # Functional implementation of inherent class + ################################################################################# + + @classmethod + async def async_refresh_accounts( + cls, + entities: Dict[Hashable, "TNSEnergoAccount"], + account: "Account", + config_entry: ConfigEntry, + account_config: ConfigType, + async_add_entities: Callable[[List["TNSEnergoAccount"], bool], Any], + ) -> None: + entity_key = account.code + try: + entity = entities[entity_key] + except KeyError: + entity = cls(account, account_config) + entities[entity_key] = entity + + async_add_entities([entity], False) + else: + if entity.enabled: + entity.async_schedule_update_ha_state(force_refresh=True) + + async def async_update_internal(self) -> None: + account = self._account + account_code = account.code + accounts = await account.api.async_get_accounts_list(account_code) + + for account in accounts: + if account.code == account_code: + self._account = account + break + + self.register_supported_services(account) + + ################################################################################# + # Services callbacks + ################################################################################# + + @property + def supported_features(self) -> int: + return get_supported_features( + self._supported_services, + self._account, + ) + + async def async_service_get_payments(self, **call_data): + account = self._account + + _LOGGER.info(self.log_prefix + "Begin handling payments retrieval") + + dt_start: Optional["datetime"] = call_data[ATTR_START] + dt_end: Optional["datetime"] = call_data[ATTR_END] + + dt_start, dt_end = process_start_end_arguments(dt_start, dt_end) + results = [] + + event_data = { + ATTR_ENTITY_ID: self.entity_id, + ATTR_ACCOUNT_CODE: account.code, + ATTR_SUCCESS: False, + ATTR_START: dt_start.isoformat(), + ATTR_END: dt_end.isoformat(), + ATTR_RESULT: results, + ATTR_COMMENT: None, + ATTR_SUM: 0.0, + } + + try: + payments = await with_auto_auth( + account.api, + account.async_get_payments, + dt_start, + dt_end, + ) + + for payment in payments: + event_data[ATTR_SUM] += payment.amount + results.append(payment_to_attrs(payment)) + + except BaseException as e: + event_data[ATTR_COMMENT] = "Unknown error: %r" % e + _LOGGER.exception(event_data[ATTR_COMMENT]) + raise + else: + event_data[ATTR_SUCCESS] = True + + finally: + _LOGGER.debug(self.log_prefix + "Payments retrieval event: " + str(event_data)) + self.hass.bus.async_fire( + event_type=DOMAIN + "_" + SERVICE_GET_PAYMENTS, + event_data=event_data, + ) + + _LOGGER.info(self.log_prefix + "Finish handling payments retrieval") + + +class TNSEnergoMeter(TNSEnergoEntity): + """The class for this sensor""" + + config_key: ClassVar[str] = CONF_METERS + + _supported_services: ClassVar[SupportedServicesType] = { + None: { + SERVICE_PUSH_INDICATIONS: SERVICE_PUSH_INDICATIONS_SCHEMA, + SERVICE_CALCULATE_INDICATIONS: SERVICE_PUSH_INDICATIONS_SCHEMA, + SERVICE_GET_INDICATIONS: _SERVICE_SCHEMA_BASE_DATED, + }, + } + + def __init__(self, *args, meter: "Meter", **kwargs) -> None: + super().__init__(*args, **kwargs) + self._meter = meter + + self.entity_id: Optional[str] = f"sensor." + self.entity_id_prefix + "_meter_" + meter.code + + ################################################################################# + # Implementation base of inherent class + ################################################################################# + + @classmethod + async def async_refresh_accounts( + cls, + entities: Dict[Hashable, Optional[_TTNSEnergoEntity]], + account: "Account", + config_entry: ConfigEntry, + account_config: ConfigType, + async_add_entities: Callable[[List[_TTNSEnergoEntity], bool], Any], + ): + new_meter_entities = [] + meters = await account.async_get_meters() + + for meter_code, meter in meters.items(): + entity_key = (account.code, meter_code) + try: + entity = entities[entity_key] + except KeyError: + entity = cls( + account, + account_config, + meter=meter, + ) + entities[entity_key] = entity + new_meter_entities.append(entity) + else: + if entity.enabled: + entity.async_schedule_update_ha_state(force_refresh=True) + + if new_meter_entities: + async_add_entities(new_meter_entities, False) + + async def async_update_internal(self) -> None: + meters = await self._account.async_get_meters() + meter = meters.get(self._meter.code) + + if meter is None: + self.hass.async_create_task(self.async_remove()) + else: + self.register_supported_services(meter) + self._meter = meter + + ################################################################################# + # Data-oriented implementation of inherent class + ################################################################################# + + @property + def code(self) -> str: + return self._meter.code + + @property + def unique_id(self) -> str: + """Return the unique ID of the sensor""" + met = self._meter + acc = met.account + return f"{acc.api.__class__.__name__}_meter_{acc.code}_{met.code}" + + @property + def state(self) -> str: + return self._meter.status or STATE_OK + + @property + def icon(self): + return "mdi:counter" + + @property + def device_class(self) -> Optional[str]: + return DOMAIN + "_meter" + + @property + def sensor_related_attributes(self) -> Optional[Mapping[str, Any]]: + meter = self._meter + + attributes = meter_to_attrs(meter) + + self._handle_dev_presentation( + attributes, + (), + ( + ATTR_METER_CODE, + ATTR_LAST_INDICATIONS_DATE, + *filter(lambda x: x.startswith("zone_"), attributes.keys()), + ), + ) + + return attributes + + @property + def name_format_values(self) -> Mapping[str, Any]: + meter = self._meter + return { + FORMAT_VAR_ID: meter.code or "", + FORMAT_VAR_TYPE_EN: "meter", + FORMAT_VAR_TYPE_RU: "счётчик", + } + + ################################################################################# + # Additional functionality + ################################################################################# + + def _get_real_indications(self, call_data: Mapping) -> Mapping[str, Union[int, float]]: + indications: Mapping[str, Union[int, float]] = call_data[ATTR_INDICATIONS] + meter_zones = self._meter.zones + + for zone_id, new_value in indications.items(): + if zone_id not in meter_zones: + raise ValueError(f"meter zone {zone_id} does not exist") + + if call_data[ATTR_INCREMENTAL]: + return { + zone_id: ((meter_zones[zone_id].last_indication or 0) + new_value) + for zone_id, new_value in indications.items() + } + + return indications + + async def async_service_push_indications(self, **call_data): + """ + Push indications entity service. + :param call_data: Parameters for service call + :return: + """ + _LOGGER.info(self.log_prefix + "Begin handling indications submission") + + meter = self._meter + + if meter is None: + raise Exception("Meter is unavailable") + + meter_code = meter.code + + event_data = { + ATTR_ENTITY_ID: self.entity_id, + ATTR_METER_CODE: meter_code, + ATTR_SUCCESS: False, + ATTR_INDICATIONS: None, + ATTR_COMMENT: None, + } + + try: + indications = self._get_real_indications(call_data) + + event_data[ATTR_INDICATIONS] = indications + + await with_auto_auth( + meter.account.api, + meter.async_send_indications, + **indications, + ignore_values=call_data.get(ATTR_IGNORE_INDICATIONS, False), + ) + + except TNSEnergoException as e: + event_data[ATTR_COMMENT] = "API error: %s" % e + raise + + except BaseException as e: + event_data[ATTR_COMMENT] = "Unknown error: %r" % e + _LOGGER.error(event_data[ATTR_COMMENT]) + raise + + else: + event_data[ATTR_COMMENT] = "Indications submitted successfully" + event_data[ATTR_SUCCESS] = True + self.async_schedule_update_ha_state(force_refresh=True) + + finally: + _LOGGER.debug(self.log_prefix + "Indications push event: " + str(event_data)) + self.hass.bus.async_fire( + event_type=DOMAIN + "_" + SERVICE_PUSH_INDICATIONS, + event_data=event_data, + ) + + _LOGGER.info(self.log_prefix + "End handling indications submission") + + async def async_service_get_indications(self, **call_data): + account = self._account + meter = self._meter + + _LOGGER.info(self.log_prefix + "Begin handling indications retrieval") + + dt_start: Optional["datetime"] = call_data[ATTR_START] + dt_end: Optional["datetime"] = call_data[ATTR_END] + + dt_start, dt_end = process_start_end_arguments(dt_start, dt_end) + results = [] + + event_data = { + ATTR_ENTITY_ID: self.entity_id, + ATTR_ACCOUNT_CODE: account.code, + ATTR_METER_CODE: meter.code, + ATTR_SUCCESS: False, + ATTR_START: dt_start.isoformat(), + ATTR_END: dt_end.isoformat(), + ATTR_RESULT: results, + ATTR_COMMENT: None, + } + + try: + indications = await with_auto_auth( + account.api, + meter.async_get_indications, + dt_start, + dt_end, + ) + + for indication in indications: + results.append(indication_to_attrs(indication)) + + except BaseException as e: + event_data[ATTR_COMMENT] = "Unknown error: %r" % e + _LOGGER.exception(event_data[ATTR_COMMENT]) + raise + else: + event_data[ATTR_SUCCESS] = True + + finally: + _LOGGER.debug(self.log_prefix + "Indications retrieval event: " + str(event_data)) + self.hass.bus.async_fire( + event_type=DOMAIN + "_" + SERVICE_GET_INDICATIONS, + event_data=event_data, + ) + + _LOGGER.info(self.log_prefix + "Finish handling indications retrieval") + + +class TNSEnergoLastPayment(TNSEnergoEntity): + config_key: ClassVar[str] = CONF_LAST_PAYMENT + + def __init__(self, *args, last_payment: Optional[Payment] = None, **kwargs) -> None: + super().__init__(*args, **kwargs) + self._last_payment = last_payment + self.entity_id: Optional[str] = f"sensor." + self.entity_id_prefix + "_last_payment" + + ################################################################################# + # Implementation base of inherent class + ################################################################################# + + @classmethod + async def async_refresh_accounts( + cls: Type[_TTNSEnergoEntity], + entities: Dict[Hashable, _TTNSEnergoEntity], + account: "Account", + config_entry: ConfigEntry, + account_config: ConfigType, + async_add_entities: Callable[[List[_TTNSEnergoEntity], bool], Any], + ) -> None: + entity_key = account.code + + try: + entity = entities[entity_key] + except KeyError: + entity = cls(account, account_config) + entities[entity_key] = entity + async_add_entities([entity], True) + + else: + if entity.enabled: + await entity.async_update_ha_state(force_refresh=True) + + async def async_update_internal(self) -> None: + self._last_payment = await self._account.async_get_last_payment() + + ################################################################################# + # Data-oriented implementation of inherent class + ################################################################################# + + @property + def code(self) -> str: + return self._account.code + + @property + def state(self) -> StateType: + data = self._last_payment + + if data is None: + return STATE_UNKNOWN + + return self._last_payment.amount + + @property + def icon(self) -> str: + return "mdi:cash-multiple" + + @property + def sensor_related_attributes(self) -> Optional[Mapping[str, Any]]: + payment = self._last_payment + + if payment is None: + attributes = {} + + else: + attributes = payment_to_attrs(payment) + + self._handle_dev_presentation( + attributes, + (ATTR_PAID_AT), + (ATTR_AMOUNT, ATTR_SOURCE), + ) + + return attributes + + @property + def name_format_values(self) -> Mapping[str, Any]: + last_payment = self._last_payment + return { + FORMAT_VAR_ID: last_payment.transaction_id if last_payment else "", + FORMAT_VAR_TYPE_EN: "last payment", + FORMAT_VAR_TYPE_RU: "последний платёж", + } + + @property + def unique_id(self) -> str: + """Return the unique ID of the sensor""" + acc = self._account + return f"{acc.api.__class__.__name__}_lastpayment_{acc.code}" + + @property + def device_class(self) -> Optional[str]: + return DOMAIN + "_payment" + + +async_setup_entry = make_common_async_setup_entry( + TNSEnergoAccount, + TNSEnergoMeter, + TNSEnergoLastPayment, +) diff --git a/custom_components/tns_energo/services.yaml b/custom_components/tns_energo/services.yaml new file mode 100644 index 0000000..7222497 --- /dev/null +++ b/custom_components/tns_energo/services.yaml @@ -0,0 +1,141 @@ +push_indications: + description: 'Передать показания в личный кабинет' + target: + entity: + device_class: tns_energo_meter + fields: + indications: + description: 'Список показаний (от 1 до 3) для тарифов: T1, T2, T3' + required: true + advanced: false + example: '123, 456, 789' + selector: + text: + multiline: false + incremental: + description: 'Сложить известные переданные показания счётчика с передаваемыми' + required: false + advanced: false + default: false + example: 'false' + selector: + boolean: + notification: + description: 'Показывать уведомление при успешной передаче' + required: false + advanced: false + default: false + example: 'true' + selector: + boolean: + ignore_indications: + description: 'Игнорировать ограничения по показаниям' + required: false + advanced: true + default: false + example: 'false' + selector: + boolean: + +calculate_indications: + description: 'Подсчитать начисления по передаваемым показаниям' + target: + entity: + device_class: tns_energo_meter + fields: + indications: + description: 'Список показаний (от 1 до 3) для тарифов: T1, T2, T3' + required: true + advanced: false + example: '123, 456, 789' + selector: + text: + multiline: false + incremental: + description: 'Сложить известные переданные показания счётчика с передаваемыми' + required: false + advanced: false + default: false + example: 'false' + selector: + boolean: + notification: + description: 'Показывать уведомление при успешной передаче' + required: false + advanced: false + default: false + example: 'true' + selector: + boolean: + ignore_indications: + description: 'Игнорировать ограничения по показаниям' + required: false + advanced: true + default: false + example: 'false' + selector: + boolean: + + +set_description: + description: "Задать комментарий к лицевому счёту. Пустой параметр `description` (или его упущение) очистит описание к лицевому счёту." + target: + entity: + device_class: tns_energo_account + fields: + description: + description: 'Описание' + required: false + advanced: false + selector: + text: + multiline: false + +get_payments: + description: "Получить перечень платежей, связанных с лицевым счётом, которые находятся внутри заданного периода" + target: + entity: + device_class: tns_energo_account + fields: + start: + description: "Дата начала периода" + required: false + advanced: false + selector: + text: + multiline: false + end: + description: "Дата окончания периода" + required: false + advanced: false + selector: + text: + multiline: false + +get_indications: + description: "Получить перечень квитанций, связанных с счётчиком (-ами), которые находятся внутри заданного периода" + target: + entity: + device_class: tns_energo_meter + fields: + start: + description: "Дата начала периода" + required: false + advanced: false + selector: + text: + multiline: false + end: + description: "Дата окончания периода" + required: false + advanced: false + selector: + text: + multiline: false + meter_codes: + description: "Номера счётчиков (недоступно при использовании на счётчиках)" + required: false + advanced: false + selector: + text: + multiline: true \ No newline at end of file diff --git a/custom_components/tns_energo/translations/en.json b/custom_components/tns_energo/translations/en.json new file mode 100644 index 0000000..3af981d --- /dev/null +++ b/custom_components/tns_energo/translations/en.json @@ -0,0 +1,40 @@ +{ + "config": { + "error": { + "authentication_error": "Authentication error! This might be due to bad credentials or server connectivity issues", + "api_load_error": "API loading error. Please, report this to the developer as soon as possible!" + }, + "step": { + "user": { + "data": { + "type": "Personal cabinet type", + "username": "Username", + "password": "Password", + "user_agent": "User-Agent header" + }, + "description": "Enter credentials for the chosen personal cabinet.\n\nAll accounts tied to your personal cabinet will be added.", + "title": "Authorization" + }, + "select": { + "data": { + "accounts": "Accounts" + }, + "description": "Select accounts that you would like to be added on load, or leave empty to add all accounts automatically.", + "title": "Account selection" + } + } + }, + "options": { + "abort": { + "yaml_not_supported": "Changing parameters is done inside YAML configuration file" + }, + "step": { + "init": { + "data": { + }, + "description": "" + } + } + }, + "title": "TNS Energo" +} \ No newline at end of file diff --git a/custom_components/tns_energo/translations/ru.json b/custom_components/tns_energo/translations/ru.json new file mode 100644 index 0000000..ec4ce07 --- /dev/null +++ b/custom_components/tns_energo/translations/ru.json @@ -0,0 +1,39 @@ +{ + "config": { + "error": { + "authentication_error": "Ошибка авторизации! Это может быть связано с неправильными данными авторизации или связью с сервером", + "api_load_error": "Ошибка загрузки программного интерфейса. Пожалуйста, незамедлительно сообщите об этом разработчику!" + }, + "step": { + "user": { + "data": { + "username": "Имя пользователя", + "password": "Пароль" + }, + "description": "Введите данные для входа в выбранный личный кабинет.\n\nОтбор привязанных лицевых счетов производится на следующем шаге.", + "title": "Авторизация" + }, + "select": { + "data": { + "accounts": "Лицевые счета" + }, + "description": "Выберите лицевые счета, которые будут добавляться при загрузке интеграции, или оставьте поле пустым чтобы добавлять все лицевые счета автоматически.", + "title": "Выбор лицевых счетов" + } + } + }, + "options": { + "abort": { + "yaml_not_supported": "Изменение параметров производится через файловую конфигурацию (YAML)" + }, + "step": { + "init": { + "data": { + "user_agent": "Заголовок User-Agent" + }, + "description": "" + } + } + }, + "title": "ТНС Энерго" +} \ No newline at end of file diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..9b99733 --- /dev/null +++ b/hacs.json @@ -0,0 +1,12 @@ +{ + "name": "Личный кабинет ТНС Энерго", + "content_in_root": false, + "zip_release": false, + "render_readme": true, + "domains": [ + "sensor" + ], + "country": "ru", + "homeassistant": "2021.4.6", + "iot_class": "Cloud Polling" +} \ No newline at end of file