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
This commit is contained in:
J. Nick Koston 2021-07-02 10:24:43 -05:00 committed by GitHub
parent 98fdb00bc7
commit a3f1489785
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 146 additions and 48 deletions

View File

@ -12,6 +12,10 @@ from getmac import get_mac_address
from mac_vendor_lookup import AsyncMacLookup from mac_vendor_lookup import AsyncMacLookup
from nmap import PortScanner, PortScannerError 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.config_entries import ConfigEntry
from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS, EVENT_HOMEASSISTANT_STARTED from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS, EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import CoreState, HomeAssistant, callback from homeassistant.core import CoreState, HomeAssistant, callback
@ -25,6 +29,7 @@ import homeassistant.util.dt as dt_util
from .const import ( from .const import (
CONF_HOME_INTERVAL, CONF_HOME_INTERVAL,
CONF_OPTIONS, CONF_OPTIONS,
DEFAULT_TRACK_NEW_DEVICES,
DOMAIN, DOMAIN,
NMAP_TRACKED_DEVICES, NMAP_TRACKED_DEVICES,
PLATFORMS, PLATFORMS,
@ -146,7 +151,10 @@ class NmapDeviceScanner:
self._hosts = None self._hosts = None
self._options = None self._options = None
self._exclude = None self._exclude = None
self._scan_interval = None
self._track_new_devices = None
self._known_mac_addresses = {}
self._finished_first_scan = False self._finished_first_scan = False
self._last_results = [] self._last_results = []
self._mac_vendor_lookup = None self._mac_vendor_lookup = None
@ -154,6 +162,10 @@ class NmapDeviceScanner:
async def async_setup(self): async def async_setup(self):
"""Set up the tracker.""" """Set up the tracker."""
config = self._entry.options 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._hosts = cv.ensure_list_csv(config[CONF_HOSTS])
self._exclude = cv.ensure_list_csv(config[CONF_EXCLUDE]) self._exclude = cv.ensure_list_csv(config[CONF_EXCLUDE])
self._options = config[CONF_OPTIONS] self._options = config[CONF_OPTIONS]
@ -170,6 +182,12 @@ class NmapDeviceScanner:
EVENT_HOMEASSISTANT_STARTED, self._async_start_scanner 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 @property
def signal_device_new(self) -> str: def signal_device_new(self) -> str:
@ -199,7 +217,7 @@ class NmapDeviceScanner:
async_track_time_interval( async_track_time_interval(
self._hass, self._hass,
self._async_scan_devices, self._async_scan_devices,
timedelta(seconds=TRACKER_SCAN_INTERVAL), self._scan_interval,
) )
) )
self._mac_vendor_lookup = AsyncMacLookup() self._mac_vendor_lookup = AsyncMacLookup()
@ -258,26 +276,22 @@ class NmapDeviceScanner:
# After all config entries have finished their first # After all config entries have finished their first
# scan we mark devices that were not found as not_home # scan we mark devices that were not found as not_home
# from unavailable # from unavailable
registry = er.async_get(self._hass)
now = dt_util.now() now = dt_util.now()
for entry in registry.entities.values(): for mac_address, original_name in self._known_mac_addresses.items():
if entry.config_entry_id != self._entry_id: if mac_address in self.devices.tracked:
continue continue
if entry.unique_id not in self.devices.tracked: self.devices.config_entry_owner[mac_address] = self._entry_id
self.devices.config_entry_owner[entry.unique_id] = self._entry_id self.devices.tracked[mac_address] = NmapDevice(
self.devices.tracked[entry.unique_id] = NmapDevice( mac_address,
entry.unique_id, None,
None, original_name,
entry.original_name, None,
None, self._async_get_vendor(mac_address),
self._async_get_vendor(entry.unique_id), "Device not found in initial scan",
"Device not found in initial scan", now,
now, 1,
1, )
) async_dispatcher_send(self._hass, self.signal_device_missing, mac_address)
async_dispatcher_send(
self._hass, self.signal_device_missing, entry.unique_id
)
def _run_nmap_scan(self): def _run_nmap_scan(self):
"""Run nmap and return the result.""" """Run nmap and return the result."""
@ -344,21 +358,28 @@ class NmapDeviceScanner:
_LOGGER.info("No MAC address found for %s", ipv4) _LOGGER.info("No MAC address found for %s", ipv4)
continue continue
hostname = info["hostnames"][0]["name"] if info["hostnames"] else ipv4
formatted_mac = format_mac(mac) 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 ( if (
devices.config_entry_owner.setdefault(formatted_mac, entry_id) devices.config_entry_owner.setdefault(formatted_mac, entry_id)
!= entry_id != entry_id
): ):
continue continue
hostname = info["hostnames"][0]["name"] if info["hostnames"] else ipv4
vendor = info.get("vendor", {}).get(mac) or self._async_get_vendor(mac) vendor = info.get("vendor", {}).get(mac) or self._async_get_vendor(mac)
name = human_readable_name(hostname, vendor, mac) name = human_readable_name(hostname, vendor, mac)
device = NmapDevice( device = NmapDevice(
formatted_mac, hostname, name, ipv4, vendor, reason, now, 0 formatted_mac, hostname, name, ipv4, vendor, reason, now, 0
) )
new = formatted_mac not in devices.tracked
devices.tracked[formatted_mac] = device devices.tracked[formatted_mac] = device
devices.ipv4_last_mac[ipv4] = formatted_mac devices.ipv4_last_mac[ipv4] = formatted_mac

