From 5aad93464ab61d407eece2832ee4daa0005a3071 Mon Sep 17 00:00:00 2001 From: Snuffy2 Date: Wed, 13 Nov 2024 11:01:35 -0500 Subject: [PATCH] Connect to Z-Wave and move to config_entry_id as key Rename KeymasterLock entities from lock to kmlock Rename KeymasterLock parent to parent_name --- custom_components/keymaster/__init__.py | 32 +- custom_components/keymaster/binary_sensor.py | 69 +---- custom_components/keymaster/config_flow.py | 8 +- custom_components/keymaster/coordinator.py | 298 ++++++++++--------- custom_components/keymaster/entity.py | 41 ++- custom_components/keymaster/helpers.py | 137 +++++---- custom_components/keymaster/lock.py | 8 +- custom_components/keymaster/sensor.py | 26 +- custom_components/keymaster/services.py | 6 +- custom_components/keymaster/text.py | 36 ++- 10 files changed, 333 insertions(+), 328 deletions(-) diff --git a/custom_components/keymaster/__init__.py b/custom_components/keymaster/__init__.py index b71c5858..86f61505 100644 --- a/custom_components/keymaster/__init__.py +++ b/custom_components/keymaster/__init__.py @@ -103,7 +103,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b config_entry.data[CONF_START], config_entry.data[CONF_START] + config_entry.data[CONF_SLOTS], ): - code_slots[x] = KeymasterCodeSlot(number=x) dow_slots: Mapping[int, KeymasterCodeSlotDayOfWeek] = {} for i, dow in enumerate( [ @@ -117,13 +116,15 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b ] ): dow_slots[i] = KeymasterCodeSlotDayOfWeek( - day_of_week_num=1, day_of_week_name=dow + day_of_week_num=i, day_of_week_name=dow ) + code_slots[x] = KeymasterCodeSlot(number=x, accesslimit_day_of_week=dow_slots) - lock = KeymasterLock( + kmlock = KeymasterLock( lock_name=config_entry.data[CONF_LOCK_NAME], lock_entity_id=config_entry.data[CONF_LOCK_ENTITY_ID], keymaster_device_id=device.id, + keymaster_config_entry_id=config_entry.entry_id, alarm_level_or_user_code_entity_id=config_entry.data[ CONF_ALARM_LEVEL_OR_USER_CODE_ENTITY_ID ], @@ -131,12 +132,10 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b CONF_ALARM_TYPE_OR_ACCESS_CONTROL_ENTITY_ID ], door_sensor_entity_id=config_entry.data[CONF_SENSOR_NAME], - # zwave_js_lock_node = config_entry.data[ - # zwave_js_lock_device = config_entry.data[ number_of_code_slots=config_entry.data[CONF_SLOTS], starting_code_slot=config_entry.data[CONF_START], - code_slots={}, - parent=config_entry.data[CONF_PARENT], + code_slots=code_slots, + parent_name=config_entry.data[CONF_PARENT], ) hass.data[DOMAIN][config_entry.entry_id] = device.id @@ -146,7 +145,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b else: coordinator = hass.data[DOMAIN][COORDINATOR] - await coordinator.add_lock(lock=lock) + await coordinator.add_lock(kmlock=kmlock) # await coordinator.async_config_entry_first_refresh() await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) @@ -161,11 +160,11 @@ async def system_health_check(hass: HomeAssistant, config_entry: ConfigEntry) -> _LOGGER.debug( f"[system_health_check] hass.data[DOMAIN][config_entry.entry_id]: {hass.data[DOMAIN][config_entry.entry_id]}" ) - lock: KeymasterLock = await coordinator.get_lock_by_device_id( - hass.data[DOMAIN][config_entry.entry_id] + kmlock: KeymasterLock = await coordinator.get_lock_by_config_entry_id( + config_entry.entry_id ) - if async_using_zwave_js(hass=hass, lock=lock): + if async_using_zwave_js(hass=hass, kmlock=kmlock): hass.data[DOMAIN][INTEGRATION] = "zwave_js" else: hass.data[DOMAIN][INTEGRATION] = "unknown" @@ -204,9 +203,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> # await async_reload_package_platforms(hass) - await coordinator.delete_lock_by_device_id( - hass.data[DOMAIN][config_entry.entry_id] - ) + await coordinator.delete_lock_by_config_entry_id(config_entry.entry_id) hass.data[DOMAIN].pop(config_entry.entry_id, None) @@ -305,10 +302,11 @@ async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> Non day_of_week_num=1, day_of_week_name=dow ) - lock = KeymasterLock( + kmlock = KeymasterLock( lock_name=config_entry.data[CONF_LOCK_NAME], lock_entity_id=config_entry.data[CONF_LOCK_ENTITY_ID], keymaster_device_id=device.id, + keymaster_config_entry_id=config_entry.entry_id, alarm_level_or_user_code_entity_id=config_entry.data[ CONF_ALARM_LEVEL_OR_USER_CODE_ENTITY_ID ], @@ -321,7 +319,7 @@ async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> Non number_of_code_slots=config_entry.data[CONF_SLOTS], starting_code_slot=config_entry.data[CONF_START], code_slots={}, - parent=config_entry.data[CONF_PARENT], + parent_name=config_entry.data[CONF_PARENT], ) hass.data[DOMAIN][config_entry.entry_id] = device.id @@ -331,7 +329,7 @@ async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> Non else: coordinator = hass.data[DOMAIN][COORDINATOR] - await coordinator.update_lock(lock=lock) + await coordinator.update_lock(kmlock=kmlock) if old_slots != new_slots: async_dispatcher_send( diff --git a/custom_components/keymaster/binary_sensor.py b/custom_components/keymaster/binary_sensor.py index 57ebdf7b..a07516ca 100644 --- a/custom_components/keymaster/binary_sensor.py +++ b/custom_components/keymaster/binary_sensor.py @@ -26,8 +26,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Setup config entry.""" coordinator = hass.data[DOMAIN][COORDINATOR] - lock = await coordinator.get_lock_by_config_entry_id(config_entry.entry_id) - if async_using_zwave_js(hass=hass, lock=lock): + kmlock = await coordinator.get_lock_by_config_entry_id(config_entry.entry_id) + if async_using_zwave_js(hass=hass, kmlock=kmlock): entity = ZwaveJSNetworkReadySensor( entity_description=KeymasterBinarySensorEntityDescription( key="binary_sensor.connected", @@ -68,14 +68,11 @@ def __init__( ) self.integration_name = integration_name self._attr_is_on = False - self._attr_should_poll = False @callback def _handle_coordinator_update(self) -> None: - _LOGGER.debug( - f"[Binary Sensor handle_coordinator_update] self.coordinator.data: {self.coordinator.data}" - ) - + # _LOGGER.debug(f"[Binary Sensor handle_coordinator_update] self.coordinator.data: {self.coordinator.data}") + self._attr_is_on = self._get_property_value() self.async_write_ha_state() # @callback @@ -108,61 +105,3 @@ def __init__( integration_name=ZWAVE_JS_DOMAIN, entity_description=entity_description, ) - self.lock_config_entry_id = None - self._lock_found = True - self._attr_should_poll = True - - # async def async_update(self) -> None: - # """Update sensor.""" - # if not self.ent_reg: - # self.ent_reg = async_get_entity_registry(self.hass) - - # if ( - # not self.lock_config_entry_id - # or not self.hass.config_entries.async_get_entry(self.lock_config_entry_id) - # ): - # entity_id = self.primary_lock.lock_entity_id - # lock_ent_reg_entry = self.ent_reg.async_get(entity_id) - - # if not lock_ent_reg_entry: - # if self._lock_found: - # self._lock_found = False - # _LOGGER.warning("Can't find your lock %s.", entity_id) - # return - - # self.lock_config_entry_id = lock_ent_reg_entry.config_entry_id - - # if not self._lock_found: - # _LOGGER.info("Found your lock %s", entity_id) - # self._lock_found = True - - # try: - # zwave_entry = self.hass.config_entries.async_get_entry( - # self.lock_config_entry_id - # ) - # client = zwave_entry.runtime_data[ZWAVE_JS_DATA_CLIENT] - # except: - # _LOGGER.exception("Can't access Z-Wave JS client.") - # self._attr_is_on = False - # return - - # network_ready = bool( - # client.connected and client.driver and client.driver.controller - # ) - - # # If network_ready and self._attr_is_on are both true or both false, we don't need - # # to do anything since there is nothing to update. - # if not network_ready ^ self.is_on: - # return - - # self.async_set_is_on_property(network_ready, False) - - # # If we just turned the sensor on, we need to get the latest lock - # # nodes and devices - # if self.is_on: - # await async_update_zwave_js_nodes_and_devices( - # self.hass, - # self.lock_config_entry_id, - # self.primary_lock, - # self.child_locks, - # ) diff --git a/custom_components/keymaster/config_flow.py b/custom_components/keymaster/config_flow.py index f261371f..e10d724a 100644 --- a/custom_components/keymaster/config_flow.py +++ b/custom_components/keymaster/config_flow.py @@ -102,13 +102,13 @@ class KeyMasterOptionsFlow(config_entries.OptionsFlow): def __init__(self, config_entry: config_entries.ConfigEntry): """Initialize.""" - self.config_entry = config_entry + self._config_entry = config_entry def _get_unique_name_error(self, user_input): """Check if name is unique, returning dictionary error if so.""" # If lock name has changed, make sure new name isn't already being used # otherwise show an error - if self.config_entry.unique_id != user_input[CONF_LOCK_NAME]: + if self._config_entry.unique_id != user_input[CONF_LOCK_NAME]: for entry in self.hass.config_entries.async_entries(DOMAIN): if entry.unique_id == user_input[CONF_LOCK_NAME]: return {CONF_LOCK_NAME: "same_name"} @@ -123,8 +123,8 @@ async def async_step_init( "init", "", user_input, - self.config_entry.data, - self.config_entry.entry_id, + self._config_entry.data, + self._config_entry.entry_id, ) diff --git a/custom_components/keymaster/coordinator.py b/custom_components/keymaster/coordinator.py index b95bc8f0..2db58a16 100644 --- a/custom_components/keymaster/coordinator.py +++ b/custom_components/keymaster/coordinator.py @@ -29,6 +29,10 @@ from zwave_js_server.util.lock import get_usercode_from_node, get_usercodes from homeassistant.components.zwave_js import ZWAVE_JS_NOTIFICATION_EVENT + from homeassistant.components.zwave_js.const import ( + DATA_CLIENT as ZWAVE_JS_DATA_CLIENT, + DOMAIN as ZWAVE_JS_DOMAIN, + ) except (ModuleNotFoundError, ImportError): pass @@ -46,7 +50,7 @@ class KeymasterCoordinator(DataUpdateCoordinator): def __init__(self, hass: HomeAssistant) -> None: self._device_registry = dr.async_get(hass) self._entity_registry = er.async_get(hass) - self.locks: Mapping[str, KeymasterLock] = {} + self.kmlocks: Mapping[str, KeymasterLock] = {} super().__init__( hass, _LOGGER, @@ -84,7 +88,7 @@ def __init__(self, hass: HomeAssistant) -> None: # async def async_update_usercodes(self) -> Mapping[Union[str, int], Any]: # """Wrapper to update usercodes.""" - # self.slots = get_code_slots_list(self.config_entry.data) + # self.slots = get_code_slots_list(self._config_entry.data) # if not self.network_sensor: # self.network_sensor = self._entity_registry.async_get_entity_id( # "binary_sensor", @@ -112,7 +116,7 @@ def __init__(self, hass: HomeAssistant) -> None: # ZWaveNetworkNotReady, # ) as err: # # We can silently fail if we've never been able to retrieve data - # if not self.locks: + # if not self.kmlocks: # return {} # raise UpdateFailed from err @@ -161,116 +165,125 @@ def __init__(self, hass: HomeAssistant) -> None: # return data async def _rebuild_lock_relationships(self): - for keymaster_device_id, lock in self.locks.items(): - if lock.parent is not None: - for parent_device_id, parent_lock in self.locks.items(): - if lock.parent == parent_lock.lock_name: - if lock.parent_device_id is None: - lock.parent_device_id = parent_device_id - if keymaster_device_id not in parent_lock.child_device_ids: - parent_lock.child_device_ids.append(keymaster_device_id) + for keymaster_config_entry_id, kmlock in self.kmlocks.items(): + if kmlock.parent_name is not None: + for parent_config_entry_id, parent_lock in self.kmlocks.items(): + if kmlock.parent_name == parent_lock.lock_name: + if kmlock.parent_config_entry_id is None: + kmlock.parent_config_entry_id = parent_config_entry_id + if ( + keymaster_config_entry_id + not in parent_lock.child_config_entry_ids + ): + parent_lock.child_config_entry_ids.append( + keymaster_config_entry_id + ) break - for child_device_id in lock.child_device_ids: + for child_config_entry_id in kmlock.child_config_entry_ids: if ( - child_device_id not in self.locks - or self.locks[child_device_id].parent_device_id - != keymaster_device_id + child_config_entry_id not in self.kmlocks + or self.kmlocks[child_config_entry_id].parent_config_entry_id + != keymaster_config_entry_id ): try: - lock.child_device_ids.remove(child_device_id) + kmlock.child_config_entry_ids.remove(child_config_entry_id) except ValueError: pass async def _update_code_slots(self): pass - async def _unsubscribe_listeners(self, lock: KeymasterLock): + async def _unsubscribe_listeners(self, kmlock: KeymasterLock): # Unsubscribe to any listeners - for unsub_listener in lock.listeners: + for unsub_listener in kmlock.listeners: unsub_listener() - lock.listeners = [] + kmlock.listeners = [] - async def _update_listeners(self, lock: KeymasterLock): - await self._unsubscribe_listeners(lock) - if async_using_zwave_js(hass=self.hass, lock=lock): + async def _update_listeners(self, kmlock: KeymasterLock): + await self._unsubscribe_listeners(kmlock) + if async_using_zwave_js(hass=self.hass, kmlock=kmlock): # Listen to Z-Wave JS events so we can fire our own events - lock.listeners.append( + kmlock.listeners.append( self.hass.bus.async_listen( ZWAVE_JS_NOTIFICATION_EVENT, - functools.partial(handle_zwave_js_event, self.hass, lock), + functools.partial(handle_zwave_js_event, self.hass, kmlock), ) ) # Check if we need to check alarm type/alarm level sensors, in which case # we need to listen for lock state changes - if lock.alarm_level_or_user_code_entity_id not in ( + if kmlock.alarm_level_or_user_code_entity_id not in ( None, "sensor.fake", - ) and lock.alarm_type_or_access_control_entity_id not in ( + ) and kmlock.alarm_type_or_access_control_entity_id not in ( None, "sensor.fake", ): if self.hass.state == CoreState.running: - await homeassistant_started_listener(self.hass, lock) + await homeassistant_started_listener(self.hass, kmlock) else: self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STARTED, - functools.partial(homeassistant_started_listener, self.hass, lock), + functools.partial( + homeassistant_started_listener, self.hass, kmlock + ), ) - async def add_lock(self, lock: KeymasterLock) -> bool: - if lock.keymaster_device_id in self.locks: + async def add_lock(self, kmlock: KeymasterLock) -> bool: + if kmlock.keymaster_config_entry_id in self.kmlocks: return False - self.locks[lock.keymaster_device_id] = lock + self.kmlocks[kmlock.keymaster_config_entry_id] = kmlock await self._rebuild_lock_relationships() await self._update_code_slots() - await self._update_listeners(lock) + await self._update_listeners(kmlock) return True - async def update_lock(self, lock: KeymasterLock) -> bool: - if lock.keymaster_device_id not in self.locks: + async def update_lock(self, kmlock: KeymasterLock) -> bool: + if kmlock.keymaster_config_entry_id not in self.kmlocks: return False - self.locks.update({lock.keymaster_device_id: lock}) + self.kmlocks.update({kmlock.keymaster_config_entry_id: kmlock}) await self._rebuild_lock_relationships() await self._update_code_slots() - await self._update_listeners(self.locks[lock.keymaster_device_id]) + await self._update_listeners(self.kmlocks[kmlock.keymaster_config_entry_id]) return True - async def update_lock_by_device_id( - self, keymaster_device_id: str, **kwargs + async def update_lock_by_config_entry_id( + self, config_entry_id: str, **kwargs ) -> bool: - if keymaster_device_id not in self.locks: + if config_entry_id not in self.kmlocks: return False for attr, value in kwargs.items(): - if hasattr(self.locks[keymaster_device_id], attr): - setattr(self.locks[keymaster_device_id], attr, value) + if hasattr(self.kmlocks[config_entry_id], attr): + setattr(self.kmlocks[config_entry_id], attr, value) await self._rebuild_lock_relationships() await self._update_code_slots() - await self._update_listeners(self.locks[keymaster_device_id]) + await self._update_listeners(self.kmlocks[config_entry_id]) return True - async def delete_lock(self, lock: KeymasterLock) -> bool: - if lock.keymaster_device_id not in self.locks: + async def delete_lock(self, kmlock: KeymasterLock) -> bool: + if kmlock.keymaster_config_entry_id not in self.kmlocks: return True - await self._unsubscribe_listeners(self.locks[lock.keymaster_device_id]) - self.locks.pop(lock.keymaster_device_id, None) + await self._unsubscribe_listeners( + self.kmlocks[kmlock.keymaster_config_entry_id] + ) + self.kmlocks.pop(kmlock.keymaster_config_entry_id, None) await self._rebuild_lock_relationships() await self._update_code_slots() return True - async def delete_lock_by_device_id(self, keymaster_device_id: str) -> bool: - if keymaster_device_id not in self.locks: + async def delete_lock_by_config_entry_id(self, config_entry_id: str) -> bool: + if config_entry_id not in self.kmlocks: return True - await self._unsubscribe_listeners(self.locks[keymaster_device_id]) - self.locks.pop(keymaster_device_id, None) + await self._unsubscribe_listeners(self.kmlocks[config_entry_id]) + self.kmlocks.pop(config_entry_id, None) await self._rebuild_lock_relationships() await self._update_code_slots() return True async def get_lock_by_name(self, lock_name: str) -> KeymasterLock | None: - for lock in self.locks.values(): - if lock_name == lock.lock_name: - return lock + for kmlock in self.kmlocks.values(): + if lock_name == kmlock.lock_name: + return kmlock return None async def get_lock_by_config_entry_id( @@ -279,16 +292,9 @@ async def get_lock_by_config_entry_id( _LOGGER.debug( f"[get_lock_by_config_entry_id] config_entry_id: {config_entry_id}" ) - devices = dr.async_entries_for_config_entry( - self._device_registry, config_entry_id - ) - if ( - not isinstance(devices, list) - or len(devices) != 1 - or devices[0].id not in self.locks - ): + if config_entry_id not in self.kmlocks: return None - return self.locks[devices[0].id] + return self.kmlocks[config_entry_id] def sync_get_lock_by_config_entry_id( self, config_entry_id: str @@ -296,80 +302,94 @@ def sync_get_lock_by_config_entry_id( _LOGGER.debug( f"[sync_get_lock_by_config_entry_id] config_entry_id: {config_entry_id}" ) - devices = dr.async_entries_for_config_entry( - self._device_registry, config_entry_id - ) - if ( - not isinstance(devices, list) - or len(devices) != 1 - or devices[0].id not in self.locks - ): + if config_entry_id not in self.kmlocks: return None - return self.locks[devices[0].id] + return self.kmlocks[config_entry_id] - async def get_lock_by_device_id( - self, keymaster_device_id: str - ) -> KeymasterLock | None: - _LOGGER.debug( - f"[get_lock_by_device_id] keymaster_device_id: {keymaster_device_id}" - ) - if keymaster_device_id not in self.locks: + async def get_device_id_from_config_entry_id( + self, config_entry_id: str + ) -> str | None: + if config_entry_id not in self.kmlocks: return None - return self.locks[keymaster_device_id] + return self.kmlocks[config_entry_id].keymaster_device_id + + def sync_get_device_id_from_config_entry_id( + self, config_entry_id: str + ) -> str | None: + if config_entry_id not in self.kmlocks: + return None + return self.kmlocks[config_entry_id].keymaster_device_id + + async def _connect_and_update_lock(self, kmlock: KeymasterLock) -> None: + prev_lock_connected: bool = kmlock.connected + kmlock.connected = False + if kmlock.lock_config_entry_id is None: + lock_ent_reg_entry = self._entity_registry.async_get(kmlock.lock_entity_id) + + if not lock_ent_reg_entry: + _LOGGER.error( + f"[Coordinator] {kmlock.lock_name}: Can't find the lock in the Entity Registry" + ) + kmlock.connected = False + return + + kmlock.lock_config_entry_id = lock_ent_reg_entry.config_entry_id + + try: + zwave_entry = self.hass.config_entries.async_get_entry( + kmlock.lock_config_entry_id + ) + client = zwave_entry.runtime_data[ZWAVE_JS_DATA_CLIENT] + except Exception as e: + _LOGGER.error( + f"[Coordinator] {kmlock.lock_name}: Can't access the Z-Wave JS client. {e.__class__.__qualname__}: {e}" + ) + kmlock.connected = False + return + + kmlock.connected = bool( + client.connected and client.driver and client.driver.controller + ) + + if not kmlock.connected: + return + + if kmlock.connected and prev_lock_connected: + return - def sync_get_lock_by_device_id( - self, keymaster_device_id: str - ) -> KeymasterLock | None: _LOGGER.debug( - f"[sync_get_lock_by_device_id] keymaster_device_id: {keymaster_device_id}" + f"[Coordinator] {kmlock.lock_name}: Now connected, updating Device and Nodes" ) - if keymaster_device_id not in self.locks: - return None - return self.locks[keymaster_device_id] - - async def _check_lock_connection(self, lock) -> bool: - # TODO: redo this to use lock.connected - return lock.connected - - # self.network_sensor = self._entity_registry.async_get_entity_id( - # "binary_sensor", - # DOMAIN, - # slugify(generate_binary_sensor_name(lock.lock_name)), - # ) - # if self.network_sensor is None: - # return False - # try: - # network_ready = self.hass.states.get(self.network_sensor) - # if not network_ready: - # # We may need to get a new entity ID - # self.network_sensor = None - # raise ZWaveNetworkNotReady - - # if network_ready.state != STATE_ON: - # raise ZWaveNetworkNotReady - - # return True - # except ( - # NativeNotFoundError, - # NativeNotSupportedError, - # NoNodeSpecifiedError, - # ZWaveIntegrationNotConfiguredError, - # ZWaveNetworkNotReady, - # ): - # return False + lock_dev_reg_entry = self._device_registry.async_get( + lock_ent_reg_entry.device_id + ) + if not lock_dev_reg_entry: + _LOGGER.error( + f"[Coordinator] {kmlock.lock_name}: Can't find the lock in the Device Registry" + ) + kmlock.connected = False + return + node_id: int = 0 + for identifier in lock_dev_reg_entry.identifiers: + if identifier[0] == ZWAVE_JS_DOMAIN: + node_id = int(identifier[1].split("-")[1]) + + kmlock.zwave_js_lock_node = client.driver.controller.nodes[node_id] + kmlock.zwave_js_lock_device = lock_dev_reg_entry async def _async_update_data(self) -> Mapping[str, Any]: - _LOGGER.debug(f"[Coordinator] self.locks: {self.locks}") - for lock in self.locks.values(): - if not await self._check_lock_connection(lock): - _LOGGER.error(f"[Coordinator] {lock.lock_name} not Connected") + _LOGGER.debug(f"[Coordinator] self.kmlocks: {self.kmlocks}") + for kmlock in self.kmlocks.values(): + await self._connect_and_update_lock(kmlock) + if not kmlock.connected: + _LOGGER.error(f"[Coordinator] {kmlock.lock_name}: Not Connected") continue - if async_using_zwave_js(hass=self.hass, lock=lock): - node: ZwaveJSNode = lock.zwave_js_lock_node + if async_using_zwave_js(hass=self.hass, kmlock=kmlock): + node: ZwaveJSNode = kmlock.zwave_js_lock_node if node is None: - _LOGGER.debug( - f"[Coordinator] {lock.lock_name} Z-Wave Node not defined" + _LOGGER.error( + f"[Coordinator] {kmlock.lock_name}: Z-Wave JS Node not defined" ) continue @@ -378,33 +398,39 @@ async def _async_update_data(self) -> Mapping[str, Any]: usercode: str | None = slot[ATTR_USERCODE] slot_name: str | None = slot[ATTR_NAME] in_use: bool | None = slot[ATTR_IN_USE] + if code_slot not in kmlock.code_slots: + _LOGGER.debug( + f"[Coordinator] {kmlock.lock_name}: Code Slot {code_slot} defined in lock but not in Keymaster, ignoring" + ) + continue # Retrieve code slots that haven't been populated yet - if in_use is None and code_slot in lock.code_slots: + if in_use is None and code_slot in kmlock.code_slots: usercode_resp = await get_usercode_from_node(node, code_slot) usercode = slot[ATTR_USERCODE] = usercode_resp[ATTR_USERCODE] slot_name = slot[ATTR_NAME] = usercode_resp[ATTR_NAME] in_use = slot[ATTR_IN_USE] = usercode_resp[ATTR_IN_USE] if not in_use: - _LOGGER.debug("DEBUG: Code slot %s not enabled", code_slot) - lock.code_slots[code_slot].enabled = False + _LOGGER.debug( + f"[Coordinator] {kmlock.lock_name}: Code slot {code_slot} not enabled" + ) + kmlock.code_slots[code_slot].enabled = False elif usercode and "*" in str(usercode): _LOGGER.debug( - "DEBUG: Ignoring code slot with * in value for code slot %s", - code_slot, + f"[Coordinator] {kmlock.lock_name}: Ignoring code slot with * in value for code slot {code_slot}" ) else: _LOGGER.debug( - "DEBUG: Code slot %s value: %s", code_slot, usercode + f"[Coordinator] {kmlock.lock_name}: Code slot {code_slot} value: {usercode}" ) - lock.code_slots[code_slot].enabled = True - lock.code_slots[code_slot].name = slot_name - lock.code_slots[code_slot].pin = usercode + kmlock.code_slots[code_slot].enabled = True + kmlock.code_slots[code_slot].name = slot_name + kmlock.code_slots[code_slot].pin = usercode else: - _LOGGER.error(f"[Coordinator] {lock.lock_name} not using Z-Wave") + _LOGGER.error(f"[Coordinator] {kmlock.lock_name}: Not using Z-Wave JS") continue # TODO: Loop through locks again and filter down any changes to children # (If changes, need to also push those to the physical child lock as well) - return self.locks + return self.kmlocks diff --git a/custom_components/keymaster/entity.py b/custom_components/keymaster/entity.py index f8078b5a..cabd6717 100644 --- a/custom_components/keymaster/entity.py +++ b/custom_components/keymaster/entity.py @@ -23,34 +23,35 @@ class KeymasterEntity(CoordinatorEntity[KeymasterCoordinator]): """Base entity for Keymaster""" - _attr_available = True - def __init__(self, entity_description: EntityDescription) -> None: self.hass: HomeAssistant = entity_description.hass self.coordinator: KeymasterCoordinator = entity_description.coordinator self._config_entry = entity_description.config_entry self.entity_description: EntityDescription = entity_description + self._attr_available = False self._property: str = entity_description.key - self._keymaster_device_id: str = self.hass.data[DOMAIN][ + self._kmlock: KeymasterLock = self.coordinator.sync_get_lock_by_config_entry_id( self._config_entry.entry_id - ] - lock: KeymasterLock = self.coordinator.sync_get_lock_by_device_id( - self._keymaster_device_id ) - self._attr_name: str = f"{lock.lock_name} {self.entity_description.name}" + self._keymaster_device_id: str = self._kmlock.keymaster_device_id + self._attr_name: str = ( + f"{self._kmlock.lock_name} {self.entity_description.name}" + ) _LOGGER.debug( f"[Entity init] entity_description.name: {self.entity_description.name}, name: {self.name}" ) self._attr_unique_id: str = ( - f"{self._keymaster_device_id}_{slugify(self._property)}" + f"{self._config_entry.entry_id}_{slugify(self._property)}" ) _LOGGER.debug( f"[Entity init] self._property: {self._property}, unique_id: {self.unique_id}" ) self._attr_extra_state_attributes: Mapping[str, Any] = {} self._attr_device_info: Mapping[str, Any] = { - "identifiers": {(DOMAIN, entity_description.config_entry.entry_id)}, - "via_device": lock.parent_device_id, + "identifiers": {(DOMAIN, self._config_entry.entry_id)}, + "via_device": self.coordinator.sync_get_device_id_from_config_entry_id( + self._kmlock.parent_config_entry_id + ), } super().__init__(self.coordinator, self._attr_unique_id) @@ -58,6 +59,26 @@ def __init__(self, entity_description: EntityDescription) -> None: def available(self) -> bool: return self._attr_available + def _get_property_value(self): + if "." not in self._property: + return None + prop_list: str = self._property.split(".") + result = self._kmlock + for key in prop_list[1:]: + num = None + try: + if ":" in key: + num = int(key.split(":")[1]) + key = key.split(":")[0] + if not hasattr(result, key): + return None + result = getattr(result, key) + if num is not None: + result = result[num] + except (TypeError, KeyError, AttributeError): + return None + return result + @dataclass(kw_only=True) class KeymasterEntityDescription(EntityDescription): diff --git a/custom_components/keymaster/helpers.py b/custom_components/keymaster/helpers.py index f4a7c8b9..42da8014 100644 --- a/custom_components/keymaster/helpers.py +++ b/custom_components/keymaster/helpers.py @@ -1,10 +1,10 @@ """Helpers for keymaster.""" import asyncio -import functools -import logging from collections.abc import Mapping from datetime import timedelta +import functools +import logging from homeassistant.components.automation import DOMAIN as AUTO_DOMAIN from homeassistant.components.input_boolean import DOMAIN as IN_BOOL_DOMAIN @@ -25,8 +25,6 @@ from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback from homeassistant.exceptions import ServiceNotFound from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.device_registry import async_get as async_get_device_registry -from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry from homeassistant.helpers.event import async_track_state_change_event from homeassistant.util import dt as dt_util @@ -49,18 +47,15 @@ zwave_js_supported = True try: + from zwave_js_server.const.command_class.lock import ATTR_CODE_SLOT + from homeassistant.components.zwave_js.const import ( ATTR_EVENT_LABEL, ATTR_NODE_ID, ATTR_PARAMETERS, - ) - from homeassistant.components.zwave_js.const import ( DATA_CLIENT as ZWAVE_JS_DATA_CLIENT, - ) - from homeassistant.components.zwave_js.const import ( DOMAIN as ZWAVE_JS_DOMAIN, ) - from zwave_js_server.const.command_class.lock import ATTR_CODE_SLOT except (ModuleNotFoundError, ImportError): zwave_js_supported = False ATTR_CODE_SLOT = "code_slot" @@ -73,15 +68,15 @@ def _async_using( hass: HomeAssistant, domain: str, - lock: KeymasterLock | None, + kmlock: KeymasterLock | None, entity_id: str | None, ) -> bool: """Base function for using_ logic.""" - if not (lock or entity_id): + if not (kmlock or entity_id): raise Exception("Missing arguments") ent_reg = er.async_get(hass) - if lock: - entity = ent_reg.async_get(lock.lock_entity_id) + if kmlock: + entity = ent_reg.async_get(kmlock.lock_entity_id) else: entity = ent_reg.async_get(entity_id) @@ -91,14 +86,14 @@ def _async_using( @callback def async_using_zwave_js( hass: HomeAssistant, - lock: KeymasterLock = None, + kmlock: KeymasterLock = None, entity_id: str = None, ) -> bool: """Returns whether the zwave_js integration is configured.""" return zwave_js_supported and _async_using( hass=hass, domain=ZWAVE_JS_DOMAIN, - lock=lock, + kmlock=kmlock, entity_id=entity_id, ) @@ -108,35 +103,35 @@ def get_code_slots_list(data: Mapping[str, int]) -> list[int]: return list(range(data[CONF_START], data[CONF_START] + data[CONF_SLOTS])) -async def async_update_zwave_js_nodes_and_devices( - hass: HomeAssistant, - entry_id: str, - primary_lock: KeymasterLock, - child_locks: list[KeymasterLock], -) -> None: - """Update Z-Wave JS nodes and devices.""" - try: - zwave_entry = hass.config_entries.async_get_entry(entry_id) - client = zwave_entry.runtime_data[ZWAVE_JS_DATA_CLIENT] - except: - _LOGGER.exception("Can't access Z-Wave JS client.") - return - ent_reg = async_get_entity_registry(hass) - dev_reg = async_get_device_registry(hass) - for lock in [primary_lock, *child_locks]: - lock_ent_reg_entry = ent_reg.async_get(lock.lock_entity_id) - if not lock_ent_reg_entry: - continue - lock_dev_reg_entry = dev_reg.async_get(lock_ent_reg_entry.device_id) - if not lock_dev_reg_entry: - continue - node_id: int = 0 - for identifier in lock_dev_reg_entry.identifiers: - if identifier[0] == ZWAVE_JS_DOMAIN: - node_id = int(identifier[1].split("-")[1]) - - lock.zwave_js_lock_node = client.driver.controller.nodes[node_id] - lock.zwave_js_lock_device = lock_dev_reg_entry +# async def async_update_zwave_js_nodes_and_devices( +# hass: HomeAssistant, +# entry_id: str, +# primary_lock: KeymasterLock, +# child_locks: list[KeymasterLock], +# ) -> None: +# """Update Z-Wave JS nodes and devices.""" +# try: +# zwave_entry = hass.config_entries.async_get_entry(entry_id) +# client = zwave_entry.runtime_data[ZWAVE_JS_DATA_CLIENT] +# except: +# _LOGGER.exception("Can't access Z-Wave JS client.") +# return +# ent_reg = async_get_entity_registry(hass) +# dev_reg = async_get_device_registry(hass) +# for lock in [primary_lock, *child_locks]: +# lock_ent_reg_entry = ent_reg.async_get(lock.lock_entity_id) +# if not lock_ent_reg_entry: +# continue +# lock_dev_reg_entry = dev_reg.async_get(lock_ent_reg_entry.device_id) +# if not lock_dev_reg_entry: +# continue +# node_id: int = 0 +# for identifier in lock_dev_reg_entry.identifiers: +# if identifier[0] == ZWAVE_JS_DOMAIN: +# node_id = int(identifier[1].split("-")[1]) + +# lock.zwave_js_lock_node = client.driver.controller.nodes[node_id] +# lock.zwave_js_lock_device = lock_dev_reg_entry # def output_to_file_from_template( @@ -162,7 +157,7 @@ async def async_update_zwave_js_nodes_and_devices( # def delete_lock_and_base_folder(hass: HomeAssistant, config_entry: ConfigEntry) -> None: # """Delete packages folder for lock and base keymaster folder if empty.""" # base_path = os.path.join(hass.config.path(), config_entry.data[CONF_PATH]) -# lock: KeymasterLock = hass.data[DOMAIN][config_entry.entry_id][PRIMARY_LOCK] +# kmlock: KeymasterLock = hass.data[DOMAIN][config_entry.entry_id][PRIMARY_LOCK] # delete_folder(base_path, lock.lock_name) # if not os.listdir(base_path): @@ -180,25 +175,27 @@ async def async_update_zwave_js_nodes_and_devices( # os.rmdir(path) -def handle_zwave_js_event(hass: HomeAssistant, lock: KeymasterLock, evt: Event) -> None: +def handle_zwave_js_event( + hass: HomeAssistant, kmlock: KeymasterLock, evt: Event +) -> None: """Handle Z-Wave JS event.""" if ( - not lock.zwave_js_lock_node - or not lock.zwave_js_lock_device - or evt.data[ATTR_NODE_ID] != lock.zwave_js_lock_node.node_id - or evt.data[ATTR_DEVICE_ID] != lock.zwave_js_lock_device.id + not kmlock.zwave_js_lock_node + or not kmlock.zwave_js_lock_device + or evt.data[ATTR_NODE_ID] != kmlock.zwave_js_lock_node.node_id + or evt.data[ATTR_DEVICE_ID] != kmlock.zwave_js_lock_device.id ): return # Get lock state to provide as part of event data - lock_state = hass.states.get(lock.lock_entity_id) + lock_state = hass.states.get(kmlock.lock_entity_id) params = evt.data.get(ATTR_PARAMETERS) or {} code_slot = params.get("userId", 0) # Lookup name for usercode code_slot_name_state = ( - hass.states.get(f"input_text.{lock.lock_name}_name_{code_slot}") + hass.states.get(f"input_text.{kmlock.lock_name}_name_{code_slot}") if code_slot and code_slot != 0 else None ) @@ -207,8 +204,8 @@ def handle_zwave_js_event(hass: HomeAssistant, lock: KeymasterLock, evt: Event) EVENT_KEYMASTER_LOCK_STATE_CHANGED, event_data={ ATTR_NOTIFICATION_SOURCE: "event", - ATTR_NAME: lock.lock_name, - ATTR_ENTITY_ID: lock.lock_entity_id, + ATTR_NAME: kmlock.lock_name, + ATTR_ENTITY_ID: kmlock.lock_entity_id, ATTR_STATE: lock_state.state if lock_state else "", ATTR_ACTION_TEXT: evt.data.get(ATTR_EVENT_LABEL), ATTR_CODE_SLOT: code_slot or 0, @@ -272,16 +269,16 @@ def handle_zwave_js_event(hass: HomeAssistant, lock: KeymasterLock, evt: Event) async def homeassistant_started_listener( hass: HomeAssistant, - lock: KeymasterLock, + kmlock: KeymasterLock, evt: Event = None, ): """Start tracking state changes after HomeAssistant has started.""" # Listen to lock state changes so we can fire an event - lock.listeners.append( + kmlock.listeners.append( async_track_state_change_event( hass, - lock.lock_entity_id, - functools.partial(handle_state_change, hass, lock), + kmlock.lock_entity_id, + functools.partial(handle_state_change, hass, kmlock), ) ) @@ -289,7 +286,7 @@ async def homeassistant_started_listener( @callback def handle_state_change( hass: HomeAssistant, - lock: KeymasterLock, + kmlock: KeymasterLock, changed_entity: str, event: Event[EventStateChangedData] | None = None, ) -> None: @@ -300,24 +297,24 @@ def handle_state_change( new_state = event.data["new_state"] # Don't do anything if the changed entity is not this lock - if changed_entity != lock.lock_entity_id: + if changed_entity != kmlock.lock_entity_id: return # Determine action type to set appropriate action text using ACTION_MAP action_type = "" - if lock.alarm_type_or_access_control_entity_id and ( - ALARM_TYPE in lock.alarm_type_or_access_control_entity_id - or ALARM_TYPE.replace("_", "") in lock.alarm_type_or_access_control_entity_id + if kmlock.alarm_type_or_access_control_entity_id and ( + ALARM_TYPE in kmlock.alarm_type_or_access_control_entity_id + or ALARM_TYPE.replace("_", "") in kmlock.alarm_type_or_access_control_entity_id ): action_type = ALARM_TYPE if ( - lock.alarm_type_or_access_control_entity_id - and ACCESS_CONTROL in lock.alarm_type_or_access_control_entity_id + kmlock.alarm_type_or_access_control_entity_id + and ACCESS_CONTROL in kmlock.alarm_type_or_access_control_entity_id ): action_type = ACCESS_CONTROL # Get alarm_level/usercode and alarm_type/access_control states - alarm_level_state = hass.states.get(lock.alarm_level_or_user_code_entity_id) + alarm_level_state = hass.states.get(kmlock.alarm_level_or_user_code_entity_id) alarm_level_value = ( int(alarm_level_state.state) if alarm_level_state @@ -325,7 +322,7 @@ def handle_state_change( else None ) - alarm_type_state = hass.states.get(lock.alarm_type_or_access_control_entity_id) + alarm_type_state = hass.states.get(kmlock.alarm_type_or_access_control_entity_id) alarm_type_value = ( int(alarm_type_state.state) if alarm_type_state @@ -359,7 +356,7 @@ def handle_state_change( # Lookup name for usercode code_slot_name_state = hass.states.get( - f"input_text.{lock.lock_name}_name_{alarm_level_value}" + f"input_text.{kmlock.lock_name}_name_{alarm_level_value}" ) # Fire state change event @@ -367,8 +364,8 @@ def handle_state_change( EVENT_KEYMASTER_LOCK_STATE_CHANGED, event_data={ ATTR_NOTIFICATION_SOURCE: "entity_state", - ATTR_NAME: lock.lock_name, - ATTR_ENTITY_ID: lock.lock_entity_id, + ATTR_NAME: kmlock.lock_name, + ATTR_ENTITY_ID: kmlock.lock_entity_id, ATTR_STATE: new_state.state, ATTR_ACTION_CODE: alarm_type_value, ATTR_ACTION_TEXT: action_text, diff --git a/custom_components/keymaster/lock.py b/custom_components/keymaster/lock.py index fc653186..8ccedd26 100644 --- a/custom_components/keymaster/lock.py +++ b/custom_components/keymaster/lock.py @@ -39,6 +39,8 @@ class KeymasterLock: lock_name: str lock_entity_id: str keymaster_device_id: str + keymaster_config_entry_id: str + lock_config_entry_id: str | None = None alarm_level_or_user_code_entity_id: str | None = None alarm_type_or_access_control_entity_id: str | None = None door_sensor_entity_id: str | None = None @@ -48,7 +50,7 @@ class KeymasterLock: number_of_code_slots: int | None = None starting_code_slot: int = 1 code_slots: Mapping[int, KeymasterCodeSlot] | None = None - parent: str | None = None - parent_device_id: str | None = None - child_device_ids: list = field(default_factory=list) + parent_name: str | None = None + parent_config_entry_id: str | None = None + child_config_entry_ids: list = field(default_factory=list) listeners: list = field(default_factory=list) diff --git a/custom_components/keymaster/sensor.py b/custom_components/keymaster/sensor.py index dab2f3ce..8ca50c82 100644 --- a/custom_components/keymaster/sensor.py +++ b/custom_components/keymaster/sensor.py @@ -40,7 +40,7 @@ async def async_setup_entry( [ KeymasterCodesSensor( entity_description=KeymasterSensorEntityDescription( - key=f"sensor.code_slot.pin:{x}", # ...: + key=f"sensor.code_slots:{x}.pin", # ...: name=f"Code Slot {x}", icon="mdi:lock-smart", entity_registry_enabled_default=True, @@ -78,7 +78,7 @@ async def code_slots_changed( [ KeymasterCodesSensor( entity_description=KeymasterSensorEntityDescription( - key=f"sensor.code_slot.pin:{x}", # ...: + key=f"sensor.code_slots:{x}.pin", # ...: name=f"Code Slot {x}", icon="mdi:lock-smart", entity_registry_enabled_default=True, @@ -123,14 +123,26 @@ def __init__( super().__init__( entity_description=entity_description, ) - self._code_slot = self._property.split(":")[1] + self._code_slot = self._property.split(":")[1].split(".")[0] self._attr_extra_state_attributes = {ATTR_CODE_SLOT: self._code_slot} self._attr_native_value = None @callback def _handle_coordinator_update(self) -> None: - _LOGGER.debug( - f"[Sensor handle_coordinator_update] self.coordinator.data: {self.coordinator.data}" - ) - + # _LOGGER.debug(f"[Sensor handle_coordinator_update] self.coordinator.data: {self.coordinator.data}") + if not self._kmlock.connected: + self._attr_available = False + self.async_write_ha_state() + return + + if ( + "code_slots" in self._property + and not self._kmlock.code_slots[self._code_slot].enabled + ): + self._attr_available = False + self.async_write_ha_state() + return + + self._attr_available = True + self._attr_native_value = self._get_property_value() self.async_write_ha_state() diff --git a/custom_components/keymaster/services.py b/custom_components/keymaster/services.py index 3e80361e..f6a3ab70 100644 --- a/custom_components/keymaster/services.py +++ b/custom_components/keymaster/services.py @@ -252,7 +252,7 @@ def generate_package_files(hass: HomeAssistant, name: str) -> None: # Append _child to child lock yaml files child_file = "" - if primary_lock.parent is not None: + if primary_lock.parent_name is not None: child_file = "_child" lockname = slugify(primary_lock.lock_name) @@ -343,7 +343,7 @@ def generate_package_files(hass: HomeAssistant, name: str) -> None: "SENSORALARMLEVEL": sensoralarmlevel, "HIDE_PINS": hide_pins, "PARENTLOCK": ( - "" if primary_lock.parent is None else slugify(primary_lock.parent) + "" if primary_lock.parent_name is None else slugify(primary_lock.parent) ), } @@ -386,7 +386,7 @@ def generate_package_files(hass: HomeAssistant, name: str) -> None: "Package generation complete and all changes have been hot reloaded" ) reset_code_slot_if_pin_unknown(hass, lockname, code_slots, start_from) - if primary_lock.parent is not None: + if primary_lock.parent_name is not None: init_child_locks(hass, start_from, code_slots, lockname) else: create( diff --git a/custom_components/keymaster/text.py b/custom_components/keymaster/text.py index b48171ea..4ff24c62 100644 --- a/custom_components/keymaster/text.py +++ b/custom_components/keymaster/text.py @@ -27,7 +27,7 @@ async def async_setup_entry( start_from = config_entry.data[CONF_START] code_slots = config_entry.data[CONF_SLOTS] coordinator: KeymasterCoordinator = hass.data[DOMAIN][COORDINATOR] - lock: KeymasterLock = await coordinator.get_lock_by_config_entry_id( + kmlock: KeymasterLock = await coordinator.get_lock_by_config_entry_id( config_entry.entry_id ) entities: list = [] @@ -41,21 +41,19 @@ async def async_setup_entry( config_entry=config_entry, coordinator=coordinator, ), - initial_value=lock.lock_name, ) ) - if hasattr(lock, "parent") and lock.parent is not None: + if hasattr(kmlock, "parent_name") and kmlock.parent_name is not None: entities.append( KeymasterText( entity_description=KeymasterTextEntityDescription( - key="text.parent", + key="text.parent_name", name="Parent Lock", entity_registry_enabled_default=True, hass=hass, config_entry=config_entry, coordinator=coordinator, ), - initial_value=lock.parent, ) ) @@ -63,7 +61,7 @@ async def async_setup_entry( entities.append( KeymasterText( entity_description=KeymasterTextEntityDescription( - key=f"text.code_slot.name:{x}", + key=f"text.code_slots:{x}.name", name=f"Name {x}", entity_registry_enabled_default=True, hass=hass, @@ -75,7 +73,7 @@ async def async_setup_entry( entities.append( KeymasterText( entity_description=KeymasterTextEntityDescription( - key=f"text.code_slot.pin:{x}", + key=f"text.code_slots:{x}.pin", name=f"PIN {x}", mode=( TextMode.PASSWORD @@ -105,21 +103,33 @@ class KeymasterText(KeymasterEntity, TextEntity): def __init__( self, entity_description: KeymasterTextEntityDescription, - initial_value: str = None, ) -> None: """Initialize text.""" super().__init__( entity_description=entity_description, ) - self._attr_native_value: str = initial_value + self._attr_native_value: str = None @callback def _handle_coordinator_update(self) -> None: - _LOGGER.debug( - f"[Text handle_coordinator_update] self.coordinator.data: {self.coordinator.data}" - ) - + # _LOGGER.debug(f"[Text handle_coordinator_update] self.coordinator.data: {self.coordinator.data}") + if not self._kmlock.connected: + self._attr_available = False + self.async_write_ha_state() + return + + if ( + "code_slots" in self._property + and not self._kmlock.code_slots[self._code_slot].enabled + ): + self._attr_available = False + self.async_write_ha_state() + return + + self._attr_available = True + self._attr_native_value = self._get_property_value() self.async_write_ha_state() def set_value(self, value: str) -> None: + # TODO: Update kmlock and lock self._attr_native_value = value