diff --git a/README.md b/README.md index 17136b2..d970eee 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,10 @@ Convert your (dummy) `switch` into a `cover`, and allow to control its position. Additionally, if you interact with your physical switch, the position status will be updated as well. +**Optional:** If your cover uses a third button for stopping, you can also add it (normally your cover will stop once the up/down switch is turned off). + +**Experimental:** You can add `scripts` to enable custom action (eg. MQTT calls), for easy integration with other hardware. + ## Install [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=duhow&repository=hass-cover-time-based&category=integration) diff --git a/custom_components/cover_time_based/config_flow.py b/custom_components/cover_time_based/config_flow.py index b5f52e0..8c0cf4c 100644 --- a/custom_components/cover_time_based/config_flow.py +++ b/custom_components/cover_time_based/config_flow.py @@ -13,25 +13,27 @@ from homeassistant.helpers.schema_config_entry_flow import SchemaFlowFormStep from .const import CONF_ENTITY_DOWN +from .const import CONF_ENTITY_STOP from .const import CONF_ENTITY_UP from .const import CONF_TIME_CLOSE from .const import CONF_TIME_OPEN from .const import DOMAIN +DOMAIN_ENTITIES_ALLOWED = [Platform.SWITCH, Platform.LIGHT, Platform.BUTTON, "script"] + CONFIG_FLOW = { "user": SchemaFlowFormStep( vol.Schema( { vol.Required(CONF_NAME): selector.TextSelector(), vol.Required(CONF_ENTITY_UP): selector.EntitySelector( - selector.EntitySelectorConfig( - domain=[Platform.SWITCH, Platform.LIGHT] - ) + selector.EntitySelectorConfig(domain=DOMAIN_ENTITIES_ALLOWED) ), vol.Required(CONF_ENTITY_DOWN): selector.EntitySelector( - selector.EntitySelectorConfig( - domain=[Platform.SWITCH, Platform.LIGHT] - ) + selector.EntitySelectorConfig(domain=DOMAIN_ENTITIES_ALLOWED) + ), + vol.Optional(CONF_ENTITY_STOP): selector.EntitySelector( + selector.EntitySelectorConfig(domain=DOMAIN_ENTITIES_ALLOWED) ), vol.Required(CONF_TIME_OPEN, default=25): selector.NumberSelector( selector.NumberSelectorConfig( @@ -89,7 +91,7 @@ class CoverTimeBasedConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): options_flow = OPTIONS_FLOW VERSION = 1 - MINOR_VERSION = 2 + MINOR_VERSION = 3 def async_config_entry_title(self, options: Mapping[str, Any]) -> str: """Return config entry title and hide the wrapped entity if @@ -97,7 +99,9 @@ def async_config_entry_title(self, options: Mapping[str, Any]) -> str: # Hide the wrapped entry if registered registry = er.async_get(self.hass) - for entity in [CONF_ENTITY_UP, CONF_ENTITY_DOWN]: + for entity in [CONF_ENTITY_UP, CONF_ENTITY_DOWN, CONF_ENTITY_STOP]: + if not options.get(entity): # stop is optional + continue entity_entry = registry.async_get(options[entity]) if entity_entry is not None and not entity_entry.hidden: registry.async_update_entity( diff --git a/custom_components/cover_time_based/const.py b/custom_components/cover_time_based/const.py index fef5461..557129f 100644 --- a/custom_components/cover_time_based/const.py +++ b/custom_components/cover_time_based/const.py @@ -5,5 +5,6 @@ CONF_ENTITY_UP: Final = "up" CONF_ENTITY_DOWN: Final = "down" +CONF_ENTITY_STOP: Final = "stop" CONF_TIME_OPEN: Final = "time_open" CONF_TIME_CLOSE: Final = "time_close" diff --git a/custom_components/cover_time_based/cover.py b/custom_components/cover_time_based/cover.py index f7aabe4..1bc44a4 100644 --- a/custom_components/cover_time_based/cover.py +++ b/custom_components/cover_time_based/cover.py @@ -11,6 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID from homeassistant.const import EVENT_STATE_CHANGED +from homeassistant.const import Platform from homeassistant.const import SERVICE_CLOSE_COVER from homeassistant.const import SERVICE_OPEN_COVER from homeassistant.const import SERVICE_STOP_COVER @@ -29,6 +30,7 @@ from homeassistant.util import slugify from .const import CONF_ENTITY_DOWN +from .const import CONF_ENTITY_STOP from .const import CONF_ENTITY_UP from .const import CONF_TIME_CLOSE from .const import CONF_TIME_OPEN @@ -80,6 +82,11 @@ async def async_setup_entry( entity_down = er.async_validate_entity_id( registry, config_entry.options[CONF_ENTITY_DOWN] ) + entity_stop = None + if config_entry.options.get(CONF_ENTITY_STOP): + entity_stop = er.async_validate_entity_id( + registry, config_entry.options[CONF_ENTITY_STOP] + ) async_add_entities( [ @@ -90,6 +97,7 @@ async def async_setup_entry( config_entry.options[CONF_TIME_OPEN], entity_up, entity_down, + entity_stop, ) ] ) @@ -104,6 +112,7 @@ def __init__( travel_time_up, open_switch_entity_id, close_switch_entity_id, + stop_switch_entity_id=None, ): """Initialize the cover.""" if not travel_time_down: @@ -114,6 +123,8 @@ def __init__( self._open_switch_entity_id = open_switch_entity_id self._close_switch_state = STATE_OFF self._close_switch_entity_id = close_switch_entity_id + self._stop_switch_state = STATE_OFF + self._stop_switch_entity_id = stop_switch_entity_id self._name = name self._attr_unique_id = unique_id @@ -142,6 +153,7 @@ async def _handle_state_changed(self, event): if event.data.get(ATTR_ENTITY_ID) not in [ self._close_switch_entity_id, self._open_switch_entity_id, + self._stop_switch_entity_id, ]: return @@ -154,6 +166,13 @@ async def _handle_state_changed(self, event): if event.data.get("new_state").state == event.data.get("old_state").state: return + # avoid loop + if event.data.get(ATTR_ENTITY_ID).startswith("script."): + return + + if event.data.get(ATTR_ENTITY_ID).startswith(f"{Platform.BUTTON}."): + return + # Target switch/light if event.data.get(ATTR_ENTITY_ID) == self._close_switch_entity_id: if self._close_switch_state == event.data.get("new_state").state: @@ -163,6 +182,13 @@ async def _handle_state_changed(self, event): if self._open_switch_state == event.data.get("new_state").state: return self._open_switch_state = event.data.get("new_state").state + elif ( + self.has_stop_entity + and event.data.get(ATTR_ENTITY_ID) == self.stop_switch_entity_id + ): + if self._stop_switch_state == event.data.get("new_state").state: + return + self._stop_switch_state = event.data.get("new_state").state # Set unavailable if any of the switches becomes unavailable self._attr_available = not any( @@ -248,6 +274,11 @@ def assumed_state(self): """Return True because covers can be stopped midway.""" return True + @property + def has_stop_entity(self) -> bool: + """Check if there is a third input used to stop the cover.""" + return self._stop_switch_entity_id is not None + async def check_availability(self) -> None: """Check if any of the entities is unavailable and update status.""" for entity in [self._close_switch_entity_id, self._open_switch_entity_id]: @@ -357,51 +388,44 @@ async def auto_stop_if_necessary(self): await self._async_handle_command(SERVICE_STOP_COVER) self.tc.stop() + async def set_entity(self, state: str, entity_id, wait=False): + if state not in [STATE_ON, STATE_OFF]: + raise Exception(f"calling set_entity with wrong state {state}") + + domain = "homeassistant" + action = f"turn_{state}" + + if entity_id.startswith(Platform.BUTTON): + domain = "input_button" + action = "press" + elif entity_id.startswith("script"): + domain = "script" + + return await self.hass.services.async_call( + domain, action, {"entity_id": entity_id}, wait + ) + async def _async_handle_command(self, command, *args): if command == SERVICE_CLOSE_COVER: self._state = False - await self.hass.services.async_call( - "homeassistant", - "turn_off", - {"entity_id": self._open_switch_entity_id}, - False, - ) - await self.hass.services.async_call( - "homeassistant", - "turn_on", - {"entity_id": self._close_switch_entity_id}, - True, - ) + if self.has_stop_entity: + await self.set_entity(STATE_OFF, self._stop_switch_entity_id) + await self.set_entity(STATE_OFF, self._open_switch_entity_id) + await self.set_entity(STATE_ON, self._close_switch_entity_id, True) elif command == SERVICE_OPEN_COVER: self._state = True - await self.hass.services.async_call( - "homeassistant", - "turn_off", - {"entity_id": self._close_switch_entity_id}, - False, - ) - await self.hass.services.async_call( - "homeassistant", - "turn_on", - {"entity_id": self._open_switch_entity_id}, - True, - ) + if self.has_stop_entity: + await self.set_entity(STATE_OFF, self._stop_switch_entity_id) + await self.set_entity(STATE_OFF, self._close_switch_entity_id) + await self.set_entity(STATE_ON, self._open_switch_entity_id, True) elif command == SERVICE_STOP_COVER: self._state = True - await self.hass.services.async_call( - "homeassistant", - "turn_off", - {"entity_id": self._close_switch_entity_id}, - False, - ) - await self.hass.services.async_call( - "homeassistant", - "turn_off", - {"entity_id": self._open_switch_entity_id}, - False, - ) + await self.set_entity(STATE_OFF, self._close_switch_entity_id) + await self.set_entity(STATE_OFF, self._open_switch_entity_id) + if self.has_stop_entity: + await self.set_entity(STATE_ON, self._stop_switch_entity_id, True) _LOGGER.debug("_async_handle_command :: %s", command) diff --git a/custom_components/cover_time_based/manifest.json b/custom_components/cover_time_based/manifest.json index 671c2b7..8579a0d 100644 --- a/custom_components/cover_time_based/manifest.json +++ b/custom_components/cover_time_based/manifest.json @@ -9,5 +9,5 @@ "integration_type": "helper", "iot_class": "calculated", "issue_tracker": "https://github.com/duhow/hass-cover-time-based/issues", - "version": "0.1.2" + "version": "0.2.0" } diff --git a/custom_components/cover_time_based/translations/ca.json b/custom_components/cover_time_based/translations/ca.json index 84b0d05..5739d20 100644 --- a/custom_components/cover_time_based/translations/ca.json +++ b/custom_components/cover_time_based/translations/ca.json @@ -9,13 +9,15 @@ "name": "Nom", "up": "Pujar", "down": "Baixar", + "stop": "Aturar (opcional)", "time_open": "Temps per obrir la persiana", "time_close": "Temps per tancar la persiana (opcional)" }, "data_description": { "name": "Nom de la nova persiana a crear.", "up": "Entitat que farà l'acció de pujar.", - "down": "Entitat que farà l'acció de baixar." + "down": "Entitat que farà l'acció de baixar.", + "stop": "Entitat que farà l'acció d'aturar el moviment." } } } diff --git a/custom_components/cover_time_based/translations/en.json b/custom_components/cover_time_based/translations/en.json index 7ac8361..90c0c11 100644 --- a/custom_components/cover_time_based/translations/en.json +++ b/custom_components/cover_time_based/translations/en.json @@ -9,13 +9,15 @@ "name": "Name", "up": "Up", "down": "Down", + "stop": "Stop (optional)", "time_open": "Time to open the cover", "time_close": "Time to close the cover (optional)" }, "data_description": { "name": "Name of the new cover to create.", "up": "Entity that will open the cover.", - "down": "Entity that will close the cover." + "down": "Entity that will close the cover.", + "stop": "Entity that will stop the cover movement." } } } diff --git a/custom_components/cover_time_based/translations/es.json b/custom_components/cover_time_based/translations/es.json index 1bb030a..c25f641 100644 --- a/custom_components/cover_time_based/translations/es.json +++ b/custom_components/cover_time_based/translations/es.json @@ -9,13 +9,15 @@ "name": "Nombre", "up": "Subir", "down": "Bajar", + "stop": "Parar movimiento (opcional)", "time_open": "Tiempo para abrir la persiana", "time_close": "Tiempo para cerrar la persiana (opcional)" }, "data_description": { "name": "Nombre de la nueva persiana a crear.", "up": "Entidad que realiza la acción de subir.", - "down": "Entidad que realiza la acción de bajar." + "down": "Entidad que realiza la acción de bajar.", + "stop": "Entidad que realiza la acción de parar el movimiento." } } }