"""Spook - Your homie.""" from __future__ import annotations from abc import abstractmethod from enum import IntFlag from typing import TYPE_CHECKING from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_ICON, ATTR_SUPPORTED_FEATURES, CONF_ENTITY_ID, STATE_UNAVAILABLE, ) from homeassistant.core import Event, HomeAssistant, State, callback, split_entity_id from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import ( EventStateChangedData, async_track_state_change_event, ) from homeassistant.helpers.start import async_at_start if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry def swapped_features[FeatureT: IntFlag]( features: FeatureT, one: FeatureT, other: FeatureT, ) -> FeatureT: """Return the features with these two traded places. Something that can only be opened is, upside down, something that can only be closed. """ swapped = features & ~(one | other) if features & one: swapped |= other if features & other: swapped |= one return swapped class InverseEntity(Entity): # pylint: disable=too-many-instance-attributes """Inverse entity.""" _attr_available = False _attr_should_poll = False def __init__( self, hass: HomeAssistant, config_entry: ConfigEntry, ) -> None: """Initialize an inverse entity.""" super().__init__() # The source can be stored as its entity registry ID, which the state # machine knows nothing about. Following that would leave the inverse # unavailable next to a source that is perfectly fine. source = config_entry.options[CONF_ENTITY_ID] self._entity_id = ( er.async_resolve_entity_id(er.async_get(hass), source) or source ) self._attr_name = config_entry.title self._attr_extra_state_attributes = {ATTR_ENTITY_ID: self._entity_id} self._attr_unique_id = config_entry.entry_id self.hass = hass self.config_entry = config_entry entity_registry = er.async_get(self.hass) device_registry = dr.async_get(self.hass) source_entity = entity_registry.async_get(self._entity_id) device_id = source_entity.device_id if source_entity else None if device_id and (device := device_registry.async_get(device_id)): self.device_entry = device async def async_added_to_hass(self) -> None: """Register callbacks.""" self.async_on_remove( async_track_state_change_event( self.hass, self._entity_id, self.async_update_and_write_state, ), ) async def async_update_at_start(_: HomeAssistant) -> None: """Update the state at startup.""" self.async_update_and_write_state() self.async_on_remove(async_at_start(self.hass, async_update_at_start)) await super().async_added_to_hass() @callback def async_update_and_write_state( self, event: Event[EventStateChangedData] | None = None, ) -> None: """Update the state and write it to the entity.""" if not self.hass.is_running: return if event is not None: self.async_set_context(event.context) if ( state := self.hass.states.get(self._entity_id) ) is None or state.state == STATE_UNAVAILABLE: # Written down as well, or the inverse goes on showing what it was # before its source went, as if nothing happened. self._attr_available = False self.async_write_ha_state() return self._attr_available = True self._attr_icon = state.attributes.get(ATTR_ICON) # Only from a source of its own kind. A light's features are not a # switch's, and a device class means something else for every domain: # taken across, they claim things this entity cannot do or be. if split_entity_id(self._entity_id)[0] == split_entity_id(self.entity_id)[0]: self._attr_supported_features = state.attributes.get( ATTR_SUPPORTED_FEATURES ) self._attr_device_class = state.attributes.get(ATTR_DEVICE_CLASS) else: self._attr_supported_features = None self._attr_device_class = None self.async_update_state(state) state_attributes = { **self._attr_extra_state_attributes, ATTR_ENTITY_ID: self._entity_id, } state_attributes.pop(ATTR_ICON, None) state_attributes.pop(ATTR_DEVICE_CLASS, None) state_attributes.pop(ATTR_SUPPORTED_FEATURES, None) self._attr_extra_state_attributes = state_attributes self.async_write_ha_state() @abstractmethod @callback def async_update_state(self, state: State) -> None: """Query the source and determine the entity state."""