View File

@ -8,13 +8,24 @@ import ifaddr
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries 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.const import CONF_EXCLUDE, CONF_HOSTS
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult from homeassistant.data_entry_flow import FlowResult
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.util import get_local_ip 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 DEFAULT_NETWORK_PREFIX = 24
@ -92,23 +103,35 @@ def normalize_input(user_input):
return errors 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)) hosts = user_input.get(CONF_HOSTS, await hass.async_add_executor_job(get_network))
exclude = user_input.get( exclude = user_input.get(
CONF_EXCLUDE, await hass.async_add_executor_job(get_local_ip) CONF_EXCLUDE, await hass.async_add_executor_job(get_local_ip)
) )
return vol.Schema( schema = {
{ vol.Required(CONF_HOSTS, default=hosts): str,
vol.Required(CONF_HOSTS, default=hosts): str, vol.Required(
vol.Required( CONF_HOME_INTERVAL, default=user_input.get(CONF_HOME_INTERVAL, 0)
CONF_HOME_INTERVAL, default=user_input.get(CONF_HOME_INTERVAL, 0) ): int,
): int, vol.Optional(CONF_EXCLUDE, default=exclude): str,
vol.Optional(CONF_EXCLUDE, default=exclude): str, vol.Optional(
vol.Optional( CONF_OPTIONS, default=user_input.get(CONF_OPTIONS, DEFAULT_OPTIONS)
CONF_OPTIONS, default=user_input.get(CONF_OPTIONS, DEFAULT_OPTIONS) ): str,
): 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): class OptionsFlowHandler(config_entries.OptionsFlow):
@ -133,7 +156,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
return self.async_show_form( return self.async_show_form(
step_id="init", step_id="init",
data_schema=await _async_build_schema_with_user_input( data_schema=await _async_build_schema_with_user_input(
self.hass, self.options self.hass, self.options, True
), ),
errors=errors, errors=errors,
) )
@ -170,7 +193,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return self.async_show_form( return self.async_show_form(
step_id="user", step_id="user",
data_schema=await _async_build_schema_with_user_input( data_schema=await _async_build_schema_with_user_input(
self.hass, self.options self.hass, self.options, False
), ),
errors=errors, errors=errors,
) )

View File

@ -12,3 +12,5 @@ CONF_OPTIONS = "scan_options"
DEFAULT_OPTIONS = "-F --host-timeout 5s" DEFAULT_OPTIONS = "-F --host-timeout 5s"
TRACKER_SCAN_INTERVAL = 120 TRACKER_SCAN_INTERVAL = 120
DEFAULT_TRACK_NEW_DEVICES = True

View File

@ -11,6 +11,11 @@ from homeassistant.components.device_tracker import (
SOURCE_TYPE_ROUTER, SOURCE_TYPE_ROUTER,
) )
from homeassistant.components.device_tracker.config_entry import ScannerEntity 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.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS
from homeassistant.core import HomeAssistant, callback 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 homeassistant.helpers.dispatcher import async_dispatcher_connect
from . import NmapDeviceScanner, short_hostname, signal_device_update 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__) _LOGGER = logging.getLogger(__name__)
@ -37,16 +49,27 @@ async def async_get_scanner(hass, config):
"""Validate the configuration and return a Nmap scanner.""" """Validate the configuration and return a Nmap scanner."""
validated_config = config[DEVICE_TRACKER_DOMAIN] 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.async_create_task(
hass.config_entries.flow.async_init( hass.config_entries.flow.async_init(
DOMAIN, DOMAIN,
context={"source": SOURCE_IMPORT}, context={"source": SOURCE_IMPORT},
data={ data=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],
},
) )
) )

