Add scan interval to Command Line (#93752)

* Add scan interval

* Handle previous not complete

* Fix faulty text

* Add tests

* lingering

* Cool down

* Fix tests
This commit is contained in:
G Johansson 2023-06-03 05:35:11 +02:00 committed by GitHub
parent aa636a2805
commit 038b0e6d23
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 463 additions and 41 deletions

View File

@ -11,16 +11,24 @@ import voluptuous as vol
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
DEVICE_CLASSES_SCHEMA as BINARY_SENSOR_DEVICE_CLASSES_SCHEMA, DEVICE_CLASSES_SCHEMA as BINARY_SENSOR_DEVICE_CLASSES_SCHEMA,
DOMAIN as BINARY_SENSOR_DOMAIN, 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.notify import DOMAIN as NOTIFY_DOMAIN
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
CONF_STATE_CLASS, CONF_STATE_CLASS,
DEVICE_CLASSES_SCHEMA as SENSOR_DEVICE_CLASSES_SCHEMA, DEVICE_CLASSES_SCHEMA as SENSOR_DEVICE_CLASSES_SCHEMA,
DOMAIN as SENSOR_DOMAIN, DOMAIN as SENSOR_DOMAIN,
SCAN_INTERVAL as SENSOR_DEFAULT_SCAN_INTERVAL,
STATE_CLASSES_SCHEMA as SENSOR_STATE_CLASSES_SCHEMA, 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 ( from homeassistant.const import (
CONF_COMMAND, CONF_COMMAND,
CONF_COMMAND_CLOSE, CONF_COMMAND_CLOSE,
@ -34,6 +42,7 @@ from homeassistant.const import (
CONF_NAME, CONF_NAME,
CONF_PAYLOAD_OFF, CONF_PAYLOAD_OFF,
CONF_PAYLOAD_ON, CONF_PAYLOAD_ON,
CONF_SCAN_INTERVAL,
CONF_UNIQUE_ID, CONF_UNIQUE_ID,
CONF_UNIT_OF_MEASUREMENT, CONF_UNIT_OF_MEASUREMENT,
CONF_VALUE_TEMPLATE, CONF_VALUE_TEMPLATE,
@ -74,6 +83,9 @@ BINARY_SENSOR_SCHEMA = vol.Schema(
vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
vol.Optional(CONF_UNIQUE_ID): cv.string, 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( COVER_SCHEMA = vol.Schema(
@ -86,6 +98,9 @@ COVER_SCHEMA = vol.Schema(
vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
vol.Optional(CONF_UNIQUE_ID): cv.string, 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( NOTIFY_SCHEMA = vol.Schema(
@ -106,6 +121,9 @@ SENSOR_SCHEMA = vol.Schema(
vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_UNIQUE_ID): cv.string,
vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA,
vol.Optional(CONF_STATE_CLASS): SENSOR_STATE_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( SWITCH_SCHEMA = vol.Schema(
@ -118,6 +136,9 @@ SWITCH_SCHEMA = vol.Schema(
vol.Optional(CONF_ICON): cv.template, vol.Optional(CONF_ICON): cv.template,
vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
vol.Optional(CONF_UNIQUE_ID): cv.string, 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( COMBINED_SCHEMA = vol.Schema(

View File

@ -1,6 +1,7 @@
"""Support for custom shell commands to retrieve values.""" """Support for custom shell commands to retrieve values."""
from __future__ import annotations from __future__ import annotations
import asyncio
from datetime import timedelta from datetime import timedelta
import voluptuous as vol import voluptuous as vol
@ -18,17 +19,19 @@ from homeassistant.const import (
CONF_NAME, CONF_NAME,
CONF_PAYLOAD_OFF, CONF_PAYLOAD_OFF,
CONF_PAYLOAD_ON, CONF_PAYLOAD_ON,
CONF_SCAN_INTERVAL,
CONF_UNIQUE_ID, CONF_UNIQUE_ID,
CONF_VALUE_TEMPLATE, CONF_VALUE_TEMPLATE,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback 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.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.template import Template from homeassistant.helpers.template import Template
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType 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 from .sensor import CommandSensorData
DEFAULT_NAME = "Binary Command Sensor" 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) value_template: Template | None = binary_sensor_config.get(CONF_VALUE_TEMPLATE)
command_timeout: int = binary_sensor_config[CONF_COMMAND_TIMEOUT] command_timeout: int = binary_sensor_config[CONF_COMMAND_TIMEOUT]
unique_id: str | None = binary_sensor_config.get(CONF_UNIQUE_ID) 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: if value_template is not None:
value_template.hass = hass value_template.hass = hass
data = CommandSensorData(hass, command, command_timeout) data = CommandSensorData(hass, command, command_timeout)
@ -98,6 +104,7 @@ async def async_setup_platform(
payload_off, payload_off,
value_template, value_template,
unique_id, unique_id,
scan_interval,
) )
], ],
True, True,
@ -107,6 +114,8 @@ async def async_setup_platform(
class CommandBinarySensor(BinarySensorEntity): class CommandBinarySensor(BinarySensorEntity):
"""Representation of a command line binary sensor.""" """Representation of a command line binary sensor."""
_attr_should_poll = False
def __init__( def __init__(
self, self,
data: CommandSensorData, data: CommandSensorData,
@ -116,6 +125,7 @@ class CommandBinarySensor(BinarySensorEntity):
payload_off: str, payload_off: str,
value_template: Template | None, value_template: Template | None,
unique_id: str | None, unique_id: str | None,
scan_interval: timedelta,
) -> None: ) -> None:
"""Initialize the Command line binary sensor.""" """Initialize the Command line binary sensor."""
self.data = data self.data = data
@ -126,8 +136,39 @@ class CommandBinarySensor(BinarySensorEntity):
self._payload_off = payload_off self._payload_off = payload_off
self._value_template = value_template self._value_template = value_template
self._attr_unique_id = unique_id 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.""" """Get the latest data and updates the state."""
await self.hass.async_add_executor_job(self.data.update) await self.hass.async_add_executor_job(self.data.update)
value = self.data.value value = self.data.value
@ -141,3 +182,5 @@ class CommandBinarySensor(BinarySensorEntity):
self._attr_is_on = True self._attr_is_on = True
elif value == self._payload_off: elif value == self._payload_off:
self._attr_is_on = False self._attr_is_on = False
self.async_write_ha_state()

View File

@ -1,7 +1,11 @@
"""Allows to configure custom shell commands to turn a value for a sensor.""" """Allows to configure custom shell commands to turn a value for a sensor."""
import logging
from homeassistant.const import Platform from homeassistant.const import Platform
LOGGER = logging.getLogger(__package__)
CONF_COMMAND_TIMEOUT = "command_timeout" CONF_COMMAND_TIMEOUT = "command_timeout"
DEFAULT_TIMEOUT = 15 DEFAULT_TIMEOUT = 15
DOMAIN = "command_line" DOMAIN = "command_line"

View File

@ -1,7 +1,8 @@
"""Support for command line covers.""" """Support for command line covers."""
from __future__ import annotations from __future__ import annotations
import logging import asyncio
from datetime import timedelta
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
import voluptuous as vol import voluptuous as vol
@ -19,21 +20,23 @@ from homeassistant.const import (
CONF_COVERS, CONF_COVERS,
CONF_FRIENDLY_NAME, CONF_FRIENDLY_NAME,
CONF_NAME, CONF_NAME,
CONF_SCAN_INTERVAL,
CONF_UNIQUE_ID, CONF_UNIQUE_ID,
CONF_VALUE_TEMPLATE, CONF_VALUE_TEMPLATE,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback 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.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.template import Template from homeassistant.helpers.template import Template
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import slugify 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 from .utils import call_shell_with_timeout, check_output_or_log
_LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=15)
COVER_SCHEMA = vol.Schema( COVER_SCHEMA = vol.Schema(
{ {
@ -97,11 +100,12 @@ async def async_setup_platform(
value_template, value_template,
device_config[CONF_COMMAND_TIMEOUT], device_config[CONF_COMMAND_TIMEOUT],
device_config.get(CONF_UNIQUE_ID), device_config.get(CONF_UNIQUE_ID),
device_config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL),
) )
) )
if not covers: if not covers:
_LOGGER.error("No covers added") LOGGER.error("No covers added")
return return
async_add_entities(covers) async_add_entities(covers)
@ -110,6 +114,8 @@ async def async_setup_platform(
class CommandCover(CoverEntity): class CommandCover(CoverEntity):
"""Representation a command line cover.""" """Representation a command line cover."""
_attr_should_poll = False
def __init__( def __init__(
self, self,
name: str, name: str,
@ -120,6 +126,7 @@ class CommandCover(CoverEntity):
value_template: Template | None, value_template: Template | None,
timeout: int, timeout: int,
unique_id: str | None, unique_id: str | None,
scan_interval: timedelta,
) -> None: ) -> None:
"""Initialize the cover.""" """Initialize the cover."""
self._attr_name = name self._attr_name = name
@ -131,17 +138,32 @@ class CommandCover(CoverEntity):
self._value_template = value_template self._value_template = value_template
self._timeout = timeout self._timeout = timeout
self._attr_unique_id = unique_id 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: def _move_cover(self, command: str) -> bool:
"""Execute the actual commands.""" """Execute the actual commands."""
_LOGGER.info("Running command: %s", command) LOGGER.info("Running command: %s", command)
returncode = call_shell_with_timeout(command, self._timeout) returncode = call_shell_with_timeout(command, self._timeout)
success = returncode == 0 success = returncode == 0
if not success: if not success:
_LOGGER.error( LOGGER.error(
"Command failed (with return code %s): %s", returncode, command "Command failed (with return code %s): %s", returncode, command
) )
@ -165,12 +187,27 @@ class CommandCover(CoverEntity):
def _query_state(self) -> str | None: def _query_state(self) -> str | None:
"""Query for the state.""" """Query for the state."""
if self._command_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) return check_output_or_log(self._command_state, self._timeout)
if TYPE_CHECKING: if TYPE_CHECKING:
return None 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.""" """Update device state."""
if self._command_state: if self._command_state:
payload = str(await self.hass.async_add_executor_job(self._query_state)) payload = str(await self.hass.async_add_executor_job(self._query_state))
@ -181,15 +218,19 @@ class CommandCover(CoverEntity):
self._state = None self._state = None
if payload: if payload:
self._state = int(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.""" """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.""" """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.""" """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)

View File

@ -1,10 +1,10 @@
"""Allows to configure custom shell commands to turn a value for a sensor.""" """Allows to configure custom shell commands to turn a value for a sensor."""
from __future__ import annotations from __future__ import annotations
import asyncio
from collections.abc import Mapping from collections.abc import Mapping
from datetime import timedelta from datetime import timedelta
import json import json
import logging
import voluptuous as vol import voluptuous as vol
@ -20,6 +20,7 @@ from homeassistant.const import (
CONF_COMMAND, CONF_COMMAND,
CONF_DEVICE_CLASS, CONF_DEVICE_CLASS,
CONF_NAME, CONF_NAME,
CONF_SCAN_INTERVAL,
CONF_UNIQUE_ID, CONF_UNIQUE_ID,
CONF_UNIT_OF_MEASUREMENT, CONF_UNIT_OF_MEASUREMENT,
CONF_VALUE_TEMPLATE, CONF_VALUE_TEMPLATE,
@ -28,15 +29,14 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import TemplateError from homeassistant.exceptions import TemplateError
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback 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.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.template import Template from homeassistant.helpers.template import Template
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType 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 from .utils import check_output_or_log
_LOGGER = logging.getLogger(__name__)
CONF_JSON_ATTRIBUTES = "json_attributes" CONF_JSON_ATTRIBUTES = "json_attributes"
DEFAULT_NAME = "Command Sensor" DEFAULT_NAME = "Command Sensor"
@ -88,6 +88,7 @@ async def async_setup_platform(
if value_template is not None: if value_template is not None:
value_template.hass = hass value_template.hass = hass
json_attributes: list[str] | None = sensor_config.get(CONF_JSON_ATTRIBUTES) 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) data = CommandSensorData(hass, command, command_timeout)
async_add_entities( async_add_entities(
@ -99,15 +100,17 @@ async def async_setup_platform(
value_template, value_template,
json_attributes, json_attributes,
unique_id, unique_id,
scan_interval,
) )
], ]
True,
) )
class CommandSensor(SensorEntity): class CommandSensor(SensorEntity):
"""Representation of a sensor that is using shell commands.""" """Representation of a sensor that is using shell commands."""
_attr_should_poll = False
def __init__( def __init__(
self, self,
data: CommandSensorData, data: CommandSensorData,
@ -116,6 +119,7 @@ class CommandSensor(SensorEntity):
value_template: Template | None, value_template: Template | None,
json_attributes: list[str] | None, json_attributes: list[str] | None,
unique_id: str | None, unique_id: str | None,
scan_interval: timedelta,
) -> None: ) -> None:
"""Initialize the sensor.""" """Initialize the sensor."""
self._attr_name = name self._attr_name = name
@ -126,8 +130,39 @@ class CommandSensor(SensorEntity):
self._value_template = value_template self._value_template = value_template
self._attr_native_unit_of_measurement = unit_of_measurement self._attr_native_unit_of_measurement = unit_of_measurement
self._attr_unique_id = unique_id 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.""" """Get the latest data and updates the state."""
await self.hass.async_add_executor_job(self.data.update) await self.hass.async_add_executor_job(self.data.update)
value = self.data.value value = self.data.value
@ -144,11 +179,11 @@ class CommandSensor(SensorEntity):
if k in json_dict if k in json_dict
} }
else: else:
_LOGGER.warning("JSON result was not a dictionary") LOGGER.warning("JSON result was not a dictionary")
except ValueError: except ValueError:
_LOGGER.warning("Unable to parse output as JSON: %s", value) LOGGER.warning("Unable to parse output as JSON: %s", value)
else: 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: if self._value_template is None:
self._attr_native_value = None self._attr_native_value = None
return return
@ -163,6 +198,8 @@ class CommandSensor(SensorEntity):
else: else:
self._attr_native_value = value self._attr_native_value = value
self.async_write_ha_state()
class CommandSensorData: class CommandSensorData:
"""The class for handling the data retrieval.""" """The class for handling the data retrieval."""
@ -191,7 +228,7 @@ class CommandSensorData:
args_to_render = {"arguments": args} args_to_render = {"arguments": args}
rendered_args = args_compiled.render(args_to_render) rendered_args = args_compiled.render(args_to_render)
except TemplateError as ex: except TemplateError as ex:
_LOGGER.exception("Error rendering command template: %s", ex) LOGGER.exception("Error rendering command template: %s", ex)
return return
else: else:
rendered_args = None rendered_args = None
@ -203,5 +240,5 @@ class CommandSensorData:
# Template used. Construct the string used in the shell # Template used. Construct the string used in the shell
command = f"{prog} {rendered_args}" 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) self.value = check_output_or_log(command, self.timeout)

View File

@ -1,7 +1,8 @@
"""Support for custom shell commands to turn a switch on/off.""" """Support for custom shell commands to turn a switch on/off."""
from __future__ import annotations from __future__ import annotations
import logging import asyncio
from datetime import timedelta
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
import voluptuous as vol import voluptuous as vol
@ -20,6 +21,7 @@ from homeassistant.const import (
CONF_ICON, CONF_ICON,
CONF_ICON_TEMPLATE, CONF_ICON_TEMPLATE,
CONF_NAME, CONF_NAME,
CONF_SCAN_INTERVAL,
CONF_SWITCHES, CONF_SWITCHES,
CONF_UNIQUE_ID, CONF_UNIQUE_ID,
CONF_VALUE_TEMPLATE, CONF_VALUE_TEMPLATE,
@ -27,16 +29,17 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback 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.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.template import Template from homeassistant.helpers.template import Template
from homeassistant.helpers.template_entity import ManualTriggerEntity from homeassistant.helpers.template_entity import ManualTriggerEntity
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import slugify 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 from .utils import call_shell_with_timeout, check_output_or_log
_LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=30)
SWITCH_SCHEMA = vol.Schema( SWITCH_SCHEMA = vol.Schema(
{ {
@ -112,11 +115,12 @@ async def async_setup_platform(
device_config.get(CONF_COMMAND_STATE), device_config.get(CONF_COMMAND_STATE),
value_template, value_template,
device_config[CONF_COMMAND_TIMEOUT], device_config[CONF_COMMAND_TIMEOUT],
device_config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL),
) )
) )
if not switches: if not switches:
_LOGGER.error("No switches added") LOGGER.error("No switches added")
return return
async_add_entities(switches) async_add_entities(switches)
@ -125,6 +129,8 @@ async def async_setup_platform(
class CommandSwitch(ManualTriggerEntity, SwitchEntity): class CommandSwitch(ManualTriggerEntity, SwitchEntity):
"""Representation a switch that can be toggled using shell commands.""" """Representation a switch that can be toggled using shell commands."""
_attr_should_poll = False
def __init__( def __init__(
self, self,
config: ConfigType, config: ConfigType,
@ -134,6 +140,7 @@ class CommandSwitch(ManualTriggerEntity, SwitchEntity):
command_state: str | None, command_state: str | None,
value_template: Template | None, value_template: Template | None,
timeout: int, timeout: int,
scan_interval: timedelta,
) -> None: ) -> None:
"""Initialize the switch.""" """Initialize the switch."""
super().__init__(self.hass, config) super().__init__(self.hass, config)
@ -144,11 +151,26 @@ class CommandSwitch(ManualTriggerEntity, SwitchEntity):
self._command_state = command_state self._command_state = command_state
self._value_template = value_template self._value_template = value_template
self._timeout = timeout 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: async def _switch(self, command: str) -> bool:
"""Execute the actual commands.""" """Execute the actual commands."""
_LOGGER.info("Running command: %s", command) LOGGER.info("Running command: %s", command)
success = ( success = (
await self.hass.async_add_executor_job( await self.hass.async_add_executor_job(
@ -158,18 +180,18 @@ class CommandSwitch(ManualTriggerEntity, SwitchEntity):
) )
if not success: if not success:
_LOGGER.error("Command failed: %s", command) LOGGER.error("Command failed: %s", command)
return success return success
def _query_state_value(self, command: str) -> str | None: def _query_state_value(self, command: str) -> str | None:
"""Execute state command for return value.""" """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) return check_output_or_log(command, self._timeout)
def _query_state_code(self, command: str) -> bool: def _query_state_code(self, command: str) -> bool:
"""Execute state command for return code.""" """Execute state command for return code."""
_LOGGER.info("Running state code command: %s", command) LOGGER.info("Running state code command: %s", command)
return ( return (
call_shell_with_timeout(command, self._timeout, log_return_code=False) == 0 call_shell_with_timeout(command, self._timeout, log_return_code=False) == 0
) )
@ -188,7 +210,22 @@ class CommandSwitch(ManualTriggerEntity, SwitchEntity):
if TYPE_CHECKING: if TYPE_CHECKING:
return None 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.""" """Update device state."""
if self._command_state: if self._command_state:
payload = str(await self.hass.async_add_executor_job(self._query_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: if payload or value:
self._attr_is_on = (value or payload).lower() == "true" self._attr_is_on = (value or payload).lower() == "true"
self._process_manual_data(payload) self._process_manual_data(payload)
await self.async_update_ha_state(True)
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the device on.""" """Turn the device on."""
if await self._switch(self._command_on) and not self._command_state: if await self._switch(self._command_on) and not self._command_state:
self._attr_is_on = True self._attr_is_on = True
self.async_schedule_update_ha_state() self.async_schedule_update_ha_state()
await self._update_entity_state(None)
async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the device off.""" """Turn the device off."""
if await self._switch(self._command_off) and not self._command_state: if await self._switch(self._command_off) and not self._command_state:
self._attr_is_on = False self._attr_is_on = False
self.async_schedule_update_ha_state() self.async_schedule_update_ha_state()
await self._update_entity_state(None)

View File

@ -1,17 +1,24 @@
"""The tests for the Command line Binary sensor platform.""" """The tests for the Command line Binary sensor platform."""
from __future__ import annotations from __future__ import annotations
import asyncio
from datetime import timedelta
from typing import Any from typing import Any
from unittest.mock import patch
import pytest import pytest
from homeassistant import setup from homeassistant import setup
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN 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.components.command_line.const import DOMAIN
from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
import homeassistant.helpers.issue_registry as ir 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: async def test_setup_platform_yaml(hass: HomeAssistant) -> None:
@ -189,3 +196,59 @@ async def test_return_code(
) )
await hass.async_block_till_done() await hass.async_block_till_done()
assert "return code 33" in caplog.text 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)

View File

@ -1,6 +1,8 @@
"""The tests the cover command line platform.""" """The tests the cover command line platform."""
from __future__ import annotations from __future__ import annotations
import asyncio
from datetime import timedelta
import os import os
import tempfile import tempfile
from unittest.mock import patch from unittest.mock import patch
@ -9,6 +11,7 @@ import pytest
from homeassistant import config as hass_config, setup from homeassistant import config as hass_config, setup
from homeassistant.components.command_line import DOMAIN 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.components.cover import DOMAIN as COVER_DOMAIN, SCAN_INTERVAL
from homeassistant.const import ( from homeassistant.const import (
ATTR_ENTITY_ID, ATTR_ENTITY_ID,
@ -320,3 +323,58 @@ async def test_unique_id(
assert entity_registry.async_get_entity_id( assert entity_registry.async_get_entity_id(
"cover", "command_line", "not-so-unique-anymore" "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)

View File

@ -1,6 +1,7 @@
"""The tests for the Command line sensor platform.""" """The tests for the Command line sensor platform."""
from __future__ import annotations from __future__ import annotations
import asyncio
from datetime import timedelta from datetime import timedelta
from typing import Any from typing import Any
from unittest.mock import patch from unittest.mock import patch
@ -9,6 +10,7 @@ import pytest
from homeassistant import setup from homeassistant import setup
from homeassistant.components.command_line import DOMAIN 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.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
@ -530,3 +532,57 @@ async def test_unique_id(
assert entity_registry.async_get_entity_id( assert entity_registry.async_get_entity_id(
"sensor", "command_line", "not-so-unique-anymore" "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)

View File

@ -1,6 +1,8 @@
"""The tests for the Command line switch platform.""" """The tests for the Command line switch platform."""
from __future__ import annotations from __future__ import annotations
import asyncio
from datetime import timedelta
import json import json
import os import os
import subprocess import subprocess
@ -11,6 +13,7 @@ import pytest
from homeassistant import setup from homeassistant import setup
from homeassistant.components.command_line import DOMAIN 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.components.switch import DOMAIN as SWITCH_DOMAIN, SCAN_INTERVAL
from homeassistant.const import ( from homeassistant.const import (
ATTR_ENTITY_ID, ATTR_ENTITY_ID,
@ -637,3 +640,59 @@ async def test_templating(hass: HomeAssistant) -> None:
assert entity_state.attributes.get("icon") == "mdi:on" assert entity_state.attributes.get("icon") == "mdi:on"
assert entity_state2.state == STATE_ON assert entity_state2.state == STATE_ON
assert entity_state2.attributes.get("icon") == "mdi: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)