From a3f148978519cb5760a1958f81361930768b0be7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 2 Jul 2021 10:24:43 -0500 Subject: [PATCH] Import track_new_devices and scan_interval from yaml for nmap_tracker (#52409) * Import track_new_devices and scan_interval from yaml for nmap_tracker * Import track_new_devices and scan_interval from yaml for nmap_tracker * Import track_new_devices and scan_interval from yaml for nmap_tracker * tests * translate * tweak * adjust * save indent * pylint * There are two CONF_SCAN_INTERVAL constants * adjust name -- there are TWO CONF_SCAN_INTERVAL constants * remove CONF_SCAN_INTERVAL/CONF_TRACK_NEW from user flow * assert it does not appear in the user step --- .../components/nmap_tracker/__init__.py | 65 ++++++++++++------- .../components/nmap_tracker/config_flow.py | 55 +++++++++++----- .../components/nmap_tracker/const.py | 2 + .../components/nmap_tracker/device_tracker.py | 37 +++++++++-- .../components/nmap_tracker/strings.json | 6 +- .../nmap_tracker/translations/en.json | 4 +- .../nmap_tracker/test_config_flow.py | 25 +++++++ 7 files changed, 146 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py index 381813a3b49..399121e4e00 100644 --- a/homeassistant/components/nmap_tracker/__init__.py +++ b/homeassistant/components/nmap_tracker/__init__.py @@ -12,6 +12,10 @@ 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, + CONF_TRACK_NEW, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS, EVENT_HOMEASSISTANT_STARTED from homeassistant.core import CoreState, HomeAssistant, callback @@ -25,6 +29,7 @@ import homeassistant.util.dt as dt_util from .const import ( CONF_HOME_INTERVAL, CONF_OPTIONS, + DEFAULT_TRACK_NEW_DEVICES, DOMAIN, NMAP_TRACKED_DEVICES, PLATFORMS, @@ -146,7 +151,10 @@ class NmapDeviceScanner: self._hosts = None self._options = None self._exclude = None + self._scan_interval = None + self._track_new_devices = None + self._known_mac_addresses = {} self._finished_first_scan = False self._last_results = [] self._mac_vendor_lookup = None @@ -154,6 +162,10 @@ class NmapDeviceScanner: async def async_setup(self): """Set up the tracker.""" config = self._entry.options + self._track_new_devices = config.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW_DEVICES) + self._scan_interval = timedelta( + seconds=config.get(CONF_SCAN_INTERVAL, TRACKER_SCAN_INTERVAL) + ) self._hosts = cv.ensure_list_csv(config[CONF_HOSTS]) self._exclude = cv.ensure_list_csv(config[CONF_EXCLUDE]) self._options = config[CONF_OPTIONS] @@ -170,6 +182,12 @@ class NmapDeviceScanner: EVENT_HOMEASSISTANT_STARTED, self._async_start_scanner ) ) + registry = er.async_get(self._hass) + self._known_mac_addresses = { + entry.unique_id: entry.original_name + for entry in registry.entities.values() + if entry.config_entry_id == self._entry_id + } @property def signal_device_new(self) -> str: @@ -199,7 +217,7 @@ class NmapDeviceScanner: async_track_time_interval( self._hass, self._async_scan_devices, - timedelta(seconds=TRACKER_SCAN_INTERVAL), + self._scan_interval, ) ) self._mac_vendor_lookup = AsyncMacLookup() @@ -258,26 +276,22 @@ class NmapDeviceScanner: # After all config entries have finished their first # scan we mark devices that were not found as not_home # from unavailable - registry = er.async_get(self._hass) now = dt_util.now() - for entry in registry.entities.values(): - if entry.config_entry_id != self._entry_id: + for mac_address, original_name in self._known_mac_addresses.items(): + if mac_address in self.devices.tracked: continue - if entry.unique_id not in self.devices.tracked: - self.devices.config_entry_owner[entry.unique_id] = self._entry_id - self.devices.tracked[entry.unique_id] = NmapDevice( - entry.unique_id, - None, - entry.original_name, - None, - self._async_get_vendor(entry.unique_id), - "Device not found in initial scan", - now, - 1, - ) - async_dispatcher_send( - self._hass, self.signal_device_missing, entry.unique_id - ) + self.devices.config_entry_owner[mac_address] = self._entry_id + self.devices.tracked[mac_address] = NmapDevice( + mac_address, + None, + original_name, + None, + self._async_get_vendor(mac_address), + "Device not found in initial scan", + now, + 1, + ) + async_dispatcher_send(self._hass, self.signal_device_missing, mac_address) def _run_nmap_scan(self): """Run nmap and return the result.""" @@ -344,21 +358,28 @@ class NmapDeviceScanner: _LOGGER.info("No MAC address found for %s", ipv4) continue - hostname = info["hostnames"][0]["name"] if info["hostnames"] else ipv4 - formatted_mac = format_mac(mac) + new = formatted_mac not in devices.tracked + if ( + new + and not self._track_new_devices + and formatted_mac not in devices.tracked + and formatted_mac not in self._known_mac_addresses + ): + continue + if ( devices.config_entry_owner.setdefault(formatted_mac, entry_id) != entry_id ): continue + hostname = info["hostnames"][0]["name"] if info["hostnames"] else ipv4 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 ) - new = formatted_mac not in devices.tracked devices.tracked[formatted_mac] = device devices.ipv4_last_mac[ipv4] = formatted_mac diff --git a/homeassistant/components/nmap_tracker/config_flow.py b/homeassistant/components/nmap_tracker/config_flow.py index 942689ad575..68e61745b63 100644 --- a/homeassistant/components/nmap_tracker/config_flow.py +++ b/homeassistant/components/nmap_tracker/config_flow.py @@ -8,13 +8,24 @@ import ifaddr import voluptuous as vol from homeassistant import config_entries +from homeassistant.components.device_tracker.const import ( + CONF_SCAN_INTERVAL, + CONF_TRACK_NEW, +) from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv from homeassistant.util import get_local_ip -from .const import CONF_HOME_INTERVAL, CONF_OPTIONS, DEFAULT_OPTIONS, DOMAIN +from .const import ( + CONF_HOME_INTERVAL, + CONF_OPTIONS, + DEFAULT_OPTIONS, + DEFAULT_TRACK_NEW_DEVICES, + DOMAIN, + TRACKER_SCAN_INTERVAL, +) DEFAULT_NETWORK_PREFIX = 24 @@ -92,23 +103,35 @@ def normalize_input(user_input): return errors -async def _async_build_schema_with_user_input(hass, user_input): +async def _async_build_schema_with_user_input(hass, user_input, include_options): hosts = user_input.get(CONF_HOSTS, await hass.async_add_executor_job(get_network)) exclude = user_input.get( CONF_EXCLUDE, await hass.async_add_executor_job(get_local_ip) ) - return vol.Schema( - { - vol.Required(CONF_HOSTS, default=hosts): str, - vol.Required( - CONF_HOME_INTERVAL, default=user_input.get(CONF_HOME_INTERVAL, 0) - ): int, - vol.Optional(CONF_EXCLUDE, default=exclude): str, - vol.Optional( - CONF_OPTIONS, default=user_input.get(CONF_OPTIONS, DEFAULT_OPTIONS) - ): str, - } - ) + schema = { + vol.Required(CONF_HOSTS, default=hosts): str, + vol.Required( + CONF_HOME_INTERVAL, default=user_input.get(CONF_HOME_INTERVAL, 0) + ): int, + vol.Optional(CONF_EXCLUDE, default=exclude): str, + vol.Optional( + CONF_OPTIONS, default=user_input.get(CONF_OPTIONS, DEFAULT_OPTIONS) + ): str, + } + if include_options: + schema.update( + { + vol.Optional( + CONF_TRACK_NEW, + default=user_input.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW_DEVICES), + ): bool, + 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)), + } + ) + return vol.Schema(schema) class OptionsFlowHandler(config_entries.OptionsFlow): @@ -133,7 +156,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): return self.async_show_form( step_id="init", data_schema=await _async_build_schema_with_user_input( - self.hass, self.options + self.hass, self.options, True ), errors=errors, ) @@ -170,7 +193,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", data_schema=await _async_build_schema_with_user_input( - self.hass, self.options + self.hass, self.options, False ), errors=errors, ) diff --git a/homeassistant/components/nmap_tracker/const.py b/homeassistant/components/nmap_tracker/const.py index e71c2d58bbb..88118a81811 100644 --- a/homeassistant/components/nmap_tracker/const.py +++ b/homeassistant/components/nmap_tracker/const.py @@ -12,3 +12,5 @@ CONF_OPTIONS = "scan_options" DEFAULT_OPTIONS = "-F --host-timeout 5s" TRACKER_SCAN_INTERVAL = 120 + +DEFAULT_TRACK_NEW_DEVICES = True diff --git a/homeassistant/components/nmap_tracker/device_tracker.py b/homeassistant/components/nmap_tracker/device_tracker.py index 24e0d3d8e26..350e75adf48 100644 --- a/homeassistant/components/nmap_tracker/device_tracker.py +++ b/homeassistant/components/nmap_tracker/device_tracker.py @@ -11,6 +11,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_NEW_DEVICE_DEFAULTS, + CONF_SCAN_INTERVAL, + CONF_TRACK_NEW, +) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS from homeassistant.core import HomeAssistant, callback @@ -19,7 +24,14 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_connect from . import NmapDeviceScanner, short_hostname, signal_device_update -from .const import CONF_HOME_INTERVAL, CONF_OPTIONS, DEFAULT_OPTIONS, DOMAIN +from .const import ( + CONF_HOME_INTERVAL, + CONF_OPTIONS, + DEFAULT_OPTIONS, + DEFAULT_TRACK_NEW_DEVICES, + DOMAIN, + TRACKER_SCAN_INTERVAL, +) _LOGGER = logging.getLogger(__name__) @@ -37,16 +49,27 @@ async def async_get_scanner(hass, config): """Validate the configuration and return a Nmap scanner.""" validated_config = config[DEVICE_TRACKER_DOMAIN] + if CONF_SCAN_INTERVAL in validated_config: + scan_interval = validated_config[CONF_SCAN_INTERVAL].total_seconds() + else: + scan_interval = TRACKER_SCAN_INTERVAL + + import_config = { + CONF_HOSTS: ",".join(validated_config[CONF_HOSTS]), + CONF_HOME_INTERVAL: validated_config[CONF_HOME_INTERVAL], + CONF_EXCLUDE: ",".join(validated_config[CONF_EXCLUDE]), + CONF_OPTIONS: validated_config[CONF_OPTIONS], + CONF_SCAN_INTERVAL: scan_interval, + CONF_TRACK_NEW: validated_config.get(CONF_NEW_DEVICE_DEFAULTS, {}).get( + CONF_TRACK_NEW, DEFAULT_TRACK_NEW_DEVICES + ), + } + hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, - data={ - CONF_HOSTS: ",".join(validated_config[CONF_HOSTS]), - CONF_HOME_INTERVAL: validated_config[CONF_HOME_INTERVAL], - CONF_EXCLUDE: ",".join(validated_config[CONF_EXCLUDE]), - CONF_OPTIONS: validated_config[CONF_OPTIONS], - }, + data=import_config, ) ) diff --git a/homeassistant/components/nmap_tracker/strings.json b/homeassistant/components/nmap_tracker/strings.json index a1e04b681cd..ecb470a6f0d 100644 --- a/homeassistant/components/nmap_tracker/strings.json +++ b/homeassistant/components/nmap_tracker/strings.json @@ -8,8 +8,10 @@ "hosts": "[%key:component::nmap_tracker::config::step::user::data::hosts%]", "home_interval": "[%key:component::nmap_tracker::config::step::user::data::home_interval%]", "exclude": "[%key:component::nmap_tracker::config::step::user::data::exclude%]", - "scan_options": "[%key:component::nmap_tracker::config::step::user::data::scan_options%]" - } + "scan_options": "[%key:component::nmap_tracker::config::step::user::data::scan_options%]", + "track_new_devices": "Track new devices", + "interval_seconds": "Scan interval" + } } }, "error": { diff --git a/homeassistant/components/nmap_tracker/translations/en.json b/homeassistant/components/nmap_tracker/translations/en.json index ed37a6a5410..6b83532a0e2 100644 --- a/homeassistant/components/nmap_tracker/translations/en.json +++ b/homeassistant/components/nmap_tracker/translations/en.json @@ -28,7 +28,9 @@ "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", - "scan_options": "Raw configurable scan options for Nmap" + "interval_seconds": "Scan interval", + "scan_options": "Raw configurable scan options for Nmap", + "track_new_devices": "Track new devices" }, "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 1556dee58d9..c4e82936b88 100644 --- a/tests/components/nmap_tracker/test_config_flow.py +++ b/tests/components/nmap_tracker/test_config_flow.py @@ -4,6 +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, + CONF_TRACK_NEW, +) from homeassistant.components.nmap_tracker.const import ( CONF_HOME_INTERVAL, CONF_OPTIONS, @@ -28,6 +32,10 @@ async def test_form(hass: HomeAssistant, hosts: str) -> None: assert result["type"] == "form" assert result["errors"] == {} + schema_defaults = result["data_schema"]({}) + assert CONF_TRACK_NEW not in schema_defaults + assert CONF_SCAN_INTERVAL not in schema_defaults + with patch( "homeassistant.components.nmap_tracker.async_setup_entry", return_value=True, @@ -198,6 +206,15 @@ async def test_options_flow(hass: HomeAssistant) -> None: assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "init" + assert result["data_schema"]({}) == { + CONF_EXCLUDE: "4.4.4.4", + CONF_HOME_INTERVAL: 3, + CONF_HOSTS: "192.168.1.0/24", + CONF_SCAN_INTERVAL: 120, + CONF_OPTIONS: "-F --host-timeout 5s", + CONF_TRACK_NEW: True, + } + with patch( "homeassistant.components.nmap_tracker.async_setup_entry", return_value=True, @@ -209,6 +226,8 @@ async def test_options_flow(hass: HomeAssistant) -> None: CONF_HOME_INTERVAL: 5, CONF_OPTIONS: "-sn", CONF_EXCLUDE: "4.4.4.4, 5.5.5.5", + CONF_SCAN_INTERVAL: 10, + CONF_TRACK_NEW: False, }, ) await hass.async_block_till_done() @@ -219,6 +238,8 @@ async def test_options_flow(hass: HomeAssistant) -> None: CONF_HOME_INTERVAL: 5, CONF_OPTIONS: "-sn", CONF_EXCLUDE: "4.4.4.4,5.5.5.5", + CONF_SCAN_INTERVAL: 10, + CONF_TRACK_NEW: False, } assert len(mock_setup_entry.mock_calls) == 1 @@ -238,6 +259,8 @@ async def test_import(hass: HomeAssistant) -> None: CONF_HOME_INTERVAL: 3, CONF_OPTIONS: DEFAULT_OPTIONS, CONF_EXCLUDE: "4.4.4.4, 6.4.3.2", + CONF_SCAN_INTERVAL: 2000, + CONF_TRACK_NEW: False, }, ) await hass.async_block_till_done() @@ -250,6 +273,8 @@ async def test_import(hass: HomeAssistant) -> None: CONF_HOME_INTERVAL: 3, CONF_OPTIONS: DEFAULT_OPTIONS, CONF_EXCLUDE: "4.4.4.4,6.4.3.2", + CONF_SCAN_INTERVAL: 2000, + CONF_TRACK_NEW: False, } assert len(mock_setup_entry.mock_calls) == 1