diff --git a/homeassistant/components/ping/config_flow.py b/homeassistant/components/ping/config_flow.py index 42cdd3f3a77..29b8a8ba2a5 100644 --- a/homeassistant/components/ping/config_flow.py +++ b/homeassistant/components/ping/config_flow.py @@ -8,6 +8,10 @@ from typing import Any import voluptuous as vol from homeassistant import config_entries +from homeassistant.components.device_tracker import ( + CONF_CONSIDER_HOME, + DEFAULT_CONSIDER_HOME, +) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult @@ -45,7 +49,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry( title=user_input[CONF_HOST], data={}, - options={**user_input, CONF_PING_COUNT: DEFAULT_PING_COUNT}, + options={ + **user_input, + CONF_PING_COUNT: DEFAULT_PING_COUNT, + CONF_CONSIDER_HOME: DEFAULT_CONSIDER_HOME.seconds, + }, ) async def async_step_import(self, import_info: Mapping[str, Any]) -> FlowResult: @@ -54,6 +62,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): to_import = { CONF_HOST: import_info[CONF_HOST], CONF_PING_COUNT: import_info[CONF_PING_COUNT], + CONF_CONSIDER_HOME: import_info.get( + CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME + ).seconds, } title = import_info.get(CONF_NAME, import_info[CONF_HOST]) @@ -102,6 +113,12 @@ class OptionsFlowHandler(config_entries.OptionsFlow): min=1, max=100, mode=selector.NumberSelectorMode.BOX ) ), + vol.Optional( + CONF_CONSIDER_HOME, + default=self.config_entry.options.get( + CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.seconds + ), + ): int, } ), ) diff --git a/homeassistant/components/ping/device_tracker.py b/homeassistant/components/ping/device_tracker.py index 417659aad5c..d627082a499 100644 --- a/homeassistant/components/ping/device_tracker.py +++ b/homeassistant/components/ping/device_tracker.py @@ -1,12 +1,15 @@ """Tracks devices by sending a ICMP echo request (ping).""" from __future__ import annotations +from datetime import datetime, timedelta import logging from typing import Any import voluptuous as vol from homeassistant.components.device_tracker import ( + CONF_CONSIDER_HOME, + DEFAULT_CONSIDER_HOME, PLATFORM_SCHEMA as BASE_PLATFORM_SCHEMA, AsyncSeeCallback, ScannerEntity, @@ -31,6 +34,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util from . import PingDomainData from .const import CONF_IMPORTED_BY, CONF_PING_COUNT, DOMAIN @@ -91,6 +95,7 @@ async def async_setup_scanner( CONF_NAME: dev_name, CONF_HOST: dev_host, CONF_PING_COUNT: config[CONF_PING_COUNT], + CONF_CONSIDER_HOME: config[CONF_CONSIDER_HOME], }, ) ) @@ -131,6 +136,8 @@ async def async_setup_entry( class PingDeviceTracker(CoordinatorEntity[PingUpdateCoordinator], ScannerEntity): """Representation of a Ping device tracker.""" + _first_offline: datetime | None = None + def __init__( self, config_entry: ConfigEntry, coordinator: PingUpdateCoordinator ) -> None: @@ -139,6 +146,11 @@ class PingDeviceTracker(CoordinatorEntity[PingUpdateCoordinator], ScannerEntity) self._attr_name = config_entry.title self.config_entry = config_entry + self._consider_home_interval = timedelta( + seconds=config_entry.options.get( + CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.seconds + ) + ) @property def ip_address(self) -> str: @@ -157,8 +169,16 @@ class PingDeviceTracker(CoordinatorEntity[PingUpdateCoordinator], ScannerEntity) @property def is_connected(self) -> bool: - """Return true if ping returns is_alive.""" - return self.coordinator.data.is_alive + """Return true if ping returns is_alive or considered home.""" + if self.coordinator.data.is_alive: + self._first_offline = None + return True + + now = dt_util.utcnow() + if self._first_offline is None: + self._first_offline = now + + return (self._first_offline + self._consider_home_interval) > now @property def entity_registry_enabled_default(self) -> bool: diff --git a/homeassistant/components/ping/strings.json b/homeassistant/components/ping/strings.json index 12bc1d25c7a..421d9079c62 100644 --- a/homeassistant/components/ping/strings.json +++ b/homeassistant/components/ping/strings.json @@ -5,8 +5,7 @@ "title": "Add Ping", "description": "Ping allows you to check the availability of a host.", "data": { - "host": "[%key:common::config_flow::data::host%]", - "count": "Ping count" + "host": "[%key:common::config_flow::data::host%]" }, "data_description": { "host": "The hostname or IP address of the device you want to ping." @@ -23,7 +22,11 @@ "init": { "data": { "host": "[%key:common::config_flow::data::host%]", - "count": "[%key:component::ping::config::step::user::data::count%]" + "count": "Ping count", + "consider_home": "Consider home interval" + }, + "data_description": { + "consider_home": "Seconds to wait till marking a device tracker as not home after not being seen." } } }, diff --git a/tests/components/ping/conftest.py b/tests/components/ping/conftest.py index 4ad06a09c1c..24dd3314e3c 100644 --- a/tests/components/ping/conftest.py +++ b/tests/components/ping/conftest.py @@ -4,6 +4,7 @@ from unittest.mock import patch from icmplib import Host import pytest +from homeassistant.components.device_tracker.const import CONF_CONSIDER_HOME from homeassistant.components.ping import DOMAIN from homeassistant.components.ping.const import CONF_PING_COUNT from homeassistant.const import CONF_HOST @@ -39,7 +40,11 @@ async def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: return MockConfigEntry( domain=DOMAIN, title="10.10.10.10", - options={CONF_HOST: "10.10.10.10", CONF_PING_COUNT: 10.0}, + options={ + CONF_HOST: "10.10.10.10", + CONF_PING_COUNT: 10.0, + CONF_CONSIDER_HOME: 180, + }, ) diff --git a/tests/components/ping/const.py b/tests/components/ping/const.py index cf002dc7ca6..048924292c7 100644 --- a/tests/components/ping/const.py +++ b/tests/components/ping/const.py @@ -1,4 +1,6 @@ """Constants for tests.""" +from datetime import timedelta + from icmplib import Host BINARY_SENSOR_IMPORT_DATA = { @@ -6,6 +8,7 @@ BINARY_SENSOR_IMPORT_DATA = { "host": "127.0.0.1", "count": 1, "scan_interval": 50, + "consider_home": timedelta(seconds=240), } NON_AVAILABLE_HOST_PING = Host("192.168.178.1", 10, []) diff --git a/tests/components/ping/test_config_flow.py b/tests/components/ping/test_config_flow.py index 6fff4ae7c71..8757a5b5e0d 100644 --- a/tests/components/ping/test_config_flow.py +++ b/tests/components/ping/test_config_flow.py @@ -42,6 +42,7 @@ async def test_form(hass: HomeAssistant, host, expected_title) -> None: assert result["options"] == { "count": 5, "host": host, + "consider_home": 180, } @@ -58,7 +59,7 @@ async def test_options(hass: HomeAssistant, host, count, expected_title) -> None source=config_entries.SOURCE_USER, data={}, domain=DOMAIN, - options={"count": count, "host": host}, + options={"count": count, "host": host, "consider_home": 180}, title=expected_title, ) config_entry.add_to_hass(hass) @@ -83,6 +84,7 @@ async def test_options(hass: HomeAssistant, host, count, expected_title) -> None assert result["data"] == { "count": count, "host": "10.10.10.1", + "consider_home": 180, } @@ -103,6 +105,7 @@ async def test_step_import(hass: HomeAssistant) -> None: assert result["options"] == { "host": "127.0.0.1", "count": 1, + "consider_home": 240, } # test import without name @@ -119,4 +122,5 @@ async def test_step_import(hass: HomeAssistant) -> None: assert result["options"] == { "host": "10.10.10.10", "count": 5, + "consider_home": 180, } diff --git a/tests/components/ping/test_device_tracker.py b/tests/components/ping/test_device_tracker.py index 5f5bb2132c1..d91cb46da0c 100644 --- a/tests/components/ping/test_device_tracker.py +++ b/tests/components/ping/test_device_tracker.py @@ -1,6 +1,9 @@ """Test the binary sensor platform of ping.""" +from datetime import timedelta from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory +from icmplib import Host import pytest from homeassistant.components.device_tracker import legacy @@ -11,7 +14,7 @@ from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component from homeassistant.util.yaml import dump -from tests.common import MockConfigEntry, patch_yaml_files +from tests.common import MockConfigEntry, async_fire_time_changed, patch_yaml_files @pytest.mark.usefixtures("setup_integration") @@ -19,6 +22,7 @@ async def test_setup_and_update( hass: HomeAssistant, entity_registry: er.EntityRegistry, config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, ) -> None: """Test sensor setup and update.""" @@ -42,10 +46,32 @@ async def test_setup_and_update( await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() - # check device tracker is now "home" state = hass.states.get("device_tracker.10_10_10_10") assert state.state == "home" + with patch( + "homeassistant.components.ping.helpers.async_ping", + return_value=Host(address="10.10.10.10", packets_sent=10, rtts=[]), + ): + # we need to travel two times into the future to run the update twice + freezer.tick(timedelta(minutes=1, seconds=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + freezer.tick(timedelta(minutes=4, seconds=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get("device_tracker.10_10_10_10")) + assert state.state == "not_home" + + freezer.tick(timedelta(minutes=1, seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get("device_tracker.10_10_10_10")) + assert state.state == "home" + async def test_import_issue_creation( hass: HomeAssistant,