Discover devices from device_trackers with router sources (#45160)

* Discover devices from device_trackers with router sources

* Update homeassistant/components/dhcp/__init__.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* fix stop being called on the wrong context

* clean

* move it to base

* cleanup was too agressive

* Update homeassistant/components/dhcp/__init__.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* coverage

* revert legacy changes

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
J. Nick Koston 2021-01-15 14:01:37 -10:00 committed by GitHub
parent 598a0d19b1
commit 5e01b828af
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 393 additions and 68 deletions

View File

@ -13,7 +13,7 @@ from homeassistant.const import (
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_component import EntityComponent
from .const import ATTR_SOURCE_TYPE, DOMAIN, LOGGER from .const import ATTR_HOST_NAME, ATTR_IP, ATTR_MAC, ATTR_SOURCE_TYPE, DOMAIN, LOGGER
async def async_setup_entry(hass, entry): async def async_setup_entry(hass, entry):
@ -47,6 +47,21 @@ class BaseTrackerEntity(Entity):
"""Return the source type, eg gps or router, of the device.""" """Return the source type, eg gps or router, of the device."""
raise NotImplementedError raise NotImplementedError
@property
def ip_address(self) -> str:
"""Return the primary ip address of the device."""
return None
@property
def mac_address(self) -> str:
"""Return the mac address of the device."""
return None
@property
def hostname(self) -> str:
"""Return hostname of the device."""
return None
@property @property
def state_attributes(self): def state_attributes(self):
"""Return the device state attributes.""" """Return the device state attributes."""
@ -54,6 +69,12 @@ class BaseTrackerEntity(Entity):
if self.battery_level: if self.battery_level:
attr[ATTR_BATTERY_LEVEL] = self.battery_level attr[ATTR_BATTERY_LEVEL] = self.battery_level
if self.ip_address is not None:
attr[ATTR_IP] = self.ip_address
if self.ip_address is not None:
attr[ATTR_MAC] = self.mac_address
if self.hostname is not None:
attr[ATTR_HOST_NAME] = self.hostname
return attr return attr

View File

@ -34,3 +34,4 @@ ATTR_LOCATION_NAME = "location_name"
ATTR_MAC = "mac" ATTR_MAC = "mac"
ATTR_SOURCE_TYPE = "source_type" ATTR_SOURCE_TYPE = "source_type"
ATTR_CONSIDER_HOME = "consider_home" ATTR_CONSIDER_HOME = "consider_home"
ATTR_IP = "ip"

View File

@ -1,18 +1,32 @@
"""The dhcp integration.""" """The dhcp integration."""
from abc import abstractmethod
import fnmatch import fnmatch
import logging import logging
import os import os
from threading import Event, Thread import threading
from scapy.error import Scapy_Exception from scapy.error import Scapy_Exception
from scapy.layers.dhcp import DHCP from scapy.layers.dhcp import DHCP
from scapy.layers.l2 import Ether from scapy.layers.l2 import Ether
from scapy.sendrecv import sniff from scapy.sendrecv import sniff
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.components.device_tracker.const import (
from homeassistant.core import HomeAssistant ATTR_HOST_NAME,
ATTR_IP,
ATTR_MAC,
ATTR_SOURCE_TYPE,
DOMAIN as DEVICE_TRACKER_DOMAIN,
SOURCE_TYPE_ROUTER,
)
from homeassistant.const import (
EVENT_HOMEASSISTANT_STARTED,
EVENT_HOMEASSISTANT_STOP,
STATE_HOME,
)
from homeassistant.core import Event, HomeAssistant, State, callback
from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.event import async_track_state_added_domain
from homeassistant.loader import async_get_dhcp from homeassistant.loader import async_get_dhcp
from .const import DOMAIN from .const import DOMAIN
@ -32,35 +46,162 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool:
"""Set up the dhcp component.""" """Set up the dhcp component."""
async def _initialize(_): async def _initialize(_):
dhcp_watcher = DHCPWatcher(hass, await async_get_dhcp(hass)) address_data = {}
dhcp_watcher.start() integration_matchers = await async_get_dhcp(hass)
watchers = []
def _stop(*_): for cls in (DHCPWatcher, DeviceTrackerWatcher):
dhcp_watcher.stop() watcher = cls(hass, address_data, integration_matchers)
dhcp_watcher.join() watcher.async_start()
watchers.append(watcher)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop) async def _async_stop(*_):
for watcher in watchers:
if hasattr(watcher, "async_stop"):
watcher.async_stop()
else:
await hass.async_add_executor_job(watcher.stop)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _initialize) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _initialize)
return True return True
class DHCPWatcher(Thread): class WatcherBase:
"""Class to watch dhcp requests.""" """Base class for dhcp and device tracker watching."""
def __init__(self, hass, integration_matchers): def __init__(self, hass, address_data, integration_matchers):
"""Initialize class.""" """Initialize class."""
super().__init__() super().__init__()
self.hass = hass self.hass = hass
self.name = "dhcp-discovery"
self._integration_matchers = integration_matchers self._integration_matchers = integration_matchers
self._address_data = {} self._address_data = address_data
self._stop_event = Event()
def process_client(self, ip_address, hostname, mac_address):
"""Process a client."""
data = self._address_data.get(ip_address)
if data and data[MAC_ADDRESS] == mac_address and data[HOSTNAME] == hostname:
# If the address data is the same no need
# to process it
return
self._address_data[ip_address] = {MAC_ADDRESS: mac_address, HOSTNAME: hostname}
self.process_updated_address_data(ip_address, self._address_data[ip_address])
def process_updated_address_data(self, ip_address, data):
"""Process the address data update."""
lowercase_hostname = data[HOSTNAME].lower()
uppercase_mac = data[MAC_ADDRESS].upper()
_LOGGER.debug(
"Processing updated address data for %s: mac=%s hostname=%s",
ip_address,
uppercase_mac,
lowercase_hostname,
)
for entry in self._integration_matchers:
if MAC_ADDRESS in entry and not fnmatch.fnmatch(
uppercase_mac, entry[MAC_ADDRESS]
):
continue
if HOSTNAME in entry and not fnmatch.fnmatch(
lowercase_hostname, entry[HOSTNAME]
):
continue
_LOGGER.debug("Matched %s against %s", data, entry)
self.create_task(
self.hass.config_entries.flow.async_init(
entry["domain"],
context={"source": DOMAIN},
data={IP_ADDRESS: ip_address, **data},
)
)
@abstractmethod
def create_task(self, task):
"""Pass a task to async_add_task based on which context we are in."""
class DeviceTrackerWatcher(WatcherBase):
"""Class to watch dhcp data from routers."""
def __init__(self, hass, address_data, integration_matchers):
"""Initialize class."""
super().__init__(hass, address_data, integration_matchers)
self._unsub = None
@callback
def async_stop(self):
"""Stop watching for new device trackers."""
if self._unsub:
self._unsub()
self._unsub = None
@callback
def async_start(self):
"""Stop watching for new device trackers."""
self._unsub = async_track_state_added_domain(
self.hass, [DEVICE_TRACKER_DOMAIN], self._async_process_device_event
)
for state in self.hass.states.async_all(DEVICE_TRACKER_DOMAIN):
self._async_process_device_state(state)
@callback
def _async_process_device_event(self, event: Event):
"""Process a device tracker state change event."""
self._async_process_device_state(event.data.get("new_state"))
@callback
def _async_process_device_state(self, state: State):
"""Process a device tracker state."""
if state.state != STATE_HOME:
return
attributes = state.attributes
if attributes.get(ATTR_SOURCE_TYPE) != SOURCE_TYPE_ROUTER:
return
ip_address = attributes.get(ATTR_IP)
hostname = attributes.get(ATTR_HOST_NAME)
mac_address = attributes.get(ATTR_MAC)
if ip_address is None or hostname is None or mac_address is None:
return
self.process_client(ip_address, hostname, _format_mac(mac_address))
def create_task(self, task):
"""Pass a task to async_create_task since we are in async context."""
self.hass.async_create_task(task)
class DHCPWatcher(WatcherBase, threading.Thread):
"""Class to watch dhcp requests."""
def __init__(self, hass, address_data, integration_matchers):
"""Initialize class."""
super().__init__(hass, address_data, integration_matchers)
self.name = "dhcp-discovery"
self._stop_event = threading.Event()
def stop(self): def stop(self):
"""Stop the thread.""" """Stop the thread."""
self._stop_event.set() self._stop_event.set()
self.join()
@callback
def async_start(self):
"""Start the thread."""
self.start()
def run(self): def run(self):
"""Start watching for dhcp packets.""" """Start watching for dhcp packets."""
@ -98,49 +239,11 @@ class DHCPWatcher(Thread):
if ip_address is None or hostname is None or mac_address is None: if ip_address is None or hostname is None or mac_address is None:
return return
data = self._address_data.get(ip_address) self.process_client(ip_address, hostname, mac_address)
if data and data[MAC_ADDRESS] == mac_address and data[HOSTNAME] == hostname: def create_task(self, task):
# If the address data is the same no need """Pass a task to hass.add_job since we are in a thread."""
# to process it self.hass.add_job(task)
return
self._address_data[ip_address] = {MAC_ADDRESS: mac_address, HOSTNAME: hostname}
self.process_updated_address_data(ip_address, self._address_data[ip_address])
def process_updated_address_data(self, ip_address, data):
"""Process the address data update."""
lowercase_hostname = data[HOSTNAME].lower()
uppercase_mac = data[MAC_ADDRESS].upper()
_LOGGER.debug(
"Processing updated address data for %s: mac=%s hostname=%s",
ip_address,
uppercase_mac,
lowercase_hostname,
)
for entry in self._integration_matchers:
if MAC_ADDRESS in entry and not fnmatch.fnmatch(
uppercase_mac, entry[MAC_ADDRESS]
):
continue
if HOSTNAME in entry and not fnmatch.fnmatch(
lowercase_hostname, entry[HOSTNAME]
):
continue
_LOGGER.debug("Matched %s against %s", data, entry)
self.hass.add_job(
self.hass.config_entries.flow.async_init(
entry["domain"],
context={"source": DOMAIN},
data={IP_ADDRESS: ip_address, **data},
)
)
def _decode_dhcp_option(dhcp_options, key): def _decode_dhcp_option(dhcp_options, key):

