From 5ba8026e376066d356470424f780357f3471a331 Mon Sep 17 00:00:00 2001 From: raman325 <7243222+raman325@users.noreply.github.com> Date: Mon, 7 Dec 2020 15:28:19 -0500 Subject: [PATCH 1/6] refactor, bug fixes, and improvements New Features - cleanup packages folder on entry removal Changes - switch to using relative path for CONF_PATH to reduce changes of error (existing config entries will be updated automatically) Bug Fixes - Switch _generate_package from async to sync because it does I/O on the filesystem - fix output file not being opened with context manager (so never closed) - fix sensor creation loop to only create necessary sensors instead of sensors for all code slots - fix CodeSlotsData sensor (now a DataUpdateCoordinator) logic for OZW integrations Code Cleanup/Improvements - move CodeSlotsData logic into DataUpdateCoordinator since that's what it is being used for - reduce polling frequency significantly by only polling any time a slot is set or cleared with a backup hourly polling in case updates are made outside of KeyMaster (this will significantly improve battery life over current implementation with the same level of accuracy) - Update config entry using update method instead of directly manipulating attributes - use slugify when fixing CONF_NAME - use os.path.join to create paths for better OS compatibility - switch to using f-strings - only attempt cleanup of packages directory if it exists - always rely on entry.data so that options is only used to capture options updates - simplify code and reduced repeat logic by moving things into common functions - raise Exceptions in services when there is a failure (this will be shown in UI) - use more list comprehensions - switch while loops to for loops - added typing - gitignore venv - improve debug messaging --- custom_components/keymaster/__init__.py | 9 +++++---- custom_components/keymaster/config_flow.py | 1 + custom_components/keymaster/const.py | 2 +- custom_components/keymaster/sensor.py | 5 +++-- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/custom_components/keymaster/__init__.py b/custom_components/keymaster/__init__.py index a06f42a3..451e6967 100644 --- a/custom_components/keymaster/__init__.py +++ b/custom_components/keymaster/__init__.py @@ -1,11 +1,15 @@ """keymaster Integration.""" +from datetime import timedelta import logging import os import shutil -from datetime import timedelta from typing import Any, Dict, Optional +from openzwavemqtt.const import CommandClass, ATTR_CODE_SLOT +from openzwavemqtt.exceptions import NotFoundError, NotSupportedError +from openzwavemqtt.util.node import get_node_from_manager import voluptuous as vol + from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.components.ozw import DOMAIN as OZW_DOMAIN from homeassistant.components.zwave.const import DOMAIN as ZWAVE_DOMAIN @@ -14,9 +18,6 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from openzwavemqtt.const import ATTR_CODE_SLOT, CommandClass -from openzwavemqtt.exceptions import NotFoundError, NotSupportedError -from openzwavemqtt.util.node import get_node_from_manager from .const import ( ATTR_NAME, diff --git a/custom_components/keymaster/config_flow.py b/custom_components/keymaster/config_flow.py index 22c118f7..e5fa7569 100644 --- a/custom_components/keymaster/config_flow.py +++ b/custom_components/keymaster/config_flow.py @@ -4,6 +4,7 @@ import os import voluptuous as vol + from homeassistant import config_entries from homeassistant.components.binary_sensor import DOMAIN as BINARY_DOMAIN from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN diff --git a/custom_components/keymaster/const.py b/custom_components/keymaster/const.py index 3f9cd0e2..728beebb 100644 --- a/custom_components/keymaster/const.py +++ b/custom_components/keymaster/const.py @@ -1,6 +1,6 @@ DOMAIN = "keymaster" VERSION = "0.0.45" -ISSUE_URL = "https://github.com/FutureTense/keymaster" +ISSUE_URL = "https://github.com/FutureTense/keypaster" PLATFORM = "sensor" ZWAVE_NETWORK = "zwave_network" diff --git a/custom_components/keymaster/sensor.py b/custom_components/keymaster/sensor.py index 5e4d27f2..9bd90f2f 100644 --- a/custom_components/keymaster/sensor.py +++ b/custom_components/keymaster/sensor.py @@ -1,10 +1,11 @@ """Sensor for keymaster.""" import logging -from homeassistant.helpers.update_coordinator import CoordinatorEntity from openzwavemqtt.const import ATTR_CODE_SLOT -from .const import CONF_LOCK_NAME, CONF_SLOTS, CONF_START, DOMAIN +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import CONF_SLOTS, CONF_LOCK_NAME, CONF_START, DOMAIN _LOGGER = logging.getLogger(__name__) From 97445dd4f8d51cac5165e46d657c68277cf07425 Mon Sep 17 00:00:00 2001 From: raman325 <7243222+raman325@users.noreply.github.com> Date: Mon, 7 Dec 2020 17:51:05 -0500 Subject: [PATCH 2/6] fix typo --- custom_components/keymaster/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/keymaster/const.py b/custom_components/keymaster/const.py index 728beebb..3f9cd0e2 100644 --- a/custom_components/keymaster/const.py +++ b/custom_components/keymaster/const.py @@ -1,6 +1,6 @@ DOMAIN = "keymaster" VERSION = "0.0.45" -ISSUE_URL = "https://github.com/FutureTense/keypaster" +ISSUE_URL = "https://github.com/FutureTense/keymaster" PLATFORM = "sensor" ZWAVE_NETWORK = "zwave_network" From ccb0de37db71644c1df826dee767a5809dfca56c Mon Sep 17 00:00:00 2001 From: raman325 <7243222+raman325@users.noreply.github.com> Date: Mon, 7 Dec 2020 15:28:19 -0500 Subject: [PATCH 3/6] refactor, bug fixes, and improvements New Features - cleanup packages folder on entry removal Changes - switch to using relative path for CONF_PATH to reduce changes of error (existing config entries will be updated automatically) Bug Fixes - Switch _generate_package from async to sync because it does I/O on the filesystem - fix output file not being opened with context manager (so never closed) - fix sensor creation loop to only create necessary sensors instead of sensors for all code slots - fix CodeSlotsData sensor (now a DataUpdateCoordinator) logic for OZW integrations Code Cleanup/Improvements - move CodeSlotsData logic into DataUpdateCoordinator since that's what it is being used for - reduce polling frequency significantly by only polling any time a slot is set or cleared with a backup hourly polling in case updates are made outside of KeyMaster (this will significantly improve battery life over current implementation with the same level of accuracy) - Update config entry using update method instead of directly manipulating attributes - use slugify when fixing CONF_NAME - use os.path.join to create paths for better OS compatibility - switch to using f-strings - only attempt cleanup of packages directory if it exists - always rely on entry.data so that options is only used to capture options updates - simplify code and reduced repeat logic by moving things into common functions - raise Exceptions in services when there is a failure (this will be shown in UI) - use more list comprehensions - switch while loops to for loops - added typing - gitignore venv - improve debug messaging --- custom_components/keymaster/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/keymaster/const.py b/custom_components/keymaster/const.py index 3f9cd0e2..728beebb 100644 --- a/custom_components/keymaster/const.py +++ b/custom_components/keymaster/const.py @@ -1,6 +1,6 @@ DOMAIN = "keymaster" VERSION = "0.0.45" -ISSUE_URL = "https://github.com/FutureTense/keymaster" +ISSUE_URL = "https://github.com/FutureTense/keypaster" PLATFORM = "sensor" ZWAVE_NETWORK = "zwave_network" From a2269e88df617e9f50f28f60e476211d2cef6bea Mon Sep 17 00:00:00 2001 From: raman325 <7243222+raman325@users.noreply.github.com> Date: Tue, 8 Dec 2020 01:44:40 -0500 Subject: [PATCH 4/6] remove helper entities on entry unload or options update --- custom_components/keymaster/__init__.py | 97 +++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 5 deletions(-) diff --git a/custom_components/keymaster/__init__.py b/custom_components/keymaster/__init__.py index 451e6967..ce2cfe8e 100644 --- a/custom_components/keymaster/__init__.py +++ b/custom_components/keymaster/__init__.py @@ -3,21 +3,31 @@ import logging import os import shutil -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional, Union from openzwavemqtt.const import CommandClass, ATTR_CODE_SLOT from openzwavemqtt.exceptions import NotFoundError, NotSupportedError from openzwavemqtt.util.node import get_node_from_manager import voluptuous as vol +from homeassistant.components.counter import DOMAIN as COUNTER_DOMAIN +from homeassistant.components.input_boolean import DOMAIN as IN_BOOL_DOMAIN +from homeassistant.components.input_datetime import DOMAIN as IN_DT_DOMAIN +from homeassistant.components.input_number import DOMAIN as IN_NUM_DOMAIN +from homeassistant.components.input_select import DOMAIN as IN_SELECT_DOMAIN +from homeassistant.components.input_text import DOMAIN as IN_TXT_DOMAIN from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.components.ozw import DOMAIN as OZW_DOMAIN +from homeassistant.components.timer import DOMAIN as TIMER_DOMAIN from homeassistant.components.zwave.const import DOMAIN as ZWAVE_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_registry import async_get_registry from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import slugify +from homeassistant.util.yaml.loader import load_yaml from .const import ( ATTR_NAME, @@ -77,6 +87,7 @@ def _using_zwave(hass: HomeAssistant) -> bool: def _get_node_id(hass: HomeAssistant, entity_id: str) -> Optional[str]: + """Get node ID from entity.""" try: # Hack that always returns a dict so that we can do this check in one line return getattr(hass.states.get(entity_id), "attributes", {})[ATTR_NODE_ID] @@ -111,6 +122,60 @@ def _file_output_from_template( _LOGGER.debug("Completed generation of %s from %s", output_filename, input_filename) +def _get_entities_to_remove( + lock_name: str, + file_path: str, + code_slots_to_remove: Union[List[int], range], + remove_common_file: bool, +) -> List[str]: + """Gets list of entities to remove.""" + output_path = os.path.join(file_path, lock_name) + filenames = [f"{lock_name}_keymaster_{x}.yaml" for x in code_slots_to_remove] + if remove_common_file: + filenames.append(f"{lock_name}_keymaster_common.yaml") + + entities = [] + for filename in filenames: + file_dict = load_yaml(os.path.join(output_path, filename)) + # get all entities from all helper domains that exist in package files + for domain in ( + COUNTER_DOMAIN, + IN_BOOL_DOMAIN, + IN_DT_DOMAIN, + IN_NUM_DOMAIN, + IN_SELECT_DOMAIN, + IN_TXT_DOMAIN, + TIMER_DOMAIN, + ): + entities.extend( + [f"{domain}.{ent_id}" for ent_id in file_dict.get(domain, {})] + ) + + return entities + + +async def _remove_entities( + hass: HomeAssistant, + config_entry: ConfigEntry, + code_slots_to_remove: Union[List[int], range], + remove_common_file: bool, +) -> List[str]: + """Remove entities and return removed list.""" + ent_reg = await async_get_registry(hass) + entities_to_remove = await hass.async_add_executor_job( + _get_entities_to_remove, + config_entry.data[CONF_LOCK_NAME], + os.path.join(hass.config.path(), config_entry.data[CONF_PATH]), + code_slots_to_remove, + remove_common_file, + ) + + for entity_id in entities_to_remove: + ent_reg.async_remove(entity_id) + + return entities_to_remove + + async def async_setup(hass, config) -> bool: """ Disallow configuration via YAML """ @@ -436,23 +501,45 @@ def _generate_package(service): async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Handle removal of an entry.""" + + # Remove all generated helper entries + await _remove_entities( + hass, + config_entry, + range(config_entry.data[CONF_START], config_entry.data[CONF_SLOTS] + 1), + True, + ) + + # Remove all package files output_path = os.path.join( hass.config.path(), config_entry.data[CONF_PATH], config_entry.data[CONF_LOCK_NAME], ) await hass.async_add_executor_job(shutil.rmtree, output_path) + return True -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Update listener.""" + # Get current code slots and new code slots, and remove entities for current code + # slots that are being removed + curr_slots = range(config_entry.data[CONF_START], config_entry.data[CONF_SLOTS] + 1) + new_slots = range( + config_entry.options[CONF_START], config_entry.options[CONF_SLOTS] + 1 + ) + + await _remove_entities( + hass, config_entry, list(set(curr_slots) - set(new_slots)), False + ) + hass.config_entries.async_update_entry( - entry=entry, data=entry.options.copy(), options={} + entry=config_entry, data=config_entry.options.copy(), options={} ) - servicedata = {"lockname": entry.options[CONF_LOCK_NAME]} + servicedata = {"lockname": config_entry.options[CONF_LOCK_NAME]} await hass.services.async_call(DOMAIN, SERVICE_GENERATE_PACKAGE, servicedata) - await hass.config_entries.async_reload(entry.entry_id) + await hass.config_entries.async_reload(config_entry.entry_id) class LockUsercodeUpdateCoordinator(DataUpdateCoordinator): From 38ccfa6c1e8643a58035b2dad49e410cc1e369cc Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 8 Dec 2020 11:39:41 -0500 Subject: [PATCH 5/6] remove counter since it's not actually used, it was just there for futureproofing --- custom_components/keymaster/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/custom_components/keymaster/__init__.py b/custom_components/keymaster/__init__.py index ce2cfe8e..5b37276d 100644 --- a/custom_components/keymaster/__init__.py +++ b/custom_components/keymaster/__init__.py @@ -10,7 +10,6 @@ from openzwavemqtt.util.node import get_node_from_manager import voluptuous as vol -from homeassistant.components.counter import DOMAIN as COUNTER_DOMAIN from homeassistant.components.input_boolean import DOMAIN as IN_BOOL_DOMAIN from homeassistant.components.input_datetime import DOMAIN as IN_DT_DOMAIN from homeassistant.components.input_number import DOMAIN as IN_NUM_DOMAIN @@ -139,7 +138,6 @@ def _get_entities_to_remove( file_dict = load_yaml(os.path.join(output_path, filename)) # get all entities from all helper domains that exist in package files for domain in ( - COUNTER_DOMAIN, IN_BOOL_DOMAIN, IN_DT_DOMAIN, IN_NUM_DOMAIN, From 318ef04426572702fb68b09853e7eee92d0bf7e2 Mon Sep 17 00:00:00 2001 From: raman325 <7243222+raman325@users.noreply.github.com> Date: Wed, 9 Dec 2020 13:02:57 -0500 Subject: [PATCH 6/6] bugfix --- custom_components/keymaster/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/keymaster/__init__.py b/custom_components/keymaster/__init__.py index 5b37276d..4394c580 100644 --- a/custom_components/keymaster/__init__.py +++ b/custom_components/keymaster/__init__.py @@ -535,7 +535,7 @@ async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> Non hass.config_entries.async_update_entry( entry=config_entry, data=config_entry.options.copy(), options={} ) - servicedata = {"lockname": config_entry.options[CONF_LOCK_NAME]} + servicedata = {"lockname": config_entry.data[CONF_LOCK_NAME]} await hass.services.async_call(DOMAIN, SERVICE_GENERATE_PACKAGE, servicedata) await hass.config_entries.async_reload(config_entry.entry_id)