"""Generic Z-Wave Entity Classes.""" import copy import logging from openzwavemqtt.const import ( EVENT_INSTANCE_STATUS_CHANGED, EVENT_VALUE_CHANGED, OZW_READY_STATES, CommandClass, ValueIndex, ) from openzwavemqtt.models.node import OZWNode from openzwavemqtt.models.value import OZWValue from homeassistant.const import ATTR_NAME, ATTR_SW_VERSION, ATTR_VIA_DEVICE from homeassistant.core import callback from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) from homeassistant.helpers.entity import DeviceInfo, Entity from . import const from .const import DOMAIN, PLATFORMS from .discovery import check_node_schema, check_value_schema _LOGGER = logging.getLogger(__name__) OZW_READY_STATES_VALUES = {st.value for st in OZW_READY_STATES} class ZWaveDeviceEntityValues: """Manages entity access to the underlying Z-Wave value objects.""" def __init__(self, hass, options, schema, primary_value): """Initialize the values object with the passed entity schema.""" self._hass = hass self._entity_created = False self._schema = copy.deepcopy(schema) self._values = {} self.options = options # Go through values listed in the discovery schema, initialize them, # and add a check to the schema to make sure the Instance matches. for name, disc_settings in self._schema[const.DISC_VALUES].items(): self._values[name] = None disc_settings[const.DISC_INSTANCE] = (primary_value.instance,) self._values[const.DISC_PRIMARY] = primary_value self._node = primary_value.node self._schema[const.DISC_NODE_ID] = [self._node.node_id] def async_setup(self): """Set up values instance.""" # Check values that have already been discovered for node # and see if they match the schema and need added to the entity. for value in self._node.values(): self.async_check_value(value) # Check if all the _required_ values in the schema are present and # create the entity. self._async_check_entity_ready() def __getattr__(self, name): """Get the specified value for this entity.""" return self._values.get(name, None) def __iter__(self): """Allow iteration over all values.""" return iter(self._values.values()) def __contains__(self, name): """Check if the specified name/key exists in the values.""" return name in self._values @callback def async_check_value(self, value): """Check if the new value matches a missing value for this entity. If a match is found, it is added to the values mapping. """ # Make sure the node matches the schema for this entity. if not check_node_schema(value.node, self._schema): return # Go through the possible values for this entity defined by the schema. for name, name_value in self._values.items(): # Skip if it's already been added. if name_value is not None: continue # Skip if the value doesn't match the schema. if not check_value_schema(value, self._schema[const.DISC_VALUES][name]): continue # Add value to mapping. self._values[name] = value # If the entity has already been created, notify it of the new value. if self._entity_created: async_dispatcher_send( self._hass, f"{DOMAIN}_{self.values_id}_value_added" ) # Check if entity has all required values and create the entity if needed. self._async_check_entity_ready() @callback def _async_check_entity_ready(self): """Check if all required values are discovered and create entity.""" # Abort if the entity has already been created if self._entity_created: return # Go through values defined in the schema and abort if a required value is missing. for name, disc_settings in self._schema[const.DISC_VALUES].items(): if self._values[name] is None and not disc_settings.get( const.DISC_OPTIONAL ): return # We have all the required values, so create the entity. component = self._schema[const.DISC_COMPONENT] _LOGGER.debug( "Adding Node_id=%s Generic_command_class=%s, " "Specific_command_class=%s, " "Command_class=%s, Index=%s, Value type=%s, " "Genre=%s as %s", self._node.node_id, self._node.node_generic, self._node.node_specific, self.primary.command_class, self.primary.index, self.primary.type, self.primary.genre, component, ) self._entity_created = True if component in PLATFORMS: async_dispatcher_send(self._hass, f"{DOMAIN}_new_{component}", self) @property def values_id(self): """Identification for this values collection.""" return create_value_id(self.primary) class ZWaveDeviceEntity(Entity): """Generic Entity Class for a Z-Wave Device.""" def __init__(self, values): """Initialize a generic Z-Wave device entity.""" self.values = values self.options = values.options @callback def on_value_update(self): """Call when a value is added/updated in the entity EntityValues Collection. To be overridden by platforms needing this event. """ async def async_added_to_hass(self): """Call when entity is added.""" # Add dispatcher and OZW listeners callbacks. # Add to on_remove so they will be cleaned up on entity removal. self.async_on_remove( self.options.listen(EVENT_VALUE_CHANGED, self._value_changed) ) self.async_on_remove( self.options.listen(EVENT_INSTANCE_STATUS_CHANGED, self._instance_updated) ) self.async_on_remove( async_dispatcher_connect( self.hass, const.SIGNAL_DELETE_ENTITY, self._delete_callback ) ) self.async_on_remove( async_dispatcher_connect( self.hass, f"{DOMAIN}_{self.values.values_id}_value_added", self._value_added, ) ) @property def device_info(self) -> DeviceInfo: """Return device information for the device registry.""" node = self.values.primary.node node_instance = self.values.primary.instance dev_id = create_device_id(node, self.values.primary.instance) node_firmware = node.get_value( CommandClass.VERSION, ValueIndex.VERSION_APPLICATION ) device_info = DeviceInfo( identifiers={(DOMAIN, dev_id)}, name=create_device_name(node), manufacturer=node.node_manufacturer_name, model=node.node_product_name, ) if node_firmware is not None: device_info[ATTR_SW_VERSION] = node_firmware.value # device with multiple instances is split up into virtual devices for each instance if node_instance > 1: parent_dev_id = create_device_id(node) device_info[ATTR_NAME] += f" - Instance {node_instance}" device_info[ATTR_VIA_DEVICE] = (DOMAIN, parent_dev_id) return device_info @property def extra_state_attributes(self): """Return the device specific state attributes.""" return {const.ATTR_NODE_ID: self.values.primary.node.node_id} @property def name(self): """Return the name of the entity.""" node = self.values.primary.node return f"{create_device_name(node)}: {self.values.primary.label}" @property def unique_id(self): """Return the unique_id of the entity.""" return self.values.values_id @property def available(self) -> bool: """Return entity availability.""" # Use OZW Daemon status for availability. instance_status = self.values.primary.ozw_instance.get_status() return instance_status and instance_status.status in OZW_READY_STATES_VALUES @callback def _value_changed(self, value): """Call when a value from ZWaveDeviceEntityValues is changed. Should not be overridden by subclasses. """ if value.value_id_key in (v.value_id_key for v in self.values if v): self.on_value_update() self.async_write_ha_state() @callback def _value_added(self): """Call when a value from ZWaveDeviceEntityValues is added. Should not be overridden by subclasses. """ self.on_value_update() @callback def _instance_updated(self, new_status): """Call when the instance status changes. Should not be overridden by subclasses. """ self.on_value_update() self.async_write_ha_state() @property def should_poll(self): """No polling needed.""" return False async def _delete_callback(self, values_id): """Remove this entity.""" if not self.values: return # race condition: delete already requested if values_id == self.values.values_id: await self.async_remove(force_remove=True) def create_device_name(node: OZWNode): """Generate sensible (short) default device name from a OZWNode.""" # Prefer custom name set by OZWAdmin if present if node.node_name: return node.node_name # Prefer short devicename from metadata if present if node.meta_data and node.meta_data.get("Name"): return node.meta_data["Name"] # Fallback to productname or devicetype strings if node.node_product_name: return node.node_product_name if node.node_device_type_string: return node.node_device_type_string if node.node_specific_string: return node.node_specific_string # Last resort: use Node id (should never happen, but just in case) return f"Node {node.id}" def create_device_id(node: OZWNode, node_instance: int = 1): """Generate unique device_id from a OZWNode.""" ozw_instance = node.parent.id dev_id = f"{ozw_instance}.{node.node_id}.{node_instance}" return dev_id def create_value_id(value: OZWValue): """Generate unique value_id from an OZWValue.""" # [OZW_INSTANCE_ID]-[NODE_ID]-[VALUE_ID_KEY] return f"{value.node.parent.id}-{value.node.id}-{value.value_id_key}"