From 038b0e6d23db2e17c20bf040804319a698fa3657 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Jun 2023 05:35:11 +0200 Subject: [PATCH] Add scan interval to Command Line (#93752) * Add scan interval * Handle previous not complete * Fix faulty text * Add tests * lingering * Cool down * Fix tests --- .../components/command_line/__init__.py | 25 ++++++- .../components/command_line/binary_sensor.py | 47 +++++++++++- .../components/command_line/const.py | 4 ++ .../components/command_line/cover.py | 71 +++++++++++++++---- .../components/command_line/sensor.py | 61 ++++++++++++---- .../components/command_line/switch.py | 60 +++++++++++++--- .../command_line/test_binary_sensor.py | 63 ++++++++++++++++ tests/components/command_line/test_cover.py | 58 +++++++++++++++ tests/components/command_line/test_sensor.py | 56 +++++++++++++++ tests/components/command_line/test_switch.py | 59 +++++++++++++++ 10 files changed, 463 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/command_line/__init__.py b/homeassistant/components/command_line/__init__.py index 651094db7f1..c9c18fe54a8 100644 --- a/homeassistant/components/command_line/__init__.py +++ b/homeassistant/components/command_line/__init__.py @@ -11,16 +11,24 @@ import voluptuous as vol from homeassistant.components.binary_sensor import ( DEVICE_CLASSES_SCHEMA as BINARY_SENSOR_DEVICE_CLASSES_SCHEMA, DOMAIN as BINARY_SENSOR_DOMAIN, + SCAN_INTERVAL as BINARY_SENSOR_DEFAULT_SCAN_INTERVAL, +) +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + SCAN_INTERVAL as COVER_DEFAULT_SCAN_INTERVAL, ) -from homeassistant.components.cover import DOMAIN as COVER_DOMAIN from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.components.sensor import ( CONF_STATE_CLASS, DEVICE_CLASSES_SCHEMA as SENSOR_DEVICE_CLASSES_SCHEMA, DOMAIN as SENSOR_DOMAIN, + SCAN_INTERVAL as SENSOR_DEFAULT_SCAN_INTERVAL, STATE_CLASSES_SCHEMA as SENSOR_STATE_CLASSES_SCHEMA, ) -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SCAN_INTERVAL as SWITCH_DEFAULT_SCAN_INTERVAL, +) from homeassistant.const import ( CONF_COMMAND, CONF_COMMAND_CLOSE, @@ -34,6 +42,7 @@ from homeassistant.const import ( CONF_NAME, CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON, + CONF_SCAN_INTERVAL, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, @@ -74,6 +83,9 @@ BINARY_SENSOR_SCHEMA = vol.Schema( 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, + vol.Optional( + CONF_SCAN_INTERVAL, default=BINARY_SENSOR_DEFAULT_SCAN_INTERVAL + ): vol.All(cv.time_period, cv.positive_timedelta), } ) COVER_SCHEMA = vol.Schema( @@ -86,6 +98,9 @@ COVER_SCHEMA = vol.Schema( 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, + vol.Optional(CONF_SCAN_INTERVAL, default=COVER_DEFAULT_SCAN_INTERVAL): vol.All( + cv.time_period, cv.positive_timedelta + ), } ) NOTIFY_SCHEMA = vol.Schema( @@ -106,6 +121,9 @@ SENSOR_SCHEMA = vol.Schema( vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_STATE_CLASS): SENSOR_STATE_CLASSES_SCHEMA, + vol.Optional(CONF_SCAN_INTERVAL, default=SENSOR_DEFAULT_SCAN_INTERVAL): vol.All( + cv.time_period, cv.positive_timedelta + ), } ) SWITCH_SCHEMA = vol.Schema( @@ -118,6 +136,9 @@ SWITCH_SCHEMA = vol.Schema( vol.Optional(CONF_ICON): cv.template, vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=SWITCH_DEFAULT_SCAN_INTERVAL): vol.All( + cv.time_period, cv.positive_timedelta + ), } ) COMBINED_SCHEMA = vol.Schema( diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py index 18b3cf71eb0..9c5a1ce1bbe 100644 --- a/homeassistant/components/command_line/binary_sensor.py +++ b/homeassistant/components/command_line/binary_sensor.py @@ -1,6 +1,7 @@ """Support for custom shell commands to retrieve values.""" from __future__ import annotations +import asyncio from datetime import timedelta import voluptuous as vol @@ -18,17 +19,19 @@ from homeassistant.const import ( CONF_NAME, CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON, + 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.typing import ConfigType, DiscoveryInfoType -from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER from .sensor import CommandSensorData DEFAULT_NAME = "Binary Command Sensor" @@ -84,6 +87,9 @@ async def async_setup_platform( value_template: Template | None = binary_sensor_config.get(CONF_VALUE_TEMPLATE) command_timeout: int = binary_sensor_config[CONF_COMMAND_TIMEOUT] unique_id: str | None = binary_sensor_config.get(CONF_UNIQUE_ID) + scan_interval: timedelta = binary_sensor_config.get( + CONF_SCAN_INTERVAL, SCAN_INTERVAL + ) if value_template is not None: value_template.hass = hass data = CommandSensorData(hass, command, command_timeout) @@ -98,6 +104,7 @@ async def async_setup_platform( payload_off, value_template, unique_id, + scan_interval, ) ], True, @@ -107,6 +114,8 @@ async def async_setup_platform( class CommandBinarySensor(BinarySensorEntity): """Representation of a command line binary sensor.""" + _attr_should_poll = False + def __init__( self, data: CommandSensorData, @@ -116,6 +125,7 @@ class CommandBinarySensor(BinarySensorEntity): payload_off: str, value_template: Template | None, unique_id: str | None, + scan_interval: timedelta, ) -> None: """Initialize the Command line binary sensor.""" self.data = data @@ -126,8 +136,39 @@ class CommandBinarySensor(BinarySensorEntity): self._payload_off = payload_off self._value_template = value_template self._attr_unique_id = unique_id + self._scan_interval = scan_interval + self._process_updates: asyncio.Lock | None = None - async def async_update(self) -> None: + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + await self._update_entity_state(None) + self.async_on_remove( + async_track_time_interval( + self.hass, + self._update_entity_state, + self._scan_interval, + name=f"Command Line Binary Sensor - {self.name}", + cancel_on_shutdown=True, + ), + ) + + 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 Binary Sensor %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: """Get the latest data and updates the state.""" await self.hass.async_add_executor_job(self.data.update) value = self.data.value @@ -141,3 +182,5 @@ class CommandBinarySensor(BinarySensorEntity): self._attr_is_on = True elif value == self._payload_off: self._attr_is_on = False + + self.async_write_ha_state() diff --git a/homeassistant/components/command_line/const.py b/homeassistant/components/command_line/const.py index 4394f388910..ff51cb7e331 100644 --- a/homeassistant/components/command_line/const.py +++ b/homeassistant/components/command_line/const.py @@ -1,7 +1,11 @@ """Allows to configure custom shell commands to turn a value for a sensor.""" +import logging + from homeassistant.const import Platform +LOGGER = logging.getLogger(__package__) + CONF_COMMAND_TIMEOUT = "command_timeout" DEFAULT_TIMEOUT = 15 DOMAIN = "command_line" diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py index 4503ceb8e56..2d2dc8c5fc2 100644 --- a/homeassistant/components/command_line/cover.py +++ b/homeassistant/components/command_line/cover.py @@ -1,7 +1,8 @@ """Support for command line covers.""" from __future__ import annotations -import logging +import asyncio +from datetime import timedelta from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -19,21 +20,23 @@ from homeassistant.const import ( 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.typing import ConfigType, DiscoveryInfoType from homeassistant.util import slugify -from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER from .utils import call_shell_with_timeout, check_output_or_log -_LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(seconds=15) COVER_SCHEMA = vol.Schema( { @@ -97,11 +100,12 @@ async def async_setup_platform( value_template, device_config[CONF_COMMAND_TIMEOUT], device_config.get(CONF_UNIQUE_ID), + device_config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL), ) ) if not covers: - _LOGGER.error("No covers added") + LOGGER.error("No covers added") return async_add_entities(covers) @@ -110,6 +114,8 @@ async def async_setup_platform( class CommandCover(CoverEntity): """Representation a command line cover.""" + _attr_should_poll = False + def __init__( self, name: str, @@ -120,6 +126,7 @@ class CommandCover(CoverEntity): value_template: Template | None, timeout: int, unique_id: str | None, + scan_interval: timedelta, ) -> None: """Initialize the cover.""" self._attr_name = name @@ -131,17 +138,32 @@ class CommandCover(CoverEntity): self._value_template = value_template self._timeout = timeout self._attr_unique_id = unique_id - self._attr_should_poll = bool(command_state) + 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) + LOGGER.info("Running command: %s", command) returncode = call_shell_with_timeout(command, self._timeout) success = returncode == 0 if not success: - _LOGGER.error( + LOGGER.error( "Command failed (with return code %s): %s", returncode, command ) @@ -165,12 +187,27 @@ class CommandCover(CoverEntity): def _query_state(self) -> str | None: """Query for the state.""" if self._command_state: - _LOGGER.info("Running state value command: %s", 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 async_update(self) -> 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)) @@ -181,15 +218,19 @@ class CommandCover(CoverEntity): self._state = None if payload: self._state = int(payload) + await self.async_update_ha_state(True) - def open_cover(self, **kwargs: Any) -> None: + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - self._move_cover(self._command_open) + await self.hass.async_add_executor_job(self._move_cover, self._command_open) + await self._update_entity_state(None) - def close_cover(self, **kwargs: Any) -> None: + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - self._move_cover(self._command_close) + await self.hass.async_add_executor_job(self._move_cover, self._command_close) + await self._update_entity_state(None) - def stop_cover(self, **kwargs: Any) -> None: + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - self._move_cover(self._command_stop) + await self.hass.async_add_executor_job(self._move_cover, self._command_stop) + await self._update_entity_state(None) diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index 1689b136f2f..f42ac062081 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -1,10 +1,10 @@ """Allows to configure custom shell commands to turn a value for a sensor.""" from __future__ import annotations +import asyncio from collections.abc import Mapping from datetime import timedelta import json -import logging import voluptuous as vol @@ -20,6 +20,7 @@ from homeassistant.const import ( CONF_COMMAND, CONF_DEVICE_CLASS, CONF_NAME, + CONF_SCAN_INTERVAL, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, @@ -28,15 +29,14 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError 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.typing import ConfigType, DiscoveryInfoType -from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER from .utils import check_output_or_log -_LOGGER = logging.getLogger(__name__) - CONF_JSON_ATTRIBUTES = "json_attributes" DEFAULT_NAME = "Command Sensor" @@ -88,6 +88,7 @@ async def async_setup_platform( if value_template is not None: value_template.hass = hass json_attributes: list[str] | None = sensor_config.get(CONF_JSON_ATTRIBUTES) + scan_interval: timedelta = sensor_config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) data = CommandSensorData(hass, command, command_timeout) async_add_entities( @@ -99,15 +100,17 @@ async def async_setup_platform( value_template, json_attributes, unique_id, + scan_interval, ) - ], - True, + ] ) class CommandSensor(SensorEntity): """Representation of a sensor that is using shell commands.""" + _attr_should_poll = False + def __init__( self, data: CommandSensorData, @@ -116,6 +119,7 @@ class CommandSensor(SensorEntity): value_template: Template | None, json_attributes: list[str] | None, unique_id: str | None, + scan_interval: timedelta, ) -> None: """Initialize the sensor.""" self._attr_name = name @@ -126,8 +130,39 @@ class CommandSensor(SensorEntity): self._value_template = value_template self._attr_native_unit_of_measurement = unit_of_measurement self._attr_unique_id = unique_id + self._scan_interval = scan_interval + self._process_updates: asyncio.Lock | None = None - async def async_update(self) -> None: + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + await self._update_entity_state(None) + self.async_on_remove( + async_track_time_interval( + self.hass, + self._update_entity_state, + self._scan_interval, + name=f"Command Line Sensor - {self.name}", + cancel_on_shutdown=True, + ), + ) + + 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 Sensor %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: """Get the latest data and updates the state.""" await self.hass.async_add_executor_job(self.data.update) value = self.data.value @@ -144,11 +179,11 @@ class CommandSensor(SensorEntity): if k in json_dict } else: - _LOGGER.warning("JSON result was not a dictionary") + LOGGER.warning("JSON result was not a dictionary") except ValueError: - _LOGGER.warning("Unable to parse output as JSON: %s", value) + LOGGER.warning("Unable to parse output as JSON: %s", value) else: - _LOGGER.warning("Empty reply found when expecting JSON data") + LOGGER.warning("Empty reply found when expecting JSON data") if self._value_template is None: self._attr_native_value = None return @@ -163,6 +198,8 @@ class CommandSensor(SensorEntity): else: self._attr_native_value = value + self.async_write_ha_state() + class CommandSensorData: """The class for handling the data retrieval.""" @@ -191,7 +228,7 @@ class CommandSensorData: args_to_render = {"arguments": args} rendered_args = args_compiled.render(args_to_render) except TemplateError as ex: - _LOGGER.exception("Error rendering command template: %s", ex) + LOGGER.exception("Error rendering command template: %s", ex) return else: rendered_args = None @@ -203,5 +240,5 @@ class CommandSensorData: # Template used. Construct the string used in the shell command = f"{prog} {rendered_args}" - _LOGGER.debug("Running command: %s", command) + LOGGER.debug("Running command: %s", command) self.value = check_output_or_log(command, self.timeout) diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py index 7936bacd432..1a3dd39a342 100644 --- a/homeassistant/components/command_line/switch.py +++ b/homeassistant/components/command_line/switch.py @@ -1,7 +1,8 @@ """Support for custom shell commands to turn a switch on/off.""" from __future__ import annotations -import logging +import asyncio +from datetime import timedelta from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -20,6 +21,7 @@ from homeassistant.const import ( CONF_ICON, CONF_ICON_TEMPLATE, CONF_NAME, + CONF_SCAN_INTERVAL, CONF_SWITCHES, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, @@ -27,16 +29,17 @@ from homeassistant.const import ( 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 slugify -from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER from .utils import call_shell_with_timeout, check_output_or_log -_LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(seconds=30) SWITCH_SCHEMA = vol.Schema( { @@ -112,11 +115,12 @@ async def async_setup_platform( device_config.get(CONF_COMMAND_STATE), value_template, device_config[CONF_COMMAND_TIMEOUT], + device_config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL), ) ) if not switches: - _LOGGER.error("No switches added") + LOGGER.error("No switches added") return async_add_entities(switches) @@ -125,6 +129,8 @@ async def async_setup_platform( class CommandSwitch(ManualTriggerEntity, SwitchEntity): """Representation a switch that can be toggled using shell commands.""" + _attr_should_poll = False + def __init__( self, config: ConfigType, @@ -134,6 +140,7 @@ class CommandSwitch(ManualTriggerEntity, SwitchEntity): command_state: str | None, value_template: Template | None, timeout: int, + scan_interval: timedelta, ) -> None: """Initialize the switch.""" super().__init__(self.hass, config) @@ -144,11 +151,26 @@ class CommandSwitch(ManualTriggerEntity, SwitchEntity): self._command_state = command_state self._value_template = value_template self._timeout = timeout - self._attr_should_poll = bool(command_state) + 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, + ), + ) async def _switch(self, command: str) -> bool: """Execute the actual commands.""" - _LOGGER.info("Running command: %s", command) + LOGGER.info("Running command: %s", command) success = ( await self.hass.async_add_executor_job( @@ -158,18 +180,18 @@ class CommandSwitch(ManualTriggerEntity, SwitchEntity): ) if not success: - _LOGGER.error("Command failed: %s", command) + LOGGER.error("Command failed: %s", command) return success def _query_state_value(self, command: str) -> str | None: """Execute state command for return value.""" - _LOGGER.info("Running state value command: %s", command) + LOGGER.info("Running state value command: %s", command) return check_output_or_log(command, self._timeout) def _query_state_code(self, command: str) -> bool: """Execute state command for return code.""" - _LOGGER.info("Running state code command: %s", command) + LOGGER.info("Running state code command: %s", command) return ( call_shell_with_timeout(command, self._timeout, log_return_code=False) == 0 ) @@ -188,7 +210,22 @@ class CommandSwitch(ManualTriggerEntity, SwitchEntity): if TYPE_CHECKING: return None - async def async_update(self) -> 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 Switch %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)) @@ -201,15 +238,18 @@ class CommandSwitch(ManualTriggerEntity, SwitchEntity): if payload or value: self._attr_is_on = (value or payload).lower() == "true" self._process_manual_data(payload) + await self.async_update_ha_state(True) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" if await self._switch(self._command_on) and not self._command_state: self._attr_is_on = True self.async_schedule_update_ha_state() + await self._update_entity_state(None) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" if await self._switch(self._command_off) and not self._command_state: self._attr_is_on = False self.async_schedule_update_ha_state() + await self._update_entity_state(None) diff --git a/tests/components/command_line/test_binary_sensor.py b/tests/components/command_line/test_binary_sensor.py index 6f79b6bdacf..eb6b52a66be 100644 --- a/tests/components/command_line/test_binary_sensor.py +++ b/tests/components/command_line/test_binary_sensor.py @@ -1,17 +1,24 @@ """The tests for the Command line Binary sensor platform.""" from __future__ import annotations +import asyncio +from datetime import timedelta from typing import Any +from unittest.mock import patch import pytest from homeassistant import setup from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.command_line.binary_sensor import CommandBinarySensor from homeassistant.components.command_line.const import DOMAIN from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er import homeassistant.helpers.issue_registry as ir +from homeassistant.util import dt as dt_util + +from tests.common import async_fire_time_changed async def test_setup_platform_yaml(hass: HomeAssistant) -> None: @@ -189,3 +196,59 @@ async def test_return_code( ) await hass.async_block_till_done() assert "return code 33" in caplog.text + + +async def test_updating_to_often( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test handling updating when command already running.""" + called = [] + + class MockCommandBinarySensor(CommandBinarySensor): + """Mock entity that updates slow.""" + + async def _async_update(self) -> None: + """Update slow.""" + called.append(1) + # Add waiting time + await asyncio.sleep(1) + + with patch( + "homeassistant.components.command_line.binary_sensor.CommandBinarySensor", + side_effect=MockCommandBinarySensor, + ): + await setup.async_setup_component( + hass, + DOMAIN, + { + "command_line": [ + { + "binary_sensor": { + "name": "Test", + "command": "echo 1", + "payload_on": "1", + "payload_off": "0", + "scan_interval": 0.1, + } + } + ] + }, + ) + await hass.async_block_till_done() + + assert len(called) == 1 + assert ( + "Updating Command Line Binary Sensor Test took longer than the scheduled update interval" + not in caplog.text + ) + + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=1)) + await hass.async_block_till_done() + + assert len(called) == 2 + assert ( + "Updating Command Line Binary Sensor Test took longer than the scheduled update interval" + in caplog.text + ) + + await asyncio.sleep(0.2) diff --git a/tests/components/command_line/test_cover.py b/tests/components/command_line/test_cover.py index 057e632c325..d977c202b04 100644 --- a/tests/components/command_line/test_cover.py +++ b/tests/components/command_line/test_cover.py @@ -1,6 +1,8 @@ """The tests the cover command line platform.""" from __future__ import annotations +import asyncio +from datetime import timedelta import os import tempfile from unittest.mock import patch @@ -9,6 +11,7 @@ import pytest from homeassistant import config as hass_config, setup from homeassistant.components.command_line import DOMAIN +from homeassistant.components.command_line.cover import CommandCover from homeassistant.components.cover import DOMAIN as COVER_DOMAIN, SCAN_INTERVAL from homeassistant.const import ( ATTR_ENTITY_ID, @@ -320,3 +323,58 @@ async def test_unique_id( assert entity_registry.async_get_entity_id( "cover", "command_line", "not-so-unique-anymore" ) + + +async def test_updating_to_often( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test handling updating when command already running.""" + called = [] + + class MockCommandCover(CommandCover): + """Mock entity that updates slow.""" + + async def _async_update(self) -> None: + """Update slow.""" + called.append(1) + # Add waiting time + await asyncio.sleep(1) + + with patch( + "homeassistant.components.command_line.cover.CommandCover", + side_effect=MockCommandCover, + ): + await setup.async_setup_component( + hass, + DOMAIN, + { + "command_line": [ + { + "cover": { + "command_state": "echo 1", + "value_template": "{{ value }}", + "name": "Test", + "scan_interval": 0.1, + } + } + ] + }, + ) + await hass.async_block_till_done() + + assert len(called) == 0 + assert ( + "Updating Command Line Cover Test took longer than the scheduled update interval" + not in caplog.text + ) + + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=1)) + await hass.async_block_till_done() + + assert len(called) == 1 + assert ( + "Updating Command Line Cover Test took longer than the scheduled update interval" + in caplog.text + ) + + await asyncio.sleep(0.2) diff --git a/tests/components/command_line/test_sensor.py b/tests/components/command_line/test_sensor.py index 7491e7011f5..87360d0e251 100644 --- a/tests/components/command_line/test_sensor.py +++ b/tests/components/command_line/test_sensor.py @@ -1,6 +1,7 @@ """The tests for the Command line sensor platform.""" from __future__ import annotations +import asyncio from datetime import timedelta from typing import Any from unittest.mock import patch @@ -9,6 +10,7 @@ import pytest from homeassistant import setup from homeassistant.components.command_line import DOMAIN +from homeassistant.components.command_line.sensor import CommandSensor from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -530,3 +532,57 @@ async def test_unique_id( assert entity_registry.async_get_entity_id( "sensor", "command_line", "not-so-unique-anymore" ) + + +async def test_updating_to_often( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test handling updating when command already running.""" + called = [] + + class MockCommandSensor(CommandSensor): + """Mock entity that updates slow.""" + + async def _async_update(self) -> None: + """Update slow.""" + called.append(1) + # Add waiting time + await asyncio.sleep(1) + + with patch( + "homeassistant.components.command_line.sensor.CommandSensor", + side_effect=MockCommandSensor, + ): + await setup.async_setup_component( + hass, + DOMAIN, + { + "command_line": [ + { + "sensor": { + "name": "Test", + "command": "echo 1", + "scan_interval": 0.1, + } + } + ] + }, + ) + await hass.async_block_till_done() + + assert len(called) == 1 + assert ( + "Updating Command Line Sensor Test took longer than the scheduled update interval" + not in caplog.text + ) + + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=1)) + await hass.async_block_till_done() + + assert len(called) == 2 + assert ( + "Updating Command Line Sensor Test took longer than the scheduled update interval" + in caplog.text + ) + + await asyncio.sleep(0.2) diff --git a/tests/components/command_line/test_switch.py b/tests/components/command_line/test_switch.py index 017c453aa8b..88a87588375 100644 --- a/tests/components/command_line/test_switch.py +++ b/tests/components/command_line/test_switch.py @@ -1,6 +1,8 @@ """The tests for the Command line switch platform.""" from __future__ import annotations +import asyncio +from datetime import timedelta import json import os import subprocess @@ -11,6 +13,7 @@ import pytest from homeassistant import setup from homeassistant.components.command_line import DOMAIN +from homeassistant.components.command_line.switch import CommandSwitch from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SCAN_INTERVAL from homeassistant.const import ( ATTR_ENTITY_ID, @@ -637,3 +640,59 @@ async def test_templating(hass: HomeAssistant) -> None: assert entity_state.attributes.get("icon") == "mdi:on" assert entity_state2.state == STATE_ON assert entity_state2.attributes.get("icon") == "mdi:on" + + +async def test_updating_to_often( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test handling updating when command already running.""" + called = [] + + class MockCommandSwitch(CommandSwitch): + """Mock entity that updates slow.""" + + async def _async_update(self) -> None: + """Update slow.""" + called.append(1) + # Add waiting time + await asyncio.sleep(1) + + with patch( + "homeassistant.components.command_line.switch.CommandSwitch", + side_effect=MockCommandSwitch, + ): + await setup.async_setup_component( + hass, + DOMAIN, + { + "command_line": [ + { + "switch": { + "command_state": "echo 1", + "command_on": "echo 2", + "command_off": "echo 3", + "name": "Test", + "scan_interval": 0.1, + } + } + ] + }, + ) + await hass.async_block_till_done() + + assert len(called) == 0 + assert ( + "Updating Command Line Switch Test took longer than the scheduled update interval" + not in caplog.text + ) + + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=1)) + await hass.async_block_till_done() + + assert len(called) == 1 + assert ( + "Updating Command Line Switch Test took longer than the scheduled update interval" + in caplog.text + ) + + await asyncio.sleep(0.2)