View File

@ -52,6 +52,7 @@ CLIENT_STATIC_ATTRIBUTES = [
"oui", "oui",
] ]
CLIENT_CONNECTED_ALL_ATTRIBUTES = CLIENT_CONNECTED_ATTRIBUTES + CLIENT_STATIC_ATTRIBUTES CLIENT_CONNECTED_ALL_ATTRIBUTES = CLIENT_CONNECTED_ATTRIBUTES + CLIENT_STATIC_ATTRIBUTES
DEVICE_UPGRADED = (ACCESS_POINT_UPGRADED, GATEWAY_UPGRADED, SWITCH_UPGRADED) DEVICE_UPGRADED = (ACCESS_POINT_UPGRADED, GATEWAY_UPGRADED, SWITCH_UPGRADED)
@ -239,6 +240,21 @@ class UniFiClientTracker(UniFiClient, ScannerEntity):
return attributes return attributes
@property
def ip_address(self) -> str:
"""Return the primary ip address of the device."""
return self.client.raw.get("ip")
@property
def mac_address(self) -> str:
"""Return the mac address of the device."""
return self.client.raw.get("mac")
@property
def hostname(self) -> str:
"""Return hostname of the device."""
return self.client.raw.get("hostname")
async def options_updated(self) -> None: async def options_updated(self) -> None:
"""Config entry options are updated, remove entity if option is disabled.""" """Config entry options are updated, remove entity if option is disabled."""
if not self.controller.option_track_clients: if not self.controller.option_track_clients:

