core/homeassistant/components/ping/binary_sensor.py

288 lines
9.0 KiB
Python
Raw Normal View History

"""Tracks the latency of a host by sending ICMP echo requests (ping)."""
2021-03-18 12:21:46 +00:00
from __future__ import annotations
import asyncio
from contextlib import suppress
from datetime import timedelta
import logging
import re
2021-03-18 12:21:46 +00:00
from typing import Any
import async_timeout
from icmplib import NameLookupError, async_ping
import voluptuous as vol
from homeassistant.components.binary_sensor import (
PLATFORM_SCHEMA,
2021-12-16 14:38:02 +00:00
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.const import CONF_HOST, CONF_NAME, STATE_ON
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import DOMAIN, ICMP_TIMEOUT, PING_PRIVS, PING_TIMEOUT
_LOGGER = logging.getLogger(__name__)
2019-07-31 19:25:30 +00:00
ATTR_ROUND_TRIP_TIME_AVG = "round_trip_time_avg"
ATTR_ROUND_TRIP_TIME_MAX = "round_trip_time_max"
ATTR_ROUND_TRIP_TIME_MDEV = "round_trip_time_mdev"
ATTR_ROUND_TRIP_TIME_MIN = "round_trip_time_min"
2019-07-31 19:25:30 +00:00
CONF_PING_COUNT = "count"
DEFAULT_NAME = "Ping"
DEFAULT_PING_COUNT = 5
SCAN_INTERVAL = timedelta(minutes=5)
PARALLEL_UPDATES = 50
PING_MATCHER = re.compile(
2019-07-31 19:25:30 +00:00
r"(?P<min>\d+.\d+)\/(?P<avg>\d+.\d+)\/(?P<max>\d+.\d+)\/(?P<mdev>\d+.\d+)"
)
PING_MATCHER_BUSYBOX = re.compile(
2019-07-31 19:25:30 +00:00
r"(?P<min>\d+.\d+)\/(?P<avg>\d+.\d+)\/(?P<max>\d+.\d+)"
)
2019-07-31 19:25:30 +00:00
WIN32_PING_MATCHER = re.compile(r"(?P<min>\d+)ms.+(?P<max>\d+)ms.+(?P<avg>\d+)ms")
2019-07-31 19:25:30 +00:00
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_HOST): cv.string,
vol.Optional(CONF_NAME): cv.string,
vol.Optional(CONF_PING_COUNT, default=DEFAULT_PING_COUNT): vol.Range(
min=1, max=100
),
2019-07-31 19:25:30 +00:00
}
)
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Ping Binary sensor."""
host: str = config[CONF_HOST]
count: int = config[CONF_PING_COUNT]
name: str = config.get(CONF_NAME, f"{DEFAULT_NAME} {host}")
privileged: bool | None = hass.data[DOMAIN][PING_PRIVS]
ping_cls: type[PingDataSubProcess | PingDataICMPLib]
if privileged is None:
ping_cls = PingDataSubProcess
else:
ping_cls = PingDataICMPLib
async_add_entities(
[PingBinarySensor(name, ping_cls(hass, host, count, privileged))]
)
class PingBinarySensor(RestoreEntity, BinarySensorEntity):
"""Representation of a Ping Binary sensor."""
def __init__(self, name: str, ping: PingDataSubProcess | PingDataICMPLib) -> None:
"""Initialize the Ping Binary sensor."""
self._available = False
self._name = name
self._ping = ping
@property
def name(self) -> str:
"""Return the name of the device."""
return self._name
@property
def available(self) -> bool:
"""Return if we have done the first ping."""
return self._available
@property
2021-12-16 14:38:02 +00:00
def device_class(self) -> BinarySensorDeviceClass:
"""Return the class of this sensor."""
2021-12-16 14:38:02 +00:00
return BinarySensorDeviceClass.CONNECTIVITY
@property
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
return self._ping.is_alive
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the state attributes of the ICMP checo request."""
if self._ping.data is None:
return None
return {
ATTR_ROUND_TRIP_TIME_AVG: self._ping.data["avg"],
ATTR_ROUND_TRIP_TIME_MAX: self._ping.data["max"],
ATTR_ROUND_TRIP_TIME_MDEV: self._ping.data["mdev"],
ATTR_ROUND_TRIP_TIME_MIN: self._ping.data["min"],
}
async def async_update(self) -> None:
"""Get the latest data."""
await self._ping.async_update()
self._available = True
2022-09-06 07:51:33 +00:00
async def async_added_to_hass(self) -> None:
"""Restore previous state on restart to avoid blocking startup."""
await super().async_added_to_hass()
last_state = await self.async_get_last_state()
if last_state is not None:
self._available = True
if last_state is None or last_state.state != STATE_ON:
self._ping.data = None
return
attributes = last_state.attributes
self._ping.is_alive = True
self._ping.data = {
"min": attributes[ATTR_ROUND_TRIP_TIME_MIN],
"max": attributes[ATTR_ROUND_TRIP_TIME_MAX],
"avg": attributes[ATTR_ROUND_TRIP_TIME_AVG],
"mdev": attributes[ATTR_ROUND_TRIP_TIME_MDEV],
}
class PingData:
"""The base class for handling the data retrieval."""
def __init__(self, hass: HomeAssistant, host: str, count: int) -> None:
"""Initialize the data object."""
self.hass = hass
self._ip_address = host
self._count = count
self.data: dict[str, Any] | None = None
self.is_alive = False
class PingDataICMPLib(PingData):
"""The Class for handling the data retrieval using icmplib."""
def __init__(
self, hass: HomeAssistant, host: str, count: int, privileged: bool | None
) -> None:
"""Initialize the data object."""
super().__init__(hass, host, count)
self._privileged = privileged
async def async_update(self) -> None:
"""Retrieve the latest details from the host."""
_LOGGER.debug("ping address: %s", self._ip_address)
try:
data = await async_ping(
self._ip_address,
count=self._count,
timeout=ICMP_TIMEOUT,
privileged=self._privileged,
)
except NameLookupError:
self.is_alive = False
return
self.is_alive = data.is_alive
if not self.is_alive:
self.data = None
return
self.data = {
"min": data.min_rtt,
"max": data.max_rtt,
"avg": data.avg_rtt,
"mdev": "",
}
class PingDataSubProcess(PingData):
"""The Class for handling the data retrieval using the ping binary."""
def __init__(
self, hass: HomeAssistant, host: str, count: int, privileged: bool | None
) -> None:
"""Initialize the data object."""
super().__init__(hass, host, count)
self._ping_cmd = [
"ping",
"-n",
"-q",
"-c",
str(self._count),
"-W1",
self._ip_address,
]
async def async_ping(self):
"""Send ICMP echo request and return details if success."""
pinger = await asyncio.create_subprocess_exec(
*self._ping_cmd,
stdin=None,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
Avoid subprocess memory copy when c library supports posix_spawn (#87958) * use posix spawn on alpine * Avoid subprocess memory copy when c library supports posix_spawn By default python 3.10 will use the fork() which has to copy all the memory of the parent process (in our case this can be huge since Home Assistant core can use hundreds of megabytes of RAM). By using posix_spawn this is avoided. In python 3.11 vfork will also be available https://github.com/python/cpython/issues/80004#issuecomment-1093810689 https://github.com/python/cpython/pull/11671 but we won't always be able to use it and posix_spawn is considered safer https://bugzilla.kernel.org/show_bug.cgi?id=215813#c14 The subprocess library doesn't know about musl though even though it supports posix_spawn https://git.musl-libc.org/cgit/musl/log/src/process/posix_spawn.c so we have to teach it since it only has checks for glibc https://github.com/python/cpython/blob/1b736838e6ae1b4ef42cdd27c2708face908f92c/Lib/subprocess.py#L745 The constant is documented as being able to be flipped here: https://docs.python.org/3/library/subprocess.html#disabling-use-of-vfork-or-posix-spawn * Avoid subprocess memory copy when c library supports posix_spawn By default python 3.10 will use the fork() which has to copy memory of the parent process (in our case this can be huge since Home Assistant core can use hundreds of megabytes of RAM). By using posix_spawn this is avoided and subprocess creation does not get discernibly slow the larger the Home Assistant python process grows. In python 3.11 vfork will also be available https://github.com/python/cpython/issues/80004#issuecomment-1093810689 https://github.com/python/cpython/pull/11671 but we won't always be able to use it and posix_spawn is considered safer https://bugzilla.kernel.org/show_bug.cgi?id=215813#c14 The subprocess library doesn't know about musl though even though it supports posix_spawn https://git.musl-libc.org/cgit/musl/log/src/process/posix_spawn.c so we have to teach it since it only has checks for glibc https://github.com/python/cpython/blob/1b736838e6ae1b4ef42cdd27c2708face908f92c/Lib/subprocess.py#L745 The constant is documented as being able to be flipped here: https://docs.python.org/3/library/subprocess.html#disabling-use-of-vfork-or-posix-spawn * missed some * adjust more tests * coverage
2023-02-13 14:02:51 +00:00
close_fds=False, # required for posix_spawn
2019-07-31 19:25:30 +00:00
)
try:
async with async_timeout.timeout(self._count + PING_TIMEOUT):
out_data, out_error = await pinger.communicate()
if out_data:
_LOGGER.debug(
"Output of command: `%s`, return code: %s:\n%s",
" ".join(self._ping_cmd),
pinger.returncode,
out_data,
)
if out_error:
_LOGGER.debug(
"Error of command: `%s`, return code: %s:\n%s",
" ".join(self._ping_cmd),
pinger.returncode,
out_error,
)
if pinger.returncode > 1:
# returncode of 1 means the host is unreachable
_LOGGER.exception(
"Error running command: `%s`, return code: %s",
" ".join(self._ping_cmd),
pinger.returncode,
)
if "max/" not in str(out_data):
match = PING_MATCHER_BUSYBOX.search(
str(out_data).rsplit("\n", maxsplit=1)[-1]
)
rtt_min, rtt_avg, rtt_max = match.groups()
2019-07-31 19:25:30 +00:00
return {"min": rtt_min, "avg": rtt_avg, "max": rtt_max, "mdev": ""}
match = PING_MATCHER.search(str(out_data).rsplit("\n", maxsplit=1)[-1])
rtt_min, rtt_avg, rtt_max, rtt_mdev = match.groups()
2019-07-31 19:25:30 +00:00
return {"min": rtt_min, "avg": rtt_avg, "max": rtt_max, "mdev": rtt_mdev}
except asyncio.TimeoutError:
_LOGGER.exception(
"Timed out running command: `%s`, after: %ss",
self._ping_cmd,
self._count + PING_TIMEOUT,
)
if pinger:
with suppress(TypeError):
await pinger.kill()
del pinger
return None
except AttributeError:
return None
async def async_update(self) -> None:
"""Retrieve the latest details from the host."""
self.data = await self.async_ping()
self.is_alive = self.data is not None