"""Support for command line covers.""" from __future__ import annotations import asyncio from datetime import timedelta from typing import TYPE_CHECKING, Any import voluptuous as vol from homeassistant.components.cover import ( DOMAIN as COVER_DOMAIN, PLATFORM_SCHEMA, CoverEntity, ) from homeassistant.const import ( CONF_COMMAND_CLOSE, CONF_COMMAND_OPEN, CONF_COMMAND_STATE, CONF_COMMAND_STOP, CONF_COVERS, CONF_FRIENDLY_NAME, CONF_NAME, CONF_SCAN_INTERVAL, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template from homeassistant.helpers.template_entity import ManualTriggerEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util, slugify from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER from .utils import call_shell_with_timeout, check_output_or_log SCAN_INTERVAL = timedelta(seconds=15) COVER_SCHEMA = vol.Schema( { vol.Optional(CONF_COMMAND_CLOSE, default="true"): cv.string, vol.Optional(CONF_COMMAND_OPEN, default="true"): cv.string, vol.Optional(CONF_COMMAND_STATE): cv.string, vol.Optional(CONF_COMMAND_STOP, default="true"): cv.string, vol.Optional(CONF_FRIENDLY_NAME): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, vol.Optional(CONF_UNIQUE_ID): cv.string, } ) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( {vol.Required(CONF_COVERS): cv.schema_with_slug_keys(COVER_SCHEMA)} ) async def async_setup_platform( hass: HomeAssistant, config: ConfigType, async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up cover controlled by shell commands.""" covers = [] if discovery_info: entities: dict[str, Any] = {slugify(discovery_info[CONF_NAME]): discovery_info} else: async_create_issue( hass, DOMAIN, "deprecated_yaml_cover", breaks_in_ha_version="2023.12.0", is_fixable=False, severity=IssueSeverity.WARNING, translation_key="deprecated_platform_yaml", translation_placeholders={"platform": COVER_DOMAIN}, ) entities = config.get(CONF_COVERS, {}) for device_name, device_config in entities.items(): value_template: Template | None = device_config.get(CONF_VALUE_TEMPLATE) if value_template is not None: value_template.hass = hass if name := device_config.get( CONF_FRIENDLY_NAME ): # Backward compatibility. Can be removed after deprecation device_config[CONF_NAME] = name trigger_entity_config = { CONF_UNIQUE_ID: device_config.get(CONF_UNIQUE_ID), CONF_NAME: Template(device_config.get(CONF_NAME, device_name), hass), } covers.append( CommandCover( trigger_entity_config, device_config[CONF_COMMAND_OPEN], device_config[CONF_COMMAND_CLOSE], device_config[CONF_COMMAND_STOP], device_config.get(CONF_COMMAND_STATE), value_template, device_config[CONF_COMMAND_TIMEOUT], device_config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL), ) ) if not covers: LOGGER.error("No covers added") return async_add_entities(covers) class CommandCover(ManualTriggerEntity, CoverEntity): """Representation a command line cover.""" _attr_should_poll = False def __init__( self, config: ConfigType, command_open: str, command_close: str, command_stop: str, command_state: str | None, value_template: Template | None, timeout: int, scan_interval: timedelta, ) -> None: """Initialize the cover.""" super().__init__(self.hass, config) self._state: int | None = None self._command_open = command_open self._command_close = command_close self._command_stop = command_stop self._command_state = command_state self._value_template = value_template self._timeout = timeout self._scan_interval = scan_interval self._process_updates: asyncio.Lock | None = None async def async_added_to_hass(self) -> None: """Call when entity about to be added to hass.""" await super().async_added_to_hass() if self._command_state: self.async_on_remove( async_track_time_interval( self.hass, self._update_entity_state, self._scan_interval, name=f"Command Line Cover - {self.name}", cancel_on_shutdown=True, ), ) def _move_cover(self, command: str) -> bool: """Execute the actual commands.""" LOGGER.info("Running command: %s", command) returncode = call_shell_with_timeout(command, self._timeout) success = returncode == 0 if not success: LOGGER.error( "Command failed (with return code %s): %s", returncode, command ) return success @property def is_closed(self) -> bool | None: """Return if the cover is closed.""" if self.current_cover_position is not None: return self.current_cover_position == 0 return None @property def current_cover_position(self) -> int | None: """Return current position of cover. None is unknown, 0 is closed, 100 is fully open. """ return self._state def _query_state(self) -> str | None: """Query for the state.""" if self._command_state: LOGGER.info("Running state value command: %s", self._command_state) return check_output_or_log(self._command_state, self._timeout) if TYPE_CHECKING: return None async def _update_entity_state(self, now) -> None: """Update the state of the entity.""" if self._process_updates is None: self._process_updates = asyncio.Lock() if self._process_updates.locked(): LOGGER.warning( "Updating Command Line Cover %s took longer than the scheduled update interval %s", self.name, self._scan_interval, ) return async with self._process_updates: await self._async_update() async def _async_update(self) -> None: """Update device state.""" if self._command_state: payload = str(await self.hass.async_add_executor_job(self._query_state)) if self._value_template: payload = self._value_template.async_render_with_possible_json_value( payload, None ) self._state = None if payload: self._state = int(payload) self._process_manual_data(payload) self.async_write_ha_state() async def async_update(self) -> None: """Update the entity. Only used by the generic entity update service. """ await self._update_entity_state(dt_util.now()) async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" await self.hass.async_add_executor_job(self._move_cover, self._command_open) await self._update_entity_state(None) async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" await self.hass.async_add_executor_job(self._move_cover, self._command_close) await self._update_entity_state(None) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" await self.hass.async_add_executor_job(self._move_cover, self._command_stop) await self._update_entity_state(None)