Cleanup command_line (#90268)
* Cleanup command_line * Fix ipv6 resolver * Fix fix * Fix tests * Align states
This commit is contained in:
parent
2ceb24e5d0
commit
96698813ef
9 changed files with 122 additions and 161 deletions
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
62
homeassistant/components/command_line/utils.py
Normal file
62
homeassistant/components/command_line/utils.py
Normal 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
|
|
@ -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"}})
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(),
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue