Skip to content

Commit 54ff491

Browse files
tedvdbemontnemery
andauthored
Implement MAC address exclude list in nmap_tracker (home-assistant#142724)
Co-authored-by: Erik <[email protected]>
1 parent 2512dad commit 54ff491

File tree

5 files changed

+100
-5
lines changed

5 files changed

+100
-5
lines changed

homeassistant/components/nmap_tracker/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929

3030
from .const import (
3131
CONF_HOME_INTERVAL,
32+
CONF_MAC_EXCLUDE,
3233
CONF_OPTIONS,
3334
DOMAIN,
3435
NMAP_TRACKED_DEVICES,
@@ -145,6 +146,7 @@ def __init__(
145146
self._hosts = None
146147
self._options = None
147148
self._exclude = None
149+
self._mac_exclude = None
148150
self._scan_interval = None
149151

150152
self._known_mac_addresses: dict[str, str] = {}
@@ -161,6 +163,7 @@ async def async_setup(self):
161163
self._hosts = [host for host in hosts_list if host != ""]
162164
excludes_list = cv.ensure_list_csv(config[CONF_EXCLUDE])
163165
self._exclude = [exclude for exclude in excludes_list if exclude != ""]
166+
self._mac_exclude = config.get(CONF_MAC_EXCLUDE, [])
164167
self._options = config[CONF_OPTIONS]
165168
self.home_interval = timedelta(
166169
minutes=cv.positive_int(config[CONF_HOME_INTERVAL])
@@ -377,6 +380,11 @@ async def _async_run_nmap_scan(self):
377380
continue
378381

379382
formatted_mac = format_mac(mac)
383+
384+
if formatted_mac in self._mac_exclude:
385+
_LOGGER.debug("MAC address %s is excluded from tracking", formatted_mac)
386+
continue
387+
380388
if (
381389
devices.config_entry_owner.setdefault(formatted_mac, entry_id)
382390
!= entry_id

homeassistant/components/nmap_tracker/config_flow.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
from ipaddress import ip_address, ip_network, summarize_address_range
6+
import re
67
from typing import Any
78

89
import voluptuous as vol
@@ -23,10 +24,13 @@
2324
from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS
2425
from homeassistant.core import HomeAssistant, callback
2526
from homeassistant.helpers import config_validation as cv
27+
from homeassistant.helpers.device_registry import format_mac
28+
from homeassistant.helpers.selector import TextSelector, TextSelectorConfig
2629
from homeassistant.helpers.typing import VolDictType
2730

2831
from .const import (
2932
CONF_HOME_INTERVAL,
33+
CONF_MAC_EXCLUDE,
3034
CONF_OPTIONS,
3135
DEFAULT_OPTIONS,
3236
DOMAIN,
@@ -86,6 +90,31 @@ def _normalize_ips_and_network(hosts_str: str) -> list[str] | None:
8690
return normalized_hosts
8791

8892

93+
def _is_valid_mac(mac_address: str) -> bool:
94+
"""Check if a mac address is valid."""
95+
is_valid_mac = re.fullmatch(
96+
r"[0-9A-F]{12}", string=mac_address, flags=re.IGNORECASE
97+
)
98+
if is_valid_mac is not None:
99+
return True
100+
return False
101+
102+
103+
def _normalize_mac_addresses(mac_addresses: list[str]) -> list[str] | None:
104+
"""Check if a list of mac addresses are all valid."""
105+
normalized_mac_addresses = []
106+
107+
for mac_address in sorted(mac_addresses):
108+
mac_address = mac_address.replace(":", "").replace("-", "").upper().strip()
109+
if not _is_valid_mac(mac_address):
110+
return None
111+
112+
formatted_mac_address = format_mac(mac_address)
113+
normalized_mac_addresses.append(formatted_mac_address)
114+
115+
return normalized_mac_addresses
116+
117+
89118
def normalize_input(user_input: dict[str, Any]) -> dict[str, str]:
90119
"""Validate hosts and exclude are valid."""
91120
errors = {}
@@ -101,6 +130,12 @@ def normalize_input(user_input: dict[str, Any]) -> dict[str, str]:
101130
else:
102131
user_input[CONF_EXCLUDE] = ",".join(normalized_exclude)
103132

133+
normalized_mac_exclude = _normalize_mac_addresses(user_input[CONF_MAC_EXCLUDE])
134+
if normalized_mac_exclude is None:
135+
errors[CONF_MAC_EXCLUDE] = "invalid_hosts"
136+
else:
137+
user_input[CONF_MAC_EXCLUDE] = normalized_mac_exclude
138+
104139
return errors
105140

106141

@@ -111,12 +146,17 @@ async def _async_build_schema_with_user_input(
111146
exclude = user_input.get(
112147
CONF_EXCLUDE, await network.async_get_source_ip(hass, MDNS_TARGET_IP)
113148
)
149+
mac_exclude = user_input.get(CONF_MAC_EXCLUDE, [])
150+
114151
schema: VolDictType = {
115152
vol.Required(CONF_HOSTS, default=hosts): str,
116153
vol.Required(
117154
CONF_HOME_INTERVAL, default=user_input.get(CONF_HOME_INTERVAL, 0)
118155
): int,
119156
vol.Optional(CONF_EXCLUDE, default=exclude): str,
157+
vol.Optional(CONF_MAC_EXCLUDE, default=mac_exclude): TextSelector(
158+
TextSelectorConfig(multiple=True)
159+
),
120160
vol.Optional(
121161
CONF_OPTIONS, default=user_input.get(CONF_OPTIONS, DEFAULT_OPTIONS)
122162
): str,

homeassistant/components/nmap_tracker/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# Interval in minutes to exclude devices from a scan while they are home
1414
CONF_HOME_INTERVAL: Final = "home_interval"
1515
CONF_OPTIONS: Final = "scan_options"
16+
CONF_MAC_EXCLUDE: Final = "mac_exclude"
1617
DEFAULT_OPTIONS: Final = "-n -sn -PR -T4 --min-rate 10 --host-timeout 5s"
1718

1819
TRACKER_SCAN_INTERVAL: Final = 120

homeassistant/components/nmap_tracker/strings.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"home_interval": "[%key:component::nmap_tracker::config::step::user::data::home_interval%]",
1010
"consider_home": "Seconds to wait till marking a device tracker as not home after not being seen.",
1111
"exclude": "[%key:component::nmap_tracker::config::step::user::data::exclude%]",
12+
"mac_exclude": "[%key:component::nmap_tracker::config::step::user::data::mac_exclude%]",
1213
"scan_options": "[%key:component::nmap_tracker::config::step::user::data::scan_options%]",
1314
"interval_seconds": "Scan interval"
1415
}
@@ -21,11 +22,12 @@
2122
"config": {
2223
"step": {
2324
"user": {
24-
"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).",
25+
"description": "Configure hosts to be scanned by Nmap. IP address and excludes can be addresses (192.168.1.1), networks (192.168.0.0/24) or ranges (192.168.1.0-32).",
2526
"data": {
26-
"hosts": "Network addresses (comma-separated) to scan",
27+
"hosts": "IP addresses or ranges (comma-separated) to scan",
2728
"home_interval": "Minimum number of minutes between scans of active devices (preserve battery)",
28-
"exclude": "Network addresses (comma-separated) to exclude from scanning",
29+
"exclude": "IP addresses (comma-separated) to exclude from tracking",
30+
"mac_exclude": "MAC address to exclude from tracking",
2931
"scan_options": "Raw configurable scan options for Nmap"
3032
}
3133
}

tests/components/nmap_tracker/test_config_flow.py

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
)
1212
from homeassistant.components.nmap_tracker.const import (
1313
CONF_HOME_INTERVAL,
14+
CONF_MAC_EXCLUDE,
1415
CONF_OPTIONS,
1516
DEFAULT_OPTIONS,
1617
DOMAIN,
@@ -48,6 +49,7 @@ async def test_form(hass: HomeAssistant, hosts: str) -> None:
4849
CONF_HOME_INTERVAL: 3,
4950
CONF_OPTIONS: DEFAULT_OPTIONS,
5051
CONF_EXCLUDE: "4.4.4.4",
52+
CONF_MAC_EXCLUDE: ["00:00:00:00:00:00"],
5153
},
5254
)
5355
await hass.async_block_till_done()
@@ -60,6 +62,7 @@ async def test_form(hass: HomeAssistant, hosts: str) -> None:
6062
CONF_HOME_INTERVAL: 3,
6163
CONF_OPTIONS: DEFAULT_OPTIONS,
6264
CONF_EXCLUDE: "4.4.4.4",
65+
CONF_MAC_EXCLUDE: ["00:00:00:00:00:00"],
6366
}
6467
assert len(mock_setup_entry.mock_calls) == 1
6568

@@ -84,6 +87,7 @@ async def test_form_range(hass: HomeAssistant) -> None:
8487
CONF_HOME_INTERVAL: 3,
8588
CONF_OPTIONS: DEFAULT_OPTIONS,
8689
CONF_EXCLUDE: "4.4.4.4",
90+
CONF_MAC_EXCLUDE: ["00:00:00:00:00:00"],
8791
},
8892
)
8993
await hass.async_block_till_done()
@@ -96,6 +100,7 @@ async def test_form_range(hass: HomeAssistant) -> None:
96100
CONF_HOME_INTERVAL: 3,
97101
CONF_OPTIONS: DEFAULT_OPTIONS,
98102
CONF_EXCLUDE: "4.4.4.4",
103+
CONF_MAC_EXCLUDE: ["00:00:00:00:00:00"],
99104
}
100105
assert len(mock_setup_entry.mock_calls) == 1
101106

@@ -116,6 +121,7 @@ async def test_form_invalid_hosts(hass: HomeAssistant) -> None:
116121
CONF_HOME_INTERVAL: 3,
117122
CONF_OPTIONS: DEFAULT_OPTIONS,
118123
CONF_EXCLUDE: "",
124+
CONF_MAC_EXCLUDE: ["00:00:00:00:00:00"],
119125
},
120126
)
121127
await hass.async_block_till_done()
@@ -135,6 +141,7 @@ async def test_form_already_configured(hass: HomeAssistant) -> None:
135141
CONF_HOME_INTERVAL: 3,
136142
CONF_OPTIONS: DEFAULT_OPTIONS,
137143
CONF_EXCLUDE: "4.4.4.4",
144+
CONF_MAC_EXCLUDE: ["00:00:00:00:00:00"],
138145
},
139146
)
140147
config_entry.add_to_hass(hass)
@@ -151,6 +158,7 @@ async def test_form_already_configured(hass: HomeAssistant) -> None:
151158
CONF_HOME_INTERVAL: 3,
152159
CONF_OPTIONS: DEFAULT_OPTIONS,
153160
CONF_EXCLUDE: "",
161+
CONF_MAC_EXCLUDE: ["00:00:00:00:00:00"],
154162
},
155163
)
156164
await hass.async_block_till_done()
@@ -159,8 +167,8 @@ async def test_form_already_configured(hass: HomeAssistant) -> None:
159167
assert result2["reason"] == "already_configured"
160168

161169

162-
async def test_form_invalid_excludes(hass: HomeAssistant) -> None:
163-
"""Test invalid excludes passed in."""
170+
async def test_form_invalid_ip_excludes(hass: HomeAssistant) -> None:
171+
"""Test invalid ip excludes passed in."""
164172

165173
result = await hass.config_entries.flow.async_init(
166174
DOMAIN, context={"source": config_entries.SOURCE_USER}
@@ -175,6 +183,7 @@ async def test_form_invalid_excludes(hass: HomeAssistant) -> None:
175183
CONF_HOME_INTERVAL: 3,
176184
CONF_OPTIONS: DEFAULT_OPTIONS,
177185
CONF_EXCLUDE: "not an exclude",
186+
CONF_MAC_EXCLUDE: ["00:00:00:00:00:00"],
178187
},
179188
)
180189
await hass.async_block_till_done()
@@ -183,6 +192,37 @@ async def test_form_invalid_excludes(hass: HomeAssistant) -> None:
183192
assert result2["errors"] == {CONF_EXCLUDE: "invalid_hosts"}
184193

185194

195+
@pytest.mark.parametrize(
196+
"mac_excludes",
197+
[["1234567890"], ["1234567890", "11:22:33:44:55:66"], ["ABCDEFGHIJK"]],
198+
)
199+
async def test_form_invalid_mac_excludes(
200+
hass: HomeAssistant, mac_excludes: str
201+
) -> None:
202+
"""Test invalid mac excludes passed in."""
203+
204+
result = await hass.config_entries.flow.async_init(
205+
DOMAIN, context={"source": config_entries.SOURCE_USER}
206+
)
207+
assert result["type"] is FlowResultType.FORM
208+
assert result["errors"] == {}
209+
210+
result2 = await hass.config_entries.flow.async_configure(
211+
result["flow_id"],
212+
{
213+
CONF_HOSTS: "3.3.3.3",
214+
CONF_HOME_INTERVAL: 3,
215+
CONF_OPTIONS: DEFAULT_OPTIONS,
216+
CONF_EXCLUDE: "4.4.4.4",
217+
CONF_MAC_EXCLUDE: mac_excludes,
218+
},
219+
)
220+
await hass.async_block_till_done()
221+
222+
assert result2["type"] is FlowResultType.FORM
223+
assert result2["errors"] == {CONF_MAC_EXCLUDE: "invalid_hosts"}
224+
225+
186226
async def test_options_flow(hass: HomeAssistant) -> None:
187227
"""Test we can edit options."""
188228

@@ -194,6 +234,7 @@ async def test_options_flow(hass: HomeAssistant) -> None:
194234
CONF_HOME_INTERVAL: 3,
195235
CONF_OPTIONS: DEFAULT_OPTIONS,
196236
CONF_EXCLUDE: "4.4.4.4",
237+
CONF_MAC_EXCLUDE: ["00:00:00:00:00:00", "11:22:33:44:55:66"],
197238
},
198239
)
199240
config_entry.add_to_hass(hass)
@@ -214,6 +255,7 @@ async def test_options_flow(hass: HomeAssistant) -> None:
214255
CONF_CONSIDER_HOME: 180,
215256
CONF_SCAN_INTERVAL: 120,
216257
CONF_OPTIONS: "-n -sn -PR -T4 --min-rate 10 --host-timeout 5s",
258+
CONF_MAC_EXCLUDE: ["00:00:00:00:00:00", "11:22:33:44:55:66"],
217259
}
218260

219261
with patch(
@@ -229,6 +271,7 @@ async def test_options_flow(hass: HomeAssistant) -> None:
229271
CONF_OPTIONS: "-sn",
230272
CONF_EXCLUDE: "4.4.4.4, 5.5.5.5",
231273
CONF_SCAN_INTERVAL: 10,
274+
CONF_MAC_EXCLUDE: ["00:00:00:00:00:00", "11:22:33:44:55:66"],
232275
},
233276
)
234277
await hass.async_block_till_done()
@@ -241,5 +284,6 @@ async def test_options_flow(hass: HomeAssistant) -> None:
241284
CONF_OPTIONS: "-sn",
242285
CONF_EXCLUDE: "4.4.4.4,5.5.5.5",
243286
CONF_SCAN_INTERVAL: 10,
287+
CONF_MAC_EXCLUDE: ["00:00:00:00:00:00", "11:22:33:44:55:66"],
244288
}
245289
assert len(mock_setup_entry.mock_calls) == 1

0 commit comments

Comments
 (0)