UniFi - Try to handle when UniFi erroneously marks offline client as wired (#26960)

* Add controls to catch when client goes offline and UniFi bug marks client as wired
* Device trackers shouldn't jump between going away and home
* POE control shouldn't add normally wireless clients as POE control switches
This commit is contained in:
Robert Svensson 2019-10-02 21:43:14 +02:00 committed by GitHub
parent 0eb1d49046
commit 09c5b9feb3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 161 additions and 22 deletions

View File

@ -1,14 +1,13 @@
"""Support for devices connected to UniFi POE.""" """Support for devices connected to UniFi POE."""
import voluptuous as vol import voluptuous as vol
from homeassistant.components.unifi.config_flow import (
get_controller_id_from_config_entry,
)
from homeassistant.const import CONF_HOST from homeassistant.const import CONF_HOST
from homeassistant.core import callback
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from .config_flow import get_controller_id_from_config_entry
from .const import ( from .const import (
ATTR_MANUFACTURER, ATTR_MANUFACTURER,
CONF_BLOCK_CLIENT, CONF_BLOCK_CLIENT,
@ -20,9 +19,14 @@ from .const import (
CONF_SSID_FILTER, CONF_SSID_FILTER,
DOMAIN, DOMAIN,
UNIFI_CONFIG, UNIFI_CONFIG,
UNIFI_WIRELESS_CLIENTS,
) )
from .controller import UniFiController from .controller import UniFiController
SAVE_DELAY = 10
STORAGE_KEY = "unifi_data"
STORAGE_VERSION = 1
CONF_CONTROLLERS = "controllers" CONF_CONTROLLERS = "controllers"
CONTROLLER_SCHEMA = vol.Schema( CONTROLLER_SCHEMA = vol.Schema(
@ -61,6 +65,9 @@ async def async_setup(hass, config):
if DOMAIN in config: if DOMAIN in config:
hass.data[UNIFI_CONFIG] = config[DOMAIN][CONF_CONTROLLERS] hass.data[UNIFI_CONFIG] = config[DOMAIN][CONF_CONTROLLERS]
hass.data[UNIFI_WIRELESS_CLIENTS] = wireless_clients = UnifiWirelessClients(hass)
await wireless_clients.async_load()
return True return True
@ -70,9 +77,7 @@ async def async_setup_entry(hass, config_entry):
hass.data[DOMAIN] = {} hass.data[DOMAIN] = {}
controller = UniFiController(hass, config_entry) controller = UniFiController(hass, config_entry)
controller_id = get_controller_id_from_config_entry(config_entry) controller_id = get_controller_id_from_config_entry(config_entry)
hass.data[DOMAIN][controller_id] = controller hass.data[DOMAIN][controller_id] = controller
if not await controller.async_setup(): if not await controller.async_setup():
@ -99,3 +104,43 @@ async def async_unload_entry(hass, config_entry):
controller_id = get_controller_id_from_config_entry(config_entry) controller_id = get_controller_id_from_config_entry(config_entry)
controller = hass.data[DOMAIN].pop(controller_id) controller = hass.data[DOMAIN].pop(controller_id)
return await controller.async_reset() return await controller.async_reset()
class UnifiWirelessClients:
"""Class to store clients known to be wireless.
This is needed since wireless devices going offline might get marked as wired by UniFi.
"""
def __init__(self, hass):
"""Set up client storage."""
self.hass = hass
self.data = {}
self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
async def async_load(self):
"""Load data from file."""
data = await self._store.async_load()
if data is not None:
self.data = data
@callback
def get_data(self, config_entry):
"""Get data related to a specific controller."""
controller_id = get_controller_id_from_config_entry(config_entry)
data = self.data.get(controller_id, {"wireless_devices": []})
return set(data["wireless_devices"])
@callback
def update_data(self, data, config_entry):
"""Update data and schedule to save to file."""
controller_id = get_controller_id_from_config_entry(config_entry)
self.data[controller_id] = {"wireless_devices": list(data)}
self._store.async_delay_save(self._data_to_save, SAVE_DELAY)
@callback
def _data_to_save(self):
"""Return data of UniFi wireless clients to store in a file."""
return self.data

View File

@ -10,6 +10,7 @@ CONF_CONTROLLER = "controller"
CONF_SITE_ID = "site" CONF_SITE_ID = "site"
UNIFI_CONFIG = "unifi_config" UNIFI_CONFIG = "unifi_config"
UNIFI_WIRELESS_CLIENTS = "unifi_wireless_clients"
CONF_BLOCK_CLIENT = "block_client" CONF_BLOCK_CLIENT = "block_client"
CONF_DETECTION_TIME = "detection_time" CONF_DETECTION_TIME = "detection_time"

View File

@ -36,6 +36,7 @@ from .const import (
DOMAIN, DOMAIN,
LOGGER, LOGGER,
UNIFI_CONFIG, UNIFI_CONFIG,
UNIFI_WIRELESS_CLIENTS,
) )
from .errors import AuthenticationRequired, CannotConnect from .errors import AuthenticationRequired, CannotConnect
@ -50,6 +51,7 @@ class UniFiController:
self.available = True self.available = True
self.api = None self.api = None
self.progress = None self.progress = None
self.wireless_clients = None
self._site_name = None self._site_name = None
self._site_role = None self._site_role = None
@ -128,6 +130,22 @@ class UniFiController:
"""Event specific per UniFi entry to signal new options.""" """Event specific per UniFi entry to signal new options."""
return f"unifi-options-{CONTROLLER_ID.format(host=self.host, site=self.site)}" return f"unifi-options-{CONTROLLER_ID.format(host=self.host, site=self.site)}"
def update_wireless_clients(self):
"""Update set of known to be wireless clients."""
new_wireless_clients = set()
for client_id in self.api.clients:
if (
client_id not in self.wireless_clients
and not self.api.clients[client_id].is_wired
):
new_wireless_clients.add(client_id)
if new_wireless_clients:
self.wireless_clients |= new_wireless_clients
unifi_wireless_clients = self.hass.data[UNIFI_WIRELESS_CLIENTS]
unifi_wireless_clients.update_data(self.wireless_clients, self.config_entry)
async def request_update(self): async def request_update(self):
"""Request an update.""" """Request an update."""
if self.progress is not None: if self.progress is not None:
@ -170,6 +188,8 @@ class UniFiController:
LOGGER.info("Reconnected to controller %s", self.host) LOGGER.info("Reconnected to controller %s", self.host)
self.available = True self.available = True
self.update_wireless_clients()
async_dispatcher_send(self.hass, self.signal_update) async_dispatcher_send(self.hass, self.signal_update)
async def async_setup(self): async def async_setup(self):
@ -197,6 +217,10 @@ class UniFiController:
LOGGER.error("Unknown error connecting with UniFi controller: %s", err) LOGGER.error("Unknown error connecting with UniFi controller: %s", err)
return False return False
wireless_clients = hass.data[UNIFI_WIRELESS_CLIENTS]
self.wireless_clients = wireless_clients.get_data(self.config_entry)
self.update_wireless_clients()
self.import_configuration() self.import_configuration()
self.config_entry.add_update_listener(self.async_options_updated) self.config_entry.add_update_listener(self.async_options_updated)

View File

@ -26,7 +26,6 @@ DEVICE_ATTRIBUTES = [
"ip", "ip",
"is_11r", "is_11r",
"is_guest", "is_guest",
"is_wired",
"mac", "mac",
"name", "name",
"noted", "noted",
@ -121,6 +120,7 @@ class UniFiClientTracker(ScannerEntity):
"""Set up tracked client.""" """Set up tracked client."""
self.client = client self.client = client
self.controller = controller self.controller = controller
self.is_wired = self.client.mac not in controller.wireless_clients
@property @property
def entity_registry_enabled_default(self): def entity_registry_enabled_default(self):
@ -129,13 +129,13 @@ class UniFiClientTracker(ScannerEntity):
return False return False
if ( if (
not self.client.is_wired not self.is_wired
and self.controller.option_ssid_filter and self.controller.option_ssid_filter
and self.client.essid not in self.controller.option_ssid_filter and self.client.essid not in self.controller.option_ssid_filter
): ):
return False return False
if not self.controller.option_track_wired_clients and self.client.is_wired: if not self.controller.option_track_wired_clients and self.is_wired:
return False return False
return True return True
@ -145,18 +145,31 @@ class UniFiClientTracker(ScannerEntity):
LOGGER.debug("New UniFi client tracker %s (%s)", self.name, self.client.mac) LOGGER.debug("New UniFi client tracker %s (%s)", self.name, self.client.mac)
async def async_update(self): async def async_update(self):
"""Synchronize state with controller.""" """Synchronize state with controller.
Make sure to update self.is_wired if client is wireless, there is an issue when clients go offline that they get marked as wired.
"""
LOGGER.debug( LOGGER.debug(
"Updating UniFi tracked client %s (%s)", self.entity_id, self.client.mac "Updating UniFi tracked client %s (%s)", self.entity_id, self.client.mac
) )
await self.controller.request_update() await self.controller.request_update()
if self.is_wired and self.client.mac in self.controller.wireless_clients:
self.is_wired = False
@property @property
def is_connected(self): def is_connected(self):
"""Return true if the client is connected to the network.""" """Return true if the client is connected to the network.
if (
dt_util.utcnow() - dt_util.utc_from_timestamp(float(self.client.last_seen)) If is_wired and client.is_wired differ it means that the device is offline and UniFi bug shows device as wired.
) < self.controller.option_detection_time: """
if self.is_wired == self.client.is_wired and (
(
dt_util.utcnow()
- dt_util.utc_from_timestamp(float(self.client.last_seen))
)
< self.controller.option_detection_time
):
return True return True
return False return False
@ -195,6 +208,8 @@ class UniFiClientTracker(ScannerEntity):
if variable in self.client.raw: if variable in self.client.raw:
attributes[variable] = self.client.raw[variable] attributes[variable] = self.client.raw[variable]
attributes["is_wired"] = self.is_wired
return attributes return attributes

View File

@ -88,7 +88,7 @@ def update_items(controller, async_add_entities, switches, switches_off):
new_switches.append(switches[block_client_id]) new_switches.append(switches[block_client_id])
LOGGER.debug("New UniFi Block switch %s (%s)", client.hostname, client.mac) LOGGER.debug("New UniFi Block switch %s (%s)", client.hostname, client.mac)
# control poe # control POE
for client_id in controller.api.clients: for client_id in controller.api.clients:
poe_client_id = f"poe-{client_id}" poe_client_id = f"poe-{client_id}"
@ -108,9 +108,10 @@ def update_items(controller, async_add_entities, switches, switches_off):
pass pass
# Network device with active POE # Network device with active POE
elif ( elif (
not client.is_wired client_id in controller.wireless_clients
or client.sw_mac not in devices or client.sw_mac not in devices
or not devices[client.sw_mac].ports[client.sw_port].port_poe or not devices[client.sw_mac].ports[client.sw_port].port_poe
or not devices[client.sw_mac].ports[client.sw_port].poe_enable
or controller.mac == client.mac or controller.mac == client.mac
): ):
continue continue

View File

@ -8,6 +8,7 @@ from homeassistant.components.unifi.const import (
CONF_CONTROLLER, CONF_CONTROLLER,
CONF_SITE_ID, CONF_SITE_ID,
UNIFI_CONFIG, UNIFI_CONFIG,
UNIFI_WIRELESS_CLIENTS,
) )
from homeassistant.const import ( from homeassistant.const import (
CONF_HOST, CONF_HOST,
@ -49,7 +50,8 @@ async def test_controller_setup():
controller.CONF_DETECTION_TIME: 30, controller.CONF_DETECTION_TIME: 30,
controller.CONF_SSID_FILTER: ["ssid"], controller.CONF_SSID_FILTER: ["ssid"],
} }
] ],
UNIFI_WIRELESS_CLIENTS: Mock(),
} }
entry = Mock() entry = Mock()
entry.data = ENTRY_CONFIG entry.data = ENTRY_CONFIG
@ -57,6 +59,7 @@ async def test_controller_setup():
api = Mock() api = Mock()
api.initialize.return_value = mock_coro(True) api.initialize.return_value = mock_coro(True)
api.sites.return_value = mock_coro(CONTROLLER_SITES) api.sites.return_value = mock_coro(CONTROLLER_SITES)
api.clients = []
unifi_controller = controller.UniFiController(hass, entry) unifi_controller = controller.UniFiController(hass, entry)
@ -100,7 +103,8 @@ async def test_controller_site():
async def test_controller_mac(): async def test_controller_mac():
"""Test that it is possible to identify controller mac.""" """Test that it is possible to identify controller mac."""
hass = Mock() hass = Mock()
hass.data = {UNIFI_CONFIG: {}} hass.data = {UNIFI_CONFIG: {}, UNIFI_WIRELESS_CLIENTS: Mock()}
hass.data[UNIFI_WIRELESS_CLIENTS].get_data.return_value = set()
entry = Mock() entry = Mock()
entry.data = ENTRY_CONFIG entry.data = ENTRY_CONFIG
entry.options = {} entry.options = {}
@ -123,7 +127,7 @@ async def test_controller_mac():
async def test_controller_no_mac(): async def test_controller_no_mac():
"""Test that it works to not find the controllers mac.""" """Test that it works to not find the controllers mac."""
hass = Mock() hass = Mock()
hass.data = {UNIFI_CONFIG: {}} hass.data = {UNIFI_CONFIG: {}, UNIFI_WIRELESS_CLIENTS: Mock()}
entry = Mock() entry = Mock()
entry.data = ENTRY_CONFIG entry.data = ENTRY_CONFIG
entry.options = {} entry.options = {}
@ -133,6 +137,7 @@ async def test_controller_no_mac():
api.initialize.return_value = mock_coro(True) api.initialize.return_value = mock_coro(True)
api.clients = {"client1": client} api.clients = {"client1": client}
api.sites.return_value = mock_coro(CONTROLLER_SITES) api.sites.return_value = mock_coro(CONTROLLER_SITES)
api.clients = {}
unifi_controller = controller.UniFiController(hass, entry) unifi_controller = controller.UniFiController(hass, entry)
@ -195,13 +200,14 @@ async def test_reset_if_entry_had_wrong_auth():
async def test_reset_unloads_entry_if_setup(): async def test_reset_unloads_entry_if_setup():
"""Calling reset when the entry has been setup.""" """Calling reset when the entry has been setup."""
hass = Mock() hass = Mock()
hass.data = {UNIFI_CONFIG: {}} hass.data = {UNIFI_CONFIG: {}, UNIFI_WIRELESS_CLIENTS: Mock()}
entry = Mock() entry = Mock()
entry.data = ENTRY_CONFIG entry.data = ENTRY_CONFIG
entry.options = {} entry.options = {}
api = Mock() api = Mock()
api.initialize.return_value = mock_coro(True) api.initialize.return_value = mock_coro(True)
api.sites.return_value = mock_coro(CONTROLLER_SITES) api.sites.return_value = mock_coro(CONTROLLER_SITES)
api.clients = []
unifi_controller = controller.UniFiController(hass, entry) unifi_controller = controller.UniFiController(hass, entry)