View File

@ -59,3 +59,6 @@ def test_base_tracker_entity():
assert entity.battery_level is None assert entity.battery_level is None
with pytest.raises(NotImplementedError): with pytest.raises(NotImplementedError):
assert entity.state_attributes is None assert entity.state_attributes is None
assert entity.ip_address is None
assert entity.mac_address is None
assert entity.hostname is None

View File

@ -7,7 +7,19 @@ from scapy.layers.dhcp import DHCP
from scapy.layers.l2 import Ether from scapy.layers.l2 import Ether
from homeassistant.components import dhcp from homeassistant.components import dhcp
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.components.device_tracker.const import (
ATTR_HOST_NAME,
ATTR_IP,
ATTR_MAC,
ATTR_SOURCE_TYPE,
SOURCE_TYPE_ROUTER,
)
from homeassistant.const import (
EVENT_HOMEASSISTANT_STARTED,
EVENT_HOMEASSISTANT_STOP,
STATE_HOME,
STATE_NOT_HOME,
)
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from tests.common import mock_coro from tests.common import mock_coro
@ -41,6 +53,7 @@ async def test_dhcp_match_hostname_and_macaddress(hass):
"""Test matching based on hostname and macaddress.""" """Test matching based on hostname and macaddress."""
dhcp_watcher = dhcp.DHCPWatcher( dhcp_watcher = dhcp.DHCPWatcher(
hass, hass,
{},
[{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}], [{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}],
) )
@ -66,7 +79,7 @@ async def test_dhcp_match_hostname_and_macaddress(hass):
async def test_dhcp_match_hostname(hass): async def test_dhcp_match_hostname(hass):
"""Test matching based on hostname only.""" """Test matching based on hostname only."""
dhcp_watcher = dhcp.DHCPWatcher( dhcp_watcher = dhcp.DHCPWatcher(
hass, [{"domain": "mock-domain", "hostname": "connect"}] hass, {}, [{"domain": "mock-domain", "hostname": "connect"}]
) )
packet = Ether(RAW_DHCP_REQUEST) packet = Ether(RAW_DHCP_REQUEST)
@ -89,7 +102,7 @@ async def test_dhcp_match_hostname(hass):
async def test_dhcp_match_macaddress(hass): async def test_dhcp_match_macaddress(hass):
"""Test matching based on macaddress only.""" """Test matching based on macaddress only."""
dhcp_watcher = dhcp.DHCPWatcher( dhcp_watcher = dhcp.DHCPWatcher(
hass, [{"domain": "mock-domain", "macaddress": "B8B7F1*"}] hass, {}, [{"domain": "mock-domain", "macaddress": "B8B7F1*"}]
) )
packet = Ether(RAW_DHCP_REQUEST) packet = Ether(RAW_DHCP_REQUEST)
@ -112,7 +125,7 @@ async def test_dhcp_match_macaddress(hass):
async def test_dhcp_nomatch(hass): async def test_dhcp_nomatch(hass):
"""Test not matching based on macaddress only.""" """Test not matching based on macaddress only."""
dhcp_watcher = dhcp.DHCPWatcher( dhcp_watcher = dhcp.DHCPWatcher(
hass, [{"domain": "mock-domain", "macaddress": "ABC123*"}] hass, {}, [{"domain": "mock-domain", "macaddress": "ABC123*"}]
) )
packet = Ether(RAW_DHCP_REQUEST) packet = Ether(RAW_DHCP_REQUEST)
@ -128,7 +141,7 @@ async def test_dhcp_nomatch(hass):
async def test_dhcp_nomatch_hostname(hass): async def test_dhcp_nomatch_hostname(hass):
"""Test not matching based on hostname only.""" """Test not matching based on hostname only."""
dhcp_watcher = dhcp.DHCPWatcher( dhcp_watcher = dhcp.DHCPWatcher(
hass, [{"domain": "mock-domain", "hostname": "nomatch*"}] hass, {}, [{"domain": "mock-domain", "hostname": "nomatch*"}]
) )
packet = Ether(RAW_DHCP_REQUEST) packet = Ether(RAW_DHCP_REQUEST)
@ -144,7 +157,7 @@ async def test_dhcp_nomatch_hostname(hass):
async def test_dhcp_nomatch_non_dhcp_packet(hass): async def test_dhcp_nomatch_non_dhcp_packet(hass):
"""Test matching does not throw on a non-dhcp packet.""" """Test matching does not throw on a non-dhcp packet."""
dhcp_watcher = dhcp.DHCPWatcher( dhcp_watcher = dhcp.DHCPWatcher(
hass, [{"domain": "mock-domain", "hostname": "nomatch*"}] hass, {}, [{"domain": "mock-domain", "hostname": "nomatch*"}]
) )
packet = Ether(b"") packet = Ether(b"")
@ -160,7 +173,7 @@ async def test_dhcp_nomatch_non_dhcp_packet(hass):
async def test_dhcp_nomatch_non_dhcp_request_packet(hass): async def test_dhcp_nomatch_non_dhcp_request_packet(hass):
"""Test nothing happens with the wrong message-type.""" """Test nothing happens with the wrong message-type."""
dhcp_watcher = dhcp.DHCPWatcher( dhcp_watcher = dhcp.DHCPWatcher(
hass, [{"domain": "mock-domain", "hostname": "nomatch*"}] hass, {}, [{"domain": "mock-domain", "hostname": "nomatch*"}]
) )
packet = Ether(RAW_DHCP_REQUEST) packet = Ether(RAW_DHCP_REQUEST)
@ -185,7 +198,7 @@ async def test_dhcp_nomatch_non_dhcp_request_packet(hass):
async def test_dhcp_invalid_hostname(hass): async def test_dhcp_invalid_hostname(hass):
"""Test we ignore invalid hostnames.""" """Test we ignore invalid hostnames."""
dhcp_watcher = dhcp.DHCPWatcher( dhcp_watcher = dhcp.DHCPWatcher(
hass, [{"domain": "mock-domain", "hostname": "nomatch*"}] hass, {}, [{"domain": "mock-domain", "hostname": "nomatch*"}]
) )
packet = Ether(RAW_DHCP_REQUEST) packet = Ether(RAW_DHCP_REQUEST)
@ -210,7 +223,7 @@ async def test_dhcp_invalid_hostname(hass):
async def test_dhcp_missing_hostname(hass): async def test_dhcp_missing_hostname(hass):
"""Test we ignore missing hostnames.""" """Test we ignore missing hostnames."""
dhcp_watcher = dhcp.DHCPWatcher( dhcp_watcher = dhcp.DHCPWatcher(
hass, [{"domain": "mock-domain", "hostname": "nomatch*"}] hass, {}, [{"domain": "mock-domain", "hostname": "nomatch*"}]
) )
packet = Ether(RAW_DHCP_REQUEST) packet = Ether(RAW_DHCP_REQUEST)
@ -235,7 +248,7 @@ async def test_dhcp_missing_hostname(hass):
async def test_dhcp_invalid_option(hass): async def test_dhcp_invalid_option(hass):
"""Test we ignore invalid hostname option.""" """Test we ignore invalid hostname option."""
dhcp_watcher = dhcp.DHCPWatcher( dhcp_watcher = dhcp.DHCPWatcher(
hass, [{"domain": "mock-domain", "hostname": "nomatch*"}] hass, {}, [{"domain": "mock-domain", "hostname": "nomatch*"}]
) )
packet = Ether(RAW_DHCP_REQUEST) packet = Ether(RAW_DHCP_REQUEST)
@ -327,3 +340,167 @@ async def test_setup_fails_non_root(hass, caplog):
await hass.async_block_till_done() await hass.async_block_till_done()
wait_event.set() wait_event.set()
assert "Cannot watch for dhcp packets without root or CAP_NET_RAW" in caplog.text assert "Cannot watch for dhcp packets without root or CAP_NET_RAW" in caplog.text
async def test_device_tracker_hostname_and_macaddress_exists_before_start(hass):
"""Test matching based on hostname and macaddress before start."""
hass.states.async_set(
"device_tracker.august_connect",
STATE_HOME,
{
ATTR_HOST_NAME: "connect",
ATTR_IP: "192.168.210.56",
ATTR_SOURCE_TYPE: SOURCE_TYPE_ROUTER,
ATTR_MAC: "B8:B7:F1:6D:B5:33",
},
)
with patch.object(
hass.config_entries.flow, "async_init", return_value=mock_coro()
) as mock_init:
device_tracker_watcher = dhcp.DeviceTrackerWatcher(
hass,
{},
[{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}],
)
device_tracker_watcher.async_start()
await hass.async_block_till_done()
device_tracker_watcher.async_stop()
await hass.async_block_till_done()
assert len(mock_init.mock_calls) == 1
assert mock_init.mock_calls[0][1][0] == "mock-domain"
assert mock_init.mock_calls[0][2]["context"] == {"source": "dhcp"}
assert mock_init.mock_calls[0][2]["data"] == {
dhcp.IP_ADDRESS: "192.168.210.56",
dhcp.HOSTNAME: "connect",
dhcp.MAC_ADDRESS: "b8b7f16db533",
}
async def test_device_tracker_hostname_and_macaddress_after_start(hass):
"""Test matching based on hostname and macaddress after start."""
with patch.object(
hass.config_entries.flow, "async_init", return_value=mock_coro()
) as mock_init:
device_tracker_watcher = dhcp.DeviceTrackerWatcher(
hass,
{},
[{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}],
)
device_tracker_watcher.async_start()
await hass.async_block_till_done()
hass.states.async_set(
"device_tracker.august_connect",
STATE_HOME,
{
ATTR_HOST_NAME: "connect",
ATTR_IP: "192.168.210.56",
ATTR_SOURCE_TYPE: SOURCE_TYPE_ROUTER,
ATTR_MAC: "B8:B7:F1:6D:B5:33",
},
)
await hass.async_block_till_done()
device_tracker_watcher.async_stop()
await hass.async_block_till_done()
assert len(mock_init.mock_calls) == 1
assert mock_init.mock_calls[0][1][0] == "mock-domain"
assert mock_init.mock_calls[0][2]["context"] == {"source": "dhcp"}
assert mock_init.mock_calls[0][2]["data"] == {
dhcp.IP_ADDRESS: "192.168.210.56",
dhcp.HOSTNAME: "connect",
dhcp.MAC_ADDRESS: "b8b7f16db533",
}
async def test_device_tracker_hostname_and_macaddress_after_start_not_home(hass):
"""Test matching based on hostname and macaddress after start but not home."""
with patch.object(
hass.config_entries.flow, "async_init", return_value=mock_coro()
) as mock_init:
device_tracker_watcher = dhcp.DeviceTrackerWatcher(
hass,
{},
[{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}],
)
device_tracker_watcher.async_start()
await hass.async_block_till_done()
hass.states.async_set(
"device_tracker.august_connect",
STATE_NOT_HOME,
{
ATTR_HOST_NAME: "connect",
ATTR_IP: "192.168.210.56",
ATTR_SOURCE_TYPE: SOURCE_TYPE_ROUTER,
ATTR_MAC: "B8:B7:F1:6D:B5:33",
},
)
await hass.async_block_till_done()
device_tracker_watcher.async_stop()
await hass.async_block_till_done()
assert len(mock_init.mock_calls) == 0
async def test_device_tracker_hostname_and_macaddress_after_start_not_router(hass):
"""Test matching based on hostname and macaddress after start but not router."""
with patch.object(
hass.config_entries.flow, "async_init", return_value=mock_coro()
) as mock_init:
device_tracker_watcher = dhcp.DeviceTrackerWatcher(
hass,
{},
[{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}],
)
device_tracker_watcher.async_start()
await hass.async_block_till_done()
hass.states.async_set(
"device_tracker.august_connect",
STATE_HOME,
{
ATTR_HOST_NAME: "connect",
ATTR_IP: "192.168.210.56",
ATTR_SOURCE_TYPE: "something_else",
ATTR_MAC: "B8:B7:F1:6D:B5:33",
},
)
await hass.async_block_till_done()
device_tracker_watcher.async_stop()
await hass.async_block_till_done()
assert len(mock_init.mock_calls) == 0
async def test_device_tracker_hostname_and_macaddress_after_start_hostname_missing(
hass,
):
"""Test matching based on hostname and macaddress after start but missing hostname."""
with patch.object(
hass.config_entries.flow, "async_init", return_value=mock_coro()
) as mock_init:
device_tracker_watcher = dhcp.DeviceTrackerWatcher(
hass,
{},
[{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}],
)
device_tracker_watcher.async_start()
await hass.async_block_till_done()
hass.states.async_set(
"device_tracker.august_connect",
STATE_HOME,
{
ATTR_IP: "192.168.210.56",
ATTR_SOURCE_TYPE: SOURCE_TYPE_ROUTER,
ATTR_MAC: "B8:B7:F1:6D:B5:33",
},
)
await hass.async_block_till_done()
device_tracker_watcher.async_stop()
await hass.async_block_till_done()
assert len(mock_init.mock_calls) == 0

View File

@ -189,6 +189,10 @@ async def test_tracked_wireless_clients(hass):
client_1 = hass.states.get("device_tracker.client_1") client_1 = hass.states.get("device_tracker.client_1")
assert client_1.state == "home" assert client_1.state == "home"
assert client_1.attributes["ip"] == "10.0.0.1"
assert client_1.attributes["mac"] == "00:00:00:00:00:01"
assert client_1.attributes["hostname"] == "client_1"
assert client_1.attributes["host_name"] == "client_1"
# State change signalling works with events # State change signalling works with events
controller.api.websocket._data = { controller.api.websocket._data = {