Cleanup command_line (#90268)

* Cleanup command_line

* Fix ipv6 resolver

* Fix fix

* Fix tests

* Align states
This commit is contained in:
G Johansson 2023-03-27 21:19:09 +02:00 committed by GitHub
parent 2ceb24e5d0
commit 96698813ef
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 122 additions and 161 deletions

View file

@ -1,62 +1 @@
"""The command_line component."""
from __future__ import annotations
import logging
import subprocess
_LOGGER = logging.getLogger(__name__)
def call_shell_with_timeout(
command: str, timeout: int, *, log_return_code: bool = True
) -> int:
"""Run a shell command with a timeout.
If log_return_code is set to False, it will not print an error if a non-zero
return code is returned.
"""
try:
_LOGGER.debug("Running command: %s", command)
subprocess.check_output(
command,
shell=True, # nosec # shell by design
timeout=timeout,
close_fds=False, # required for posix_spawn
)
return 0
except subprocess.CalledProcessError as proc_exception:
if log_return_code:
_LOGGER.error(
"Command failed (with return code %s): %s",
proc_exception.returncode,
command,
)
return proc_exception.returncode
except subprocess.TimeoutExpired:
_LOGGER.error("Timeout for command: %s", command)
return -1
except subprocess.SubprocessError:
_LOGGER.error("Error trying to exec command: %s", command)
return -1
def check_output_or_log(command: str, timeout: int) -> str | None:
"""Run a shell command with a timeout and return the output."""
try:
return_value = subprocess.check_output(
command,
shell=True, # nosec # shell by design
timeout=timeout,
close_fds=False, # required for posix_spawn
)
return return_value.strip().decode("utf-8")
except subprocess.CalledProcessError as err:
_LOGGER.error(
"Command failed (with return code %s): %s", err.returncode, command
)
except subprocess.TimeoutExpired:
_LOGGER.error("Timeout for command: %s", command)
except subprocess.SubprocessError:
_LOGGER.error("Error trying to exec command: %s", command)
return None

View file

@ -25,10 +25,6 @@ import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.reload import async_setup_reload_service
from homeassistant.helpers.template import Template
from homeassistant.helpers.template_entity import (
TEMPLATE_ENTITY_BASE_SCHEMA,
TemplateEntity,
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS
@ -65,10 +61,6 @@ async def async_setup_platform(
await async_setup_reload_service(hass, DOMAIN, PLATFORMS)
binary_sensor_config = vol.Schema(
TEMPLATE_ENTITY_BASE_SCHEMA.schema, extra=vol.REMOVE_EXTRA
)(config)
name: str = config.get(CONF_NAME, DEFAULT_NAME)
command: str = config[CONF_COMMAND]
payload_off: str = config[CONF_PAYLOAD_OFF]
@ -84,8 +76,6 @@ async def async_setup_platform(
async_add_entities(
[
CommandBinarySensor(
hass,
binary_sensor_config,
data,
name,
device_class,
@ -99,13 +89,11 @@ async def async_setup_platform(
)
class CommandBinarySensor(TemplateEntity, BinarySensorEntity):
class CommandBinarySensor(BinarySensorEntity):
"""Representation of a command line binary sensor."""
def __init__(
self,
hass: HomeAssistant,
config: ConfigType,
data: CommandSensorData,
name: str,
device_class: BinarySensorDeviceClass | None,
@ -115,19 +103,14 @@ class CommandBinarySensor(TemplateEntity, BinarySensorEntity):
unique_id: str | None,
) -> None:
"""Initialize the Command line binary sensor."""
TemplateEntity.__init__(
self,
hass,
config=config,
fallback_name=name,
unique_id=unique_id,
)
self.data = data
self._attr_name = name
self._attr_device_class = device_class
self._attr_is_on = None
self._payload_on = payload_on
self._payload_off = payload_off
self._value_template = value_template
self._attr_unique_id = unique_id
async def async_update(self) -> None:
"""Get the latest data and updates the state."""
@ -135,9 +118,10 @@ class CommandBinarySensor(TemplateEntity, BinarySensorEntity):
value = self.data.value
if self._value_template is not None:
value = await self.hass.async_add_executor_job(
self._value_template.render_with_possible_json_value, value, False
value = self._value_template.async_render_with_possible_json_value(
value, None
)
self._attr_is_on = None
if value == self._payload_on:
self._attr_is_on = True
elif value == self._payload_off:

View file

@ -22,14 +22,10 @@ import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.reload import async_setup_reload_service
from homeassistant.helpers.template import Template
from homeassistant.helpers.template_entity import (
TEMPLATE_ENTITY_BASE_SCHEMA,
TemplateEntity,
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import call_shell_with_timeout, check_output_or_log
from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS
from .utils import call_shell_with_timeout, check_output_or_log
_LOGGER = logging.getLogger(__name__)
@ -69,14 +65,8 @@ async def async_setup_platform(
if value_template is not None:
value_template.hass = hass
cover_config = vol.Schema(
TEMPLATE_ENTITY_BASE_SCHEMA.schema, extra=vol.REMOVE_EXTRA
)(device_config)
covers.append(
CommandCover(
hass,
cover_config,
device_config.get(CONF_FRIENDLY_NAME, device_name),
device_config[CONF_COMMAND_OPEN],
device_config[CONF_COMMAND_CLOSE],
@ -95,13 +85,11 @@ async def async_setup_platform(
async_add_entities(covers)
class CommandCover(TemplateEntity, CoverEntity):
class CommandCover(CoverEntity):
"""Representation a command line cover."""
def __init__(
self,
hass: HomeAssistant,
config: ConfigType,
name: str,
command_open: str,
command_close: str,
@ -112,13 +100,7 @@ class CommandCover(TemplateEntity, CoverEntity):
unique_id: str | None,
) -> None:
"""Initialize the cover."""
TemplateEntity.__init__(
self,
hass,
config=config,
fallback_name=name,
unique_id=unique_id,
)
self._attr_name = name
self._state: int | None = None
self._command_open = command_open
self._command_close = command_close
@ -126,6 +108,7 @@ class CommandCover(TemplateEntity, CoverEntity):
self._command_state = command_state
self._value_template = value_template
self._timeout = timeout
self._attr_unique_id = unique_id
self._attr_should_poll = bool(command_state)
def _move_cover(self, command: str) -> bool:
@ -170,9 +153,11 @@ class CommandCover(TemplateEntity, CoverEntity):
if self._command_state:
payload = str(await self.hass.async_add_executor_job(self._query_state))
if self._value_template:
payload = await self.hass.async_add_executor_job(
self._value_template.render_with_possible_json_value, payload
payload = self._value_template.async_render_with_possible_json_value(
payload, None
)
self._state = None
if payload:
self._state = int(payload)
def open_cover(self, **kwargs: Any) -> None:

View file

@ -22,7 +22,6 @@ from homeassistant.const import (
CONF_UNIQUE_ID,
CONF_UNIT_OF_MEASUREMENT,
CONF_VALUE_TEMPLATE,
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import TemplateError
@ -30,14 +29,10 @@ import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.reload import async_setup_reload_service
from homeassistant.helpers.template import Template
from homeassistant.helpers.template_entity import (
TEMPLATE_SENSOR_BASE_SCHEMA,
TemplateSensor,
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import check_output_or_log
from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS
from .utils import check_output_or_log
_LOGGER = logging.getLogger(__name__)
@ -72,10 +67,6 @@ async def async_setup_platform(
await async_setup_reload_service(hass, DOMAIN, PLATFORMS)
sensor_config = vol.Schema(
TEMPLATE_SENSOR_BASE_SCHEMA.schema, extra=vol.REMOVE_EXTRA
)(config)
name: str = config[CONF_NAME]
command: str = config[CONF_COMMAND]
unit: str | None = config.get(CONF_UNIT_OF_MEASUREMENT)
@ -90,8 +81,6 @@ async def async_setup_platform(
async_add_entities(
[
CommandSensor(
hass,
sensor_config,
data,
name,
unit,
@ -104,13 +93,11 @@ async def async_setup_platform(
)
class CommandSensor(TemplateSensor, SensorEntity):
class CommandSensor(SensorEntity):
"""Representation of a sensor that is using shell commands."""
def __init__(
self,
hass: HomeAssistant,
config: ConfigType,
data: CommandSensorData,
name: str,
unit_of_measurement: str | None,
@ -119,18 +106,14 @@ class CommandSensor(TemplateSensor, SensorEntity):
unique_id: str | None,
) -> None:
"""Initialize the sensor."""
TemplateSensor.__init__(
self,
hass,
config=config,
fallback_name=name,
unique_id=unique_id,
)
self._attr_name = name
self.data = data
self._attr_extra_state_attributes = {}
self._json_attributes = json_attributes
self._attr_native_value = None
self._value_template = value_template
self._attr_native_unit_of_measurement = unit_of_measurement
self._attr_unique_id = unique_id
async def async_update(self) -> None:
"""Get the latest data and updates the state."""
@ -155,13 +138,12 @@ class CommandSensor(TemplateSensor, SensorEntity):
else:
_LOGGER.warning("Empty reply found when expecting JSON data")
if value is None:
value = STATE_UNKNOWN
elif self._value_template is not None:
self._attr_native_value = await self.hass.async_add_executor_job(
self._value_template.render_with_possible_json_value,
self._attr_native_value = (
self._value_template.async_render_with_possible_json_value(
value,
STATE_UNKNOWN,
None,
)
)
else:
self._attr_native_value = value

View file

@ -24,12 +24,12 @@ 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.reload import setup_reload_service
from homeassistant.helpers.reload import async_setup_reload_service
from homeassistant.helpers.template import Template
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import call_shell_with_timeout, check_output_or_log
from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS
from .utils import call_shell_with_timeout, check_output_or_log
_LOGGER = logging.getLogger(__name__)
@ -51,15 +51,15 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
)
def setup_platform(
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
add_entities: AddEntitiesCallback,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Find and return switches controlled by shell commands."""
setup_reload_service(hass, DOMAIN, PLATFORMS)
await async_setup_reload_service(hass, DOMAIN, PLATFORMS)
devices: dict[str, Any] = config.get(CONF_SWITCHES, {})
switches = []
@ -92,7 +92,7 @@ def setup_platform(
_LOGGER.error("No switches added")
return
add_entities(switches)
async_add_entities(switches)
class CommandSwitch(SwitchEntity):
@ -123,11 +123,16 @@ class CommandSwitch(SwitchEntity):
self._attr_unique_id = unique_id
self._attr_should_poll = bool(command_state)
def _switch(self, command: str) -> bool:
async def _switch(self, command: str) -> bool:
"""Execute the actual commands."""
_LOGGER.info("Running command: %s", command)
success = call_shell_with_timeout(command, self._timeout) == 0
success = (
await self.hass.async_add_executor_job(
call_shell_with_timeout, command, self._timeout
)
== 0
)
if not success:
_LOGGER.error("Command failed: %s", command)
@ -160,26 +165,30 @@ class CommandSwitch(SwitchEntity):
if TYPE_CHECKING:
return None
def update(self) -> None:
async def async_update(self) -> None:
"""Update device state."""
if self._command_state:
payload = str(self._query_state())
payload = str(await self.hass.async_add_executor_job(self._query_state))
if self._icon_template:
self._attr_icon = self._icon_template.render_with_possible_json_value(
payload
self._attr_icon = (
self._icon_template.async_render_with_possible_json_value(payload)
)
if self._value_template:
payload = self._value_template.render_with_possible_json_value(payload)
payload = self._value_template.async_render_with_possible_json_value(
payload, None
)
self._attr_is_on = None
if payload:
self._attr_is_on = payload.lower() == "true"
def turn_on(self, **kwargs: Any) -> None:
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the device on."""
if 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.schedule_update_ha_state()
self.async_schedule_update_ha_state()
def turn_off(self, **kwargs: Any) -> None:
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the device off."""
if 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.schedule_update_ha_state()
self.async_schedule_update_ha_state()

View file

@ -0,0 +1,62 @@
"""The command_line component utils."""
from __future__ import annotations
import logging
import subprocess
_LOGGER = logging.getLogger(__name__)
def call_shell_with_timeout(
command: str, timeout: int, *, log_return_code: bool = True
) -> int:
"""Run a shell command with a timeout.
If log_return_code is set to False, it will not print an error if a non-zero
return code is returned.
"""
try:
_LOGGER.debug("Running command: %s", command)
subprocess.check_output(
command,
shell=True, # nosec # shell by design
timeout=timeout,
close_fds=False, # required for posix_spawn
)
return 0
except subprocess.CalledProcessError as proc_exception:
if log_return_code:
_LOGGER.error(
"Command failed (with return code %s): %s",
proc_exception.returncode,
command,
)
return proc_exception.returncode
except subprocess.TimeoutExpired:
_LOGGER.error("Timeout for command: %s", command)
return -1
except subprocess.SubprocessError:
_LOGGER.error("Error trying to exec command: %s", command)
return -1
def check_output_or_log(command: str, timeout: int) -> str | None:
"""Run a shell command with a timeout and return the output."""
try:
return_value = subprocess.check_output(
command,
shell=True, # nosec # shell by design
timeout=timeout,
close_fds=False, # required for posix_spawn
)
return return_value.strip().decode("utf-8")
except subprocess.CalledProcessError as err:
_LOGGER.error(
"Command failed (with return code %s): %s", err.returncode, command
)
except subprocess.TimeoutExpired:
_LOGGER.error("Timeout for command: %s", command)
except subprocess.SubprocessError:
_LOGGER.error("Error trying to exec command: %s", command)
return None

View file

@ -42,7 +42,7 @@ async def test_no_covers(caplog: pytest.LogCaptureFixture, hass: HomeAssistant)
"""Test that the cover does not polls when there's no state command."""
with patch(
"homeassistant.components.command_line.subprocess.check_output",
"homeassistant.components.command_line.utils.subprocess.check_output",
return_value=b"50\n",
):
await setup_test_entity(hass, {})
@ -53,7 +53,7 @@ async def test_no_poll_when_cover_has_no_command_state(hass: HomeAssistant) -> N
"""Test that the cover does not polls when there's no state command."""
with patch(
"homeassistant.components.command_line.subprocess.check_output",
"homeassistant.components.command_line.utils.subprocess.check_output",
return_value=b"50\n",
) as check_output:
await setup_test_entity(hass, {"test": {}})
@ -66,7 +66,7 @@ async def test_poll_when_cover_has_command_state(hass: HomeAssistant) -> None:
"""Test that the cover polls when there's a state command."""
with patch(
"homeassistant.components.command_line.subprocess.check_output",
"homeassistant.components.command_line.utils.subprocess.check_output",
return_value=b"50\n",
) as check_output:
await setup_test_entity(hass, {"test": {"command_state": "echo state"}})

View file

@ -88,7 +88,7 @@ async def test_template_render_with_quote(hass: HomeAssistant) -> None:
"""Ensure command with templates and quotes get rendered properly."""
with patch(
"homeassistant.components.command_line.subprocess.check_output",
"homeassistant.components.command_line.utils.subprocess.check_output",
return_value=b"Works\n",
) as check_output:
await setup_test_entities(

View file

@ -323,7 +323,7 @@ async def test_switch_command_state_code_exceptions(
"""Test that switch state code exceptions are handled correctly."""
with patch(
"homeassistant.components.command_line.subprocess.check_output",
"homeassistant.components.command_line.utils.subprocess.check_output",
side_effect=[
subprocess.TimeoutExpired("cmd", 10),
subprocess.SubprocessError(),
@ -356,7 +356,7 @@ async def test_switch_command_state_value_exceptions(
"""Test that switch state value exceptions are handled correctly."""
with patch(
"homeassistant.components.command_line.subprocess.check_output",
"homeassistant.components.command_line.utils.subprocess.check_output",
side_effect=[
subprocess.TimeoutExpired("cmd", 10),
subprocess.SubprocessError(),