Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """Expose regular shell commands as services."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from contextlib import suppress
7 import logging
8 import shlex
9 
10 import voluptuous as vol
11 
12 from homeassistant.core import (
13  HomeAssistant,
14  ServiceCall,
15  ServiceResponse,
16  SupportsResponse,
17 )
18 from homeassistant.exceptions import HomeAssistantError, TemplateError
19 from homeassistant.helpers import config_validation as cv, template
20 from homeassistant.helpers.typing import ConfigType
21 from homeassistant.util.json import JsonObjectType
22 
23 DOMAIN = "shell_command"
24 
25 COMMAND_TIMEOUT = 60
26 
27 _LOGGER = logging.getLogger(__name__)
28 
29 CONFIG_SCHEMA = vol.Schema(
30  {DOMAIN: cv.schema_with_slug_keys(cv.string)}, extra=vol.ALLOW_EXTRA
31 )
32 
33 
34 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
35  """Set up the shell_command component."""
36  conf = config.get(DOMAIN, {})
37 
38  cache: dict[str, tuple[str, str | None, template.Template | None]] = {}
39 
40  async def async_service_handler(service: ServiceCall) -> ServiceResponse:
41  """Execute a shell command service."""
42  cmd = conf[service.service]
43 
44  if cmd in cache:
45  prog, args, args_compiled = cache[cmd]
46  elif " " not in cmd:
47  prog = cmd
48  args = None
49  args_compiled = None
50  cache[cmd] = prog, args, args_compiled
51  else:
52  prog, args = cmd.split(" ", 1)
53  args_compiled = template.Template(str(args), hass)
54  cache[cmd] = prog, args, args_compiled
55 
56  if args_compiled:
57  try:
58  rendered_args = args_compiled.async_render(
59  variables=service.data, parse_result=False
60  )
61  except TemplateError:
62  _LOGGER.exception("Error rendering command template")
63  raise
64  else:
65  rendered_args = None
66 
67  if rendered_args == args:
68  # No template used. default behavior
69 
70  create_process = asyncio.create_subprocess_shell(
71  cmd,
72  stdin=None,
73  stdout=asyncio.subprocess.PIPE,
74  stderr=asyncio.subprocess.PIPE,
75  close_fds=False, # required for posix_spawn
76  )
77  else:
78  # Template used. Break into list and use create_subprocess_exec
79  # (which uses shell=False) for security
80  shlexed_cmd = [prog, *shlex.split(rendered_args)]
81 
82  create_process = asyncio.create_subprocess_exec(
83  *shlexed_cmd,
84  stdin=None,
85  stdout=asyncio.subprocess.PIPE,
86  stderr=asyncio.subprocess.PIPE,
87  close_fds=False, # required for posix_spawn
88  )
89 
90  process = await create_process
91  try:
92  async with asyncio.timeout(COMMAND_TIMEOUT):
93  stdout_data, stderr_data = await process.communicate()
94  except TimeoutError as err:
95  _LOGGER.error(
96  "Timed out running command: `%s`, after: %ss", cmd, COMMAND_TIMEOUT
97  )
98  if process:
99  with suppress(TypeError):
100  process.kill()
101  # https://bugs.python.org/issue43884
102  process._transport.close() # type: ignore[attr-defined] # noqa: SLF001
103  del process
104 
105  raise HomeAssistantError(
106  translation_domain=DOMAIN,
107  translation_key="timeout",
108  translation_placeholders={
109  "command": cmd,
110  "timeout": str(COMMAND_TIMEOUT),
111  },
112  ) from err
113 
114  if stdout_data:
115  _LOGGER.debug(
116  "Stdout of command: `%s`, return code: %s:\n%s",
117  cmd,
118  process.returncode,
119  stdout_data,
120  )
121  if stderr_data:
122  _LOGGER.debug(
123  "Stderr of command: `%s`, return code: %s:\n%s",
124  cmd,
125  process.returncode,
126  stderr_data,
127  )
128  if process.returncode != 0:
129  _LOGGER.exception(
130  "Error running command: `%s`, return code: %s", cmd, process.returncode
131  )
132 
133  if service.return_response:
134  service_response: JsonObjectType = {
135  "stdout": "",
136  "stderr": "",
137  "returncode": process.returncode,
138  }
139  try:
140  if stdout_data:
141  service_response["stdout"] = stdout_data.decode("utf-8").strip()
142  if stderr_data:
143  service_response["stderr"] = stderr_data.decode("utf-8").strip()
144  except UnicodeDecodeError as err:
145  _LOGGER.exception(
146  "Unable to handle non-utf8 output of command: `%s`", cmd
147  )
148  raise HomeAssistantError(
149  translation_domain=DOMAIN,
150  translation_key="non_utf8_output",
151  translation_placeholders={"command": cmd},
152  ) from err
153  return service_response
154  return None
155 
156  for name in conf:
157  hass.services.async_register(
158  DOMAIN,
159  name,
160  async_service_handler,
161  supports_response=SupportsResponse.OPTIONAL,
162  )
163  return True
bool async_setup(HomeAssistant hass, ConfigType config)
Definition: __init__.py:34