Skip to content

Commit

Permalink
feat: added entity filtering
Browse files Browse the repository at this point in the history
feat: added options flow (enable/disable entities)
docs: updated language strings for config flow
docs: updated readme to reflect
  • Loading branch information
alryaz committed Jun 21, 2021
1 parent da49a38 commit b8f25a1
Show file tree
Hide file tree
Showing 10 changed files with 514 additions and 98 deletions.
59 changes: 59 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,25 @@ moscow_pgu:
guid: ''
user_agent: okhttp/4.9.0
driving_licenses: []
filter:
children:
- '*'
diaries:
- '*'
driving_licenses:
- '*'
electric_counters:
- '*'
flats:
- '*'
fssp_debts:
- '*'
profile:
- '*'
vehicles:
- '*'
water_counters:
- '*'
name_format:
children: Child - {identifier}
diaries: Diary - {identifier}
Expand Down Expand Up @@ -202,6 +221,46 @@ moscow_pgu:
```

##### Замечания по фильтрации объектов (ключ `filter`)


Фильтрация объектов может происхожить по двум стратегиям списков: _белый_ (когда определённый набор
объектов добавляется, а все остальные игнорируются) и _чёрный_ (когда определённый набор объектов
исключается, а все остальные добавляются). С помощью фильтрации также можно реализовать полное
отключение обновлений определённых типов сенсоров.

Определяющим фактором, какая стратегия будет применена, является наличие специального символа
в перечне добавляемых объектов: `*` (звёздочка).

**При наличии звёздочки** все объекты, перечисленные в том же списке, будут исключены из добавления.
Пример:
```yaml
moscow_pgu:
...
filter:
...
# Только дети, чьи имена совпадают с "Владимир" и "Сергей", будут добавлены.
children: ["Владимир", "Сергей"]
# Только дневники, чьё имя ребёнка не совпадает с именем "Владимир", будут добавлены.
diaries: ["*", "Владимир"]
# Только автомобиль с номером "Ж177ЭЪ799" будет запланирован на проверку штрафов.
vehicles: "Ж177ЭЪ799"
# Поддержка объектов долгов ФССП будет полностью отключена (в пределах конфигурации)
# При этом, этот тип объектов (как и некоторые другие) могут принимать только
# значения `true` и `false` (или `["*"]` и `[]` соответственно). Это связано с тем,
# что эти объекты повявляются при обновлении данных только в единственном изваянии.
fssp_debts: false
# profile: false | true
# driving_licenses: false | true

```

_Замечание:_ Для некоторых объектов доступна конфигурация, которая может порождать собой
дополнительные сенсоры (_Взыскания ФССП_ и _Водительское удостоверение_).

<hr>

## Использование
Expand Down
77 changes: 67 additions & 10 deletions custom_components/moscow_pgu/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,22 @@
"async_setup",
"async_authenticate_api_object",
"async_unload_entry",
"lazy_load_platforms_base_class",
)
import hashlib

import asyncio
import logging
from typing import (
Any,
Callable,
Dict,
Mapping,
Optional,
TYPE_CHECKING,
Type,
)

import voluptuous as vol
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.config_entries import ConfigEntry, SOURCE_IMPORT
from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME
from homeassistant.exceptions import ConfigEntryNotReady
Expand All @@ -41,8 +43,12 @@
async_load_session,
extract_config,
find_existing_entry,
generate_guid,
)

if TYPE_CHECKING:
from custom_components.moscow_pgu._base import MoscowPGUEntity

_LOGGER = logging.getLogger(__name__)

DEVICE_INFO_SCHEMA = vol.Schema(
Expand Down Expand Up @@ -72,7 +78,7 @@
)


def _lazy_load_platforms_base_class() -> Mapping[str, Type["MoscowPGUEntity"]]:
def lazy_load_platforms_base_class() -> Mapping[str, Type["MoscowPGUEntity"]]:
return {
platform: __import__(
"custom_components.moscow_pgu." + platform, globals(), locals(), ("BASE_CLASS",)
Expand All @@ -89,7 +95,7 @@ def _lazy_name_formats_schema(value: Mapping[str, Any]):
if NAME_FORMATS_SCHEMA is not None:
return NAME_FORMATS_SCHEMA(value)

platforms = _lazy_load_platforms_base_class()
platforms = lazy_load_platforms_base_class()
NAME_FORMATS_SCHEMA = vol.Schema(
{
vol.Optional(cls.CONFIG_KEY, default=cls.DEFAULT_NAME_FORMAT): cv.string
Expand All @@ -109,7 +115,7 @@ def _lazy_scan_intervals_schema(value: Any):
if SCAN_INTERVALS_SCHEMA is not None:
return SCAN_INTERVALS_SCHEMA(value)

platforms = _lazy_load_platforms_base_class()
platforms = lazy_load_platforms_base_class()
mapping_schema_dict = {
vol.Optional(cls.CONFIG_KEY, default=cls.DEFAULT_SCAN_INTERVAL): vol.All(
cv.positive_time_period, vol.Clamp(min=cls.MIN_SCAN_INTERVAL)
Expand All @@ -130,6 +136,40 @@ def _lazy_scan_intervals_schema(value: Any):
return SCAN_INTERVALS_SCHEMA(value)


FILTER_SCHEMA: Optional[vol.Schema] = None


def _lazy_filter_schema(value: Any):
global FILTER_SCHEMA
if FILTER_SCHEMA is not None:
return FILTER_SCHEMA(value)

platforms = lazy_load_platforms_base_class()

singular_validator = vol.Any(
vol.All(vol.Any(vol.Equal(["*"]), vol.Equal(True)), lambda x: ["*"]),
vol.All(vol.Any(vol.Equal([]), vol.Equal(False)), lambda x: []),
)

multiple_validator = vol.Any(
vol.All(vol.Equal(True), lambda x: ["*"]),
vol.All(vol.Equal(False), lambda x: []),
vol.All(cv.ensure_list, [cv.string]),
)

FILTER_SCHEMA = vol.Schema(
{
vol.Optional(cls.CONFIG_KEY, default=lambda: ["*"]): (
singular_validator if cls.SINGULAR_FILTER else multiple_validator
)
for base_cls in platforms.values()
for cls in base_cls.__subclasses__()
}
)

return FILTER_SCHEMA(value)


OPTIONAL_ENTRY_SCHEMA = vol.Schema(
{
vol.Optional(CONF_DEVICE_INFO, default=lambda: DEVICE_INFO_SCHEMA({})): DEVICE_INFO_SCHEMA,
Expand All @@ -146,6 +186,7 @@ def _lazy_scan_intervals_schema(value: Any):
vol.Optional(
CONF_SCAN_INTERVAL, default=lambda: _lazy_scan_intervals_schema({})
): _lazy_scan_intervals_schema,
vol.Optional(CONF_FILTER, default=lambda: _lazy_filter_schema({})): _lazy_filter_schema,
vol.Optional(CONF_TOKEN, default=None): vol.Any(vol.Equal(None), cv.string),
},
extra=vol.ALLOW_EXTRA,
Expand Down Expand Up @@ -259,8 +300,7 @@ async def async_setup_entry(hass: HomeAssistantType, config_entry: ConfigEntry)

if not additional_args.get("guid"):
# @TODO: this can be randomly generated?
hash_str = "homeassistant&" + username + "&" + password
additional_args["guid"] = hashlib.md5(hash_str.encode("utf-8")).hexdigest().lower()
additional_args["guid"] = generate_guid(config)

token = config_entry.options.get(CONF_TOKEN)
if not token:
Expand All @@ -284,7 +324,6 @@ async def async_setup_entry(hass: HomeAssistantType, config_entry: ConfigEntry)
await async_authenticate_api_object(hass, api_object)

except MoscowPGUException as e:
await api_object.close_session()
raise ConfigEntryNotReady("Error occurred while authenticating: %s", e)
except BaseException:
await api_object.close_session()
Expand All @@ -298,9 +337,21 @@ async def async_setup_entry(hass: HomeAssistantType, config_entry: ConfigEntry)
hass.config_entries.async_forward_entry_setup(config_entry, platform)
)

update_listener = config_entry.add_update_listener(async_reload_entry)
hass.data.setdefault(DATA_UPDATE_LISTENERS, {})[config_entry.entry_id] = update_listener

return True


async def async_reload_entry(
hass: HomeAssistantType,
config_entry: ConfigEntry,
) -> None:
"""Reload Lkcomu InterRAO entry"""
_LOGGER.info("Reloading configuration entry")
await hass.config_entries.async_reload(config_entry.entry_id)


async def async_unload_entry(hass: HomeAssistantType, config_entry: ConfigEntry) -> bool:
username = config_entry.data[CONF_USERNAME]

Expand All @@ -314,8 +365,14 @@ async def async_unload_entry(hass: HomeAssistantType, config_entry: ConfigEntry)
for cancel_callback in updaters.values():
cancel_callback()

hass.async_create_task(
hass.config_entries.async_forward_entry_unload(config_entry, SENSOR_DOMAIN)
cancel_listener = hass.data[DATA_UPDATE_LISTENERS].pop(config_entry.entry_id)
cancel_listener()

await asyncio.gather(
*(
hass.config_entries.async_forward_entry_unload(config_entry, domain)
for domain in SUPPORTED_PLATFORMS
)
)

return True
57 changes: 36 additions & 21 deletions custom_components/moscow_pgu/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
from homeassistant.util import as_local, utcnow

from custom_components.moscow_pgu.api import API
from custom_components.moscow_pgu.const import CONF_NAME_FORMAT, DATA_ENTITIES, DOMAIN
from custom_components.moscow_pgu.const import CONF_FILTER, CONF_NAME_FORMAT, DATA_ENTITIES, DOMAIN
from custom_components.moscow_pgu.util import extract_config

_T = TypeVar("_T")
Expand All @@ -40,14 +40,15 @@

class NameFormatDict(dict):
def __missing__(self, key):
return "{{" + str(key) + "}}"
return "{" + str(key) + "}"


class MoscowPGUEntity(Entity):
CONFIG_KEY: ClassVar[str] = NotImplemented
DEFAULT_NAME_FORMAT: ClassVar[str] = NotImplemented
DEFAULT_SCAN_INTERVAL: ClassVar[timedelta] = timedelta(hours=1)
MIN_SCAN_INTERVAL: ClassVar[timedelta] = timedelta(seconds=30)
SINGULAR_FILTER: ClassVar[bool] = False

@classmethod
@abstractmethod
Expand All @@ -59,6 +60,8 @@ async def async_refresh_entities(
config: ConfigType,
api: "API",
leftover_entities: List["MoscowPGUEntity"],
filter_entities: List[str],
is_blacklist: bool,
) -> Iterable["MoscowPGUEntity"]:
pass

Expand Down Expand Up @@ -169,7 +172,9 @@ async def async_added_to_hass(self) -> None:
if config_entry_id is None:
raise ValueError("added entity without config registry entry")

cls_entities = self.hass.data[DATA_ENTITIES][config_entry_id].setdefault(self.__class__, [])
cls_entities = self.hass.data[DATA_ENTITIES][config_entry_id].setdefault(
self.CONFIG_KEY, []
)

_LOGGER.debug("%sAdding to registry", self.log_prefix)
cls_entities.append(self)
Expand All @@ -179,21 +184,18 @@ async def async_added_to_hass(self) -> None:

async def async_will_remove_from_hass(self) -> None:
# Stop updater firstmost
_LOGGER.debug("%sWill remove from registry", self.log_prefix)
self.updater_stop()

registry_entry = self.registry_entry
if registry_entry is None:
raise ValueError("added entity without registry entry")

config_entry_id = registry_entry.config_entry_id
if config_entry_id is None:
raise ValueError("added entity without config registry entry")
if registry_entry:

cls_entities = self.hass.data[DATA_ENTITIES][config_entry_id].get(self.__class__)
config_entry_id = registry_entry.config_entry_id
if config_entry_id:
cls_entities = self.hass.data[DATA_ENTITIES][config_entry_id].get(self.CONFIG_KEY)

if cls_entities and self in cls_entities:
_LOGGER.debug("%sWill remove from registry", self.log_prefix)
cls_entities.remove(self)
if cls_entities and self in cls_entities:
cls_entities.remove(self)

@property
def device_state_attributes(self) -> Optional[Dict[str, Any]]:
Expand Down Expand Up @@ -304,15 +306,26 @@ async def async_setup_entry(

# Prepare necessary arguments
api: API = hass.data[DOMAIN][username]
all_existing_entities: Dict[Type[MoscowPGUEntity], List[MoscowPGUEntity]] = hass.data[
DATA_ENTITIES
][config_entry.entry_id]
all_existing_entities: Dict[str, List[MoscowPGUEntity]] = hass.data[DATA_ENTITIES][
config_entry.entry_id
]

update_cls = []
update_tasks = []
leftover_map = {}
for entity_cls in entity_classes:
leftover_entities = list(all_existing_entities.setdefault(entity_cls, []))
config_key = entity_cls.CONFIG_KEY
entity_cls_filter = config[CONF_FILTER][config_key]
if not entity_cls_filter:
# Effectively means entity is disabled
_LOGGER.debug(f'Entities `{config_key}` are disabled for user "{username}"')
continue

leftover_entities = list(all_existing_entities.setdefault(config_key, []))
leftover_map[entity_cls] = leftover_entities
is_blacklist = "*" in entity_cls_filter
update_cls.append(entity_cls)

update_tasks.append(
entity_cls.async_refresh_entities(
hass,
Expand All @@ -321,13 +334,15 @@ async def async_setup_entry(
config,
api,
leftover_entities,
entity_cls_filter,
is_blacklist,
)
)

new_entities = []
tasks = []

for entity_cls, results in zip(entity_classes, await asyncio.gather(*update_tasks)):
for entity_cls, results in zip(update_cls, await asyncio.gather(*update_tasks)):
if isinstance(results, BaseException):
_LOGGER.error("Exception: %s", exc_info=results)
else:
Expand All @@ -339,7 +354,7 @@ async def async_setup_entry(
result.scan_interval = scan_interval
new_entities.append(result)

existing_entities = all_existing_entities[entity_cls]
existing_entities = all_existing_entities[entity_cls.CONFIG_KEY]
leftover_entities = leftover_map[entity_cls]

for entity in existing_entities:
Expand All @@ -357,7 +372,7 @@ async def async_setup_entry(
if tasks:
await asyncio.wait(tasks)

_LOGGER.debug('Finished component setup for user "%s"', username)
_LOGGER.debug(f'Finished component setup for user "{username}"')

return async_setup_entry

Expand All @@ -372,7 +387,7 @@ def iter_and_add_or_update(
ent_cls: Type[_T],
async_add_entities: Callable[[List[_T], bool], Any],
leftover_entities: List[_T],
objs: List[Any],
objs: Iterable[Any],
ent_attr: str,
cmp_attrs: Optional[Tuple[str, ...]] = None,
check_none: bool = True,
Expand Down
Loading

0 comments on commit b8f25a1

Please sign in to comment.