View File

@ -8,8 +8,10 @@
"hosts": "[%key:component::nmap_tracker::config::step::user::data::hosts%]", "hosts": "[%key:component::nmap_tracker::config::step::user::data::hosts%]",
"home_interval": "[%key:component::nmap_tracker::config::step::user::data::home_interval%]", "home_interval": "[%key:component::nmap_tracker::config::step::user::data::home_interval%]",
"exclude": "[%key:component::nmap_tracker::config::step::user::data::exclude%]", "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": { "error": {

View File

@ -28,7 +28,9 @@
"exclude": "Network addresses (comma seperated) to exclude from scanning", "exclude": "Network addresses (comma seperated) to exclude from scanning",
"home_interval": "Minimum number of minutes between scans of active devices (preserve battery)", "home_interval": "Minimum number of minutes between scans of active devices (preserve battery)",
"hosts": "Network addresses (comma seperated) to scan", "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)." "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)."
} }

View File

@ -4,6 +4,10 @@ from unittest.mock import patch
import pytest import pytest
from homeassistant import config_entries, data_entry_flow, setup 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 ( from homeassistant.components.nmap_tracker.const import (
CONF_HOME_INTERVAL, CONF_HOME_INTERVAL,
CONF_OPTIONS, CONF_OPTIONS,
@ -28,6 +32,10 @@ async def test_form(hass: HomeAssistant, hosts: str) -> None:
assert result["type"] == "form" assert result["type"] == "form"
assert result["errors"] == {} 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( with patch(
"homeassistant.components.nmap_tracker.async_setup_entry", "homeassistant.components.nmap_tracker.async_setup_entry",
return_value=True, 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["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "init" 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( with patch(
"homeassistant.components.nmap_tracker.async_setup_entry", "homeassistant.components.nmap_tracker.async_setup_entry",
return_value=True, return_value=True,
@ -209,6 +226,8 @@ async def test_options_flow(hass: HomeAssistant) -> None:
CONF_HOME_INTERVAL: 5, CONF_HOME_INTERVAL: 5,
CONF_OPTIONS: "-sn", CONF_OPTIONS: "-sn",
CONF_EXCLUDE: "4.4.4.4, 5.5.5.5", CONF_EXCLUDE: "4.4.4.4, 5.5.5.5",
CONF_SCAN_INTERVAL: 10,
CONF_TRACK_NEW: False,
}, },
) )
await hass.async_block_till_done() await hass.async_block_till_done()
@ -219,6 +238,8 @@ async def test_options_flow(hass: HomeAssistant) -> None:
CONF_HOME_INTERVAL: 5, CONF_HOME_INTERVAL: 5,
CONF_OPTIONS: "-sn", CONF_OPTIONS: "-sn",
CONF_EXCLUDE: "4.4.4.4,5.5.5.5", 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 assert len(mock_setup_entry.mock_calls) == 1
@ -238,6 +259,8 @@ async def test_import(hass: HomeAssistant) -> None:
CONF_HOME_INTERVAL: 3, CONF_HOME_INTERVAL: 3,
CONF_OPTIONS: DEFAULT_OPTIONS, CONF_OPTIONS: DEFAULT_OPTIONS,
CONF_EXCLUDE: "4.4.4.4, 6.4.3.2", CONF_EXCLUDE: "4.4.4.4, 6.4.3.2",
CONF_SCAN_INTERVAL: 2000,
CONF_TRACK_NEW: False,
}, },
) )
await hass.async_block_till_done() await hass.async_block_till_done()
@ -250,6 +273,8 @@ async def test_import(hass: HomeAssistant) -> None:
CONF_HOME_INTERVAL: 3, CONF_HOME_INTERVAL: 3,
CONF_OPTIONS: DEFAULT_OPTIONS, CONF_OPTIONS: DEFAULT_OPTIONS,
CONF_EXCLUDE: "4.4.4.4,6.4.3.2", 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 assert len(mock_setup_entry.mock_calls) == 1