View File

@ -1,9 +1,11 @@
"""The tests for the UniFi device tracker platform.""" """The tests for the UniFi device tracker platform."""
from collections import deque from collections import deque
from copy import copy from copy import copy
from unittest.mock import Mock
from datetime import timedelta from datetime import timedelta
from asynctest import Mock
import pytest import pytest
from aiounifi.clients import Clients, ClientsAll from aiounifi.clients import Clients, ClientsAll
@ -19,6 +21,7 @@ from homeassistant.components.unifi.const import (
CONF_TRACK_WIRED_CLIENTS, CONF_TRACK_WIRED_CLIENTS,
CONTROLLER_ID as CONF_CONTROLLER_ID, CONTROLLER_ID as CONF_CONTROLLER_ID,
UNIFI_CONFIG, UNIFI_CONFIG,
UNIFI_WIRELESS_CLIENTS,
) )
from homeassistant.const import ( from homeassistant.const import (
CONF_HOST, CONF_HOST,
@ -96,7 +99,7 @@ CONTROLLER_DATA = {
CONF_PASSWORD: "mock-pswd", CONF_PASSWORD: "mock-pswd",
CONF_PORT: 1234, CONF_PORT: 1234,
CONF_SITE_ID: "mock-site", CONF_SITE_ID: "mock-site",
CONF_VERIFY_SSL: True, CONF_VERIFY_SSL: False,
} }
ENTRY_CONFIG = {CONF_CONTROLLER: CONTROLLER_DATA} ENTRY_CONFIG = {CONF_CONTROLLER: CONTROLLER_DATA}
@ -108,7 +111,9 @@ CONTROLLER_ID = CONF_CONTROLLER_ID.format(host="mock-host", site="mock-site")
def mock_controller(hass): def mock_controller(hass):
"""Mock a UniFi Controller.""" """Mock a UniFi Controller."""
hass.data[UNIFI_CONFIG] = {} hass.data[UNIFI_CONFIG] = {}
hass.data[UNIFI_WIRELESS_CLIENTS] = Mock()
controller = unifi.UniFiController(hass, None) controller = unifi.UniFiController(hass, None)
controller.wireless_clients = set()
controller.api = Mock() controller.api = Mock()
controller.mock_requests = [] controller.mock_requests = []
@ -253,6 +258,45 @@ async def test_tracked_devices(hass, mock_controller):
assert device_1 is None assert device_1 is None
async def test_wireless_client_go_wired_issue(hass, mock_controller):
"""Test the solution to catch wireless device go wired UniFi issue.
UniFi has a known issue that when a wireless device goes away it sometimes gets marked as wired.
"""
client_1_client = copy(CLIENT_1)
client_1_client["last_seen"] = dt_util.as_timestamp(dt_util.utcnow())
mock_controller.mock_client_responses.append([client_1_client])
mock_controller.mock_device_responses.append({})
await setup_controller(hass, mock_controller)
assert len(mock_controller.mock_requests) == 2
assert len(hass.states.async_all()) == 3
client_1 = hass.states.get("device_tracker.client_1")
assert client_1 is not None
assert client_1.state == "home"
client_1_client["is_wired"] = True
client_1_client["last_seen"] = dt_util.as_timestamp(dt_util.utcnow())
mock_controller.mock_client_responses.append([client_1_client])
mock_controller.mock_device_responses.append({})
await mock_controller.async_update()
await hass.async_block_till_done()
client_1 = hass.states.get("device_tracker.client_1")
assert client_1.state == "not_home"
client_1_client["is_wired"] = False
client_1_client["last_seen"] = dt_util.as_timestamp(dt_util.utcnow())
mock_controller.mock_client_responses.append([client_1_client])
mock_controller.mock_device_responses.append({})
await mock_controller.async_update()
await hass.async_block_till_done()
client_1 = hass.states.get("device_tracker.client_1")
assert client_1.state == "home"
async def test_restoring_client(hass, mock_controller): async def test_restoring_client(hass, mock_controller):
"""Test the update_items function with some clients.""" """Test the update_items function with some clients."""
mock_controller.mock_client_responses.append([CLIENT_2]) mock_controller.mock_client_responses.append([CLIENT_2])

View File

@ -17,6 +17,7 @@ from homeassistant.components.unifi.const import (
CONF_SITE_ID, CONF_SITE_ID,
CONTROLLER_ID as CONF_CONTROLLER_ID, CONTROLLER_ID as CONF_CONTROLLER_ID,
UNIFI_CONFIG, UNIFI_CONFIG,
UNIFI_WIRELESS_CLIENTS,
) )
from homeassistant.helpers import entity_registry from homeassistant.helpers import entity_registry
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
@ -221,7 +222,9 @@ CONTROLLER_ID = CONF_CONTROLLER_ID.format(host="mock-host", site="mock-site")
def mock_controller(hass): def mock_controller(hass):
"""Mock a UniFi Controller.""" """Mock a UniFi Controller."""
hass.data[UNIFI_CONFIG] = {} hass.data[UNIFI_CONFIG] = {}
hass.data[UNIFI_WIRELESS_CLIENTS] = Mock()
controller = unifi.UniFiController(hass, None) controller = unifi.UniFiController(hass, None)
controller.wireless_clients = set()
controller._site_role = "admin" controller._site_role = "admin"
@ -326,7 +329,7 @@ async def test_switches(hass, mock_controller):
await setup_controller(hass, mock_controller, options) await setup_controller(hass, mock_controller, options)
assert len(mock_controller.mock_requests) == 3 assert len(mock_controller.mock_requests) == 3
assert len(hass.states.async_all()) == 5 assert len(hass.states.async_all()) == 4
switch_1 = hass.states.get("switch.poe_client_1") switch_1 = hass.states.get("switch.poe_client_1")
assert switch_1 is not None assert switch_1 is not None