diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py index dfd8987484c..21469f197f4 100644 --- a/homeassistant/components/nmap_tracker/__init__.py +++ b/homeassistant/components/nmap_tracker/__init__.py @@ -14,7 +14,11 @@ from getmac import get_mac_address from mac_vendor_lookup import AsyncMacLookup from nmap import PortScanner, PortScannerError -from homeassistant.components.device_tracker.const import CONF_SCAN_INTERVAL +from homeassistant.components.device_tracker.const import ( + CONF_CONSIDER_HOME, + CONF_SCAN_INTERVAL, + DEFAULT_CONSIDER_HOME, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS, EVENT_HOMEASSISTANT_STARTED from homeassistant.core import CoreState, HomeAssistant, callback @@ -37,7 +41,6 @@ from .const import ( # Some version of nmap will fail with 'Assertion failed: htn.toclock_running == true (Target.cc: stopTimeOutClock: 503)\n' NMAP_TRANSIENT_FAILURE: Final = "Assertion failed: htn.toclock_running == true" MAX_SCAN_ATTEMPTS: Final = 16 -OFFLINE_SCANS_TO_MARK_UNAVAILABLE: Final = 3 def short_hostname(hostname: str) -> str: @@ -65,7 +68,7 @@ class NmapDevice: manufacturer: str reason: str last_update: datetime - offline_scans: int + first_offline: datetime | None class NmapTrackedDevices: @@ -137,6 +140,7 @@ class NmapDeviceScanner: """Initialize the scanner.""" self.devices = devices self.home_interval = None + self.consider_home = DEFAULT_CONSIDER_HOME self._hass = hass self._entry = entry @@ -170,6 +174,10 @@ class NmapDeviceScanner: self.home_interval = timedelta( minutes=cv.positive_int(config[CONF_HOME_INTERVAL]) ) + if config.get(CONF_CONSIDER_HOME): + self.consider_home = timedelta( + seconds=cv.positive_float(config[CONF_CONSIDER_HOME]) + ) self._scan_lock = asyncio.Lock() if self._hass.state == CoreState.running: await self._async_start_scanner() @@ -320,16 +328,35 @@ class NmapDeviceScanner: return result @callback - def _async_increment_device_offline(self, ipv4, reason): + def _async_device_offline(self, ipv4: str, reason: str, now: datetime) -> None: """Mark an IP offline.""" if not (formatted_mac := self.devices.ipv4_last_mac.get(ipv4)): return if not (device := self.devices.tracked.get(formatted_mac)): # Device was unloaded return - device.offline_scans += 1 - if device.offline_scans < OFFLINE_SCANS_TO_MARK_UNAVAILABLE: + if not device.first_offline: + _LOGGER.debug( + "Setting first_offline for %s (%s) to: %s", ipv4, formatted_mac, now + ) + device.first_offline = now return + if device.first_offline + self.consider_home > now: + _LOGGER.debug( + "Device %s (%s) has NOT been offline (first offline at: %s) long enough to be considered not home: %s", + ipv4, + formatted_mac, + device.first_offline, + self.consider_home, + ) + return + _LOGGER.debug( + "Device %s (%s) has been offline (first offline at: %s) long enough to be considered not home: %s", + ipv4, + formatted_mac, + device.first_offline, + self.consider_home, + ) device.reason = reason async_dispatcher_send(self._hass, signal_device_update(formatted_mac), False) del self.devices.ipv4_last_mac[ipv4] @@ -347,7 +374,7 @@ class NmapDeviceScanner: status = info["status"] reason = status["reason"] if status["state"] != "up": - self._async_increment_device_offline(ipv4, reason) + self._async_device_offline(ipv4, reason, now) continue # Mac address only returned if nmap ran as root mac = info["addresses"].get( @@ -356,12 +383,11 @@ class NmapDeviceScanner: partial(get_mac_address, ip=ipv4) ) if mac is None: - self._async_increment_device_offline(ipv4, "No MAC address found") + self._async_device_offline(ipv4, "No MAC address found", now) _LOGGER.info("No MAC address found for %s", ipv4) continue formatted_mac = format_mac(mac) - if ( devices.config_entry_owner.setdefault(formatted_mac, entry_id) != entry_id @@ -372,7 +398,7 @@ class NmapDeviceScanner: vendor = info.get("vendor", {}).get(mac) or self._async_get_vendor(mac) name = human_readable_name(hostname, vendor, mac) device = NmapDevice( - formatted_mac, hostname, name, ipv4, vendor, reason, now, 0 + formatted_mac, hostname, name, ipv4, vendor, reason, now, None ) new = formatted_mac not in devices.tracked diff --git a/homeassistant/components/nmap_tracker/config_flow.py b/homeassistant/components/nmap_tracker/config_flow.py index 2d25b62f1d2..c9e9706e4ba 100644 --- a/homeassistant/components/nmap_tracker/config_flow.py +++ b/homeassistant/components/nmap_tracker/config_flow.py @@ -8,7 +8,11 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import network -from homeassistant.components.device_tracker.const import CONF_SCAN_INTERVAL +from homeassistant.components.device_tracker.const import ( + CONF_CONSIDER_HOME, + CONF_SCAN_INTERVAL, + DEFAULT_CONSIDER_HOME, +) from homeassistant.components.network.const import MDNS_TARGET_IP from homeassistant.config_entries import ConfigEntry, OptionsFlow from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS @@ -24,6 +28,8 @@ from .const import ( TRACKER_SCAN_INTERVAL, ) +MAX_SCAN_INTERVAL = 3600 +MAX_CONSIDER_HOME = MAX_SCAN_INTERVAL * 6 DEFAULT_NETWORK_PREFIX = 24 @@ -116,7 +122,12 @@ async def _async_build_schema_with_user_input( vol.Optional( CONF_SCAN_INTERVAL, default=user_input.get(CONF_SCAN_INTERVAL, TRACKER_SCAN_INTERVAL), - ): vol.All(vol.Coerce(int), vol.Range(min=10, max=3600)), + ): vol.All(vol.Coerce(int), vol.Range(min=10, max=MAX_SCAN_INTERVAL)), + vol.Optional( + CONF_CONSIDER_HOME, + default=user_input.get(CONF_CONSIDER_HOME) + or DEFAULT_CONSIDER_HOME.total_seconds(), + ): vol.All(vol.Coerce(int), vol.Range(min=1, max=MAX_CONSIDER_HOME)), } ) return vol.Schema(schema) diff --git a/homeassistant/components/nmap_tracker/device_tracker.py b/homeassistant/components/nmap_tracker/device_tracker.py index 5ec9f2fcb9a..e475afd24c8 100644 --- a/homeassistant/components/nmap_tracker/device_tracker.py +++ b/homeassistant/components/nmap_tracker/device_tracker.py @@ -12,7 +12,11 @@ from homeassistant.components.device_tracker import ( SOURCE_TYPE_ROUTER, ) from homeassistant.components.device_tracker.config_entry import ScannerEntity -from homeassistant.components.device_tracker.const import CONF_SCAN_INTERVAL +from homeassistant.components.device_tracker.const import ( + CONF_CONSIDER_HOME, + CONF_SCAN_INTERVAL, + DEFAULT_CONSIDER_HOME, +) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS from homeassistant.core import HomeAssistant, callback @@ -38,6 +42,9 @@ PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOSTS): cv.ensure_list, vol.Required(CONF_HOME_INTERVAL, default=0): cv.positive_int, + vol.Required( + CONF_CONSIDER_HOME, default=DEFAULT_CONSIDER_HOME.total_seconds() + ): cv.time_period, vol.Optional(CONF_EXCLUDE, default=[]): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_OPTIONS, default=DEFAULT_OPTIONS): cv.string, } @@ -53,9 +60,15 @@ async def async_get_scanner(hass: HomeAssistant, config: ConfigType) -> None: else: scan_interval = TRACKER_SCAN_INTERVAL + if CONF_CONSIDER_HOME in validated_config: + consider_home = validated_config[CONF_CONSIDER_HOME].total_seconds() + else: + consider_home = DEFAULT_CONSIDER_HOME.total_seconds() + import_config = { CONF_HOSTS: ",".join(validated_config[CONF_HOSTS]), CONF_HOME_INTERVAL: validated_config[CONF_HOME_INTERVAL], + CONF_CONSIDER_HOME: consider_home, CONF_EXCLUDE: ",".join(validated_config[CONF_EXCLUDE]), CONF_OPTIONS: validated_config[CONF_OPTIONS], CONF_SCAN_INTERVAL: scan_interval, diff --git a/homeassistant/components/nmap_tracker/strings.json b/homeassistant/components/nmap_tracker/strings.json index d42e1067503..ed5a8cb0b05 100644 --- a/homeassistant/components/nmap_tracker/strings.json +++ b/homeassistant/components/nmap_tracker/strings.json @@ -7,6 +7,7 @@ "data": { "hosts": "[%key:component::nmap_tracker::config::step::user::data::hosts%]", "home_interval": "[%key:component::nmap_tracker::config::step::user::data::home_interval%]", + "consider_home": "Seconds to wait till marking a device tracker as not home after not being seen.", "exclude": "[%key:component::nmap_tracker::config::step::user::data::exclude%]", "scan_options": "[%key:component::nmap_tracker::config::step::user::data::scan_options%]", "interval_seconds": "Scan interval" diff --git a/homeassistant/components/nmap_tracker/translations/en.json b/homeassistant/components/nmap_tracker/translations/en.json index 6b83532a0e2..feeea1ff8be 100644 --- a/homeassistant/components/nmap_tracker/translations/en.json +++ b/homeassistant/components/nmap_tracker/translations/en.json @@ -25,12 +25,12 @@ "step": { "init": { "data": { + "consider_home": "Seconds to wait till marking a device tracker as not home after not being seen.", "exclude": "Network addresses (comma seperated) to exclude from scanning", "home_interval": "Minimum number of minutes between scans of active devices (preserve battery)", "hosts": "Network addresses (comma seperated) to scan", "interval_seconds": "Scan interval", - "scan_options": "Raw configurable scan options for Nmap", - "track_new_devices": "Track new devices" + "scan_options": "Raw configurable scan options for Nmap" }, "description": "Configure hosts to be scanned by Nmap. Network address and excludes can be IP Addresses (192.168.1.1), IP Networks (192.168.0.0/24) or IP Ranges (192.168.1.0-32)." } diff --git a/tests/components/nmap_tracker/test_config_flow.py b/tests/components/nmap_tracker/test_config_flow.py index 6365dd7407a..74997df5a4f 100644 --- a/tests/components/nmap_tracker/test_config_flow.py +++ b/tests/components/nmap_tracker/test_config_flow.py @@ -4,7 +4,10 @@ from unittest.mock import patch import pytest from homeassistant import config_entries, data_entry_flow, setup -from homeassistant.components.device_tracker.const import CONF_SCAN_INTERVAL +from homeassistant.components.device_tracker.const import ( + CONF_CONSIDER_HOME, + CONF_SCAN_INTERVAL, +) from homeassistant.components.nmap_tracker.const import ( CONF_HOME_INTERVAL, CONF_OPTIONS, @@ -206,6 +209,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: CONF_EXCLUDE: "4.4.4.4", CONF_HOME_INTERVAL: 3, CONF_HOSTS: "192.168.1.0/24", + CONF_CONSIDER_HOME: 180, CONF_SCAN_INTERVAL: 120, CONF_OPTIONS: "-F -T4 --min-rate 10 --host-timeout 5s", } @@ -219,6 +223,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: user_input={ CONF_HOSTS: "192.168.1.0/24, 192.168.2.0/24", CONF_HOME_INTERVAL: 5, + CONF_CONSIDER_HOME: 500, CONF_OPTIONS: "-sn", CONF_EXCLUDE: "4.4.4.4, 5.5.5.5", CONF_SCAN_INTERVAL: 10, @@ -230,6 +235,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: assert config_entry.options == { CONF_HOSTS: "192.168.1.0/24,192.168.2.0/24", CONF_HOME_INTERVAL: 5, + CONF_CONSIDER_HOME: 500, CONF_OPTIONS: "-sn", CONF_EXCLUDE: "4.4.4.4,5.5.5.5", CONF_SCAN_INTERVAL: 10, @@ -250,6 +256,7 @@ async def test_import(hass: HomeAssistant) -> None: data={ CONF_HOSTS: "1.2.3.4/20", CONF_HOME_INTERVAL: 3, + CONF_CONSIDER_HOME: 500, CONF_OPTIONS: DEFAULT_OPTIONS, CONF_EXCLUDE: "4.4.4.4, 6.4.3.2", CONF_SCAN_INTERVAL: 2000, @@ -263,6 +270,7 @@ async def test_import(hass: HomeAssistant) -> None: assert result["options"] == { CONF_HOSTS: "1.2.3.4/20", CONF_HOME_INTERVAL: 3, + CONF_CONSIDER_HOME: 500, CONF_OPTIONS: DEFAULT_OPTIONS, CONF_EXCLUDE: "4.4.4.4,6.4.3.2", CONF_SCAN_INTERVAL: 2000,