Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 49 additions & 5 deletions homeassistant/components/unifi/__init__.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
"""Support for devices connected to UniFi POE."""
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.core import callback
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC

import homeassistant.helpers.config_validation as cv

from .config_flow import get_controller_id_from_config_entry
from .const import (
ATTR_MANUFACTURER,
CONF_BLOCK_CLIENT,
Expand All @@ -20,9 +19,14 @@
CONF_SSID_FILTER,
DOMAIN,
UNIFI_CONFIG,
UNIFI_WIRELESS_CLIENTS,
)
from .controller import UniFiController

SAVE_DELAY = 10
STORAGE_KEY = "unifi_data"
STORAGE_VERSION = 1

CONF_CONTROLLERS = "controllers"

CONTROLLER_SCHEMA = vol.Schema(
Expand Down Expand Up @@ -61,6 +65,9 @@ async def async_setup(hass, config):
if DOMAIN in config:
hass.data[UNIFI_CONFIG] = config[DOMAIN][CONF_CONTROLLERS]

hass.data[UNIFI_WIRELESS_CLIENTS] = wireless_clients = unifi_wireless_clients(hass)
await wireless_clients.async_load()

return True


Expand All @@ -70,9 +77,7 @@ async def async_setup_entry(hass, config_entry):
hass.data[DOMAIN] = {}

controller = UniFiController(hass, config_entry)

controller_id = get_controller_id_from_config_entry(config_entry)

hass.data[DOMAIN][controller_id] = controller

if not await controller.async_setup():
Expand All @@ -99,3 +104,42 @@ async def async_unload_entry(hass, config_entry):
controller_id = get_controller_id_from_config_entry(config_entry)
controller = hass.data[DOMAIN].pop(controller_id)
return await controller.async_reset()


class unifi_wireless_clients:
"""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)
return set(self.data.get(controller_id, list()))
Comment thread
Kane610 marked this conversation as resolved.
Outdated

@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] = list(data)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would we not pass in a list already?

@Kane610 Kane610 Oct 2, 2019

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

data is a set from one specific controller containing all known wireless devices


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
1 change: 1 addition & 0 deletions homeassistant/components/unifi/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
CONF_SITE_ID = "site"

UNIFI_CONFIG = "unifi_config"
UNIFI_WIRELESS_CLIENTS = "unifi_wireless_clients"

CONF_BLOCK_CLIENT = "block_client"
CONF_DETECTION_TIME = "detection_time"
Expand Down
24 changes: 24 additions & 0 deletions homeassistant/components/unifi/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
DOMAIN,
LOGGER,
UNIFI_CONFIG,
UNIFI_WIRELESS_CLIENTS,
)
from .errors import AuthenticationRequired, CannotConnect

Expand All @@ -50,6 +51,7 @@ def __init__(self, hass, config_entry):
self.available = True
self.api = None
self.progress = None
self.wireless_clients = None

self._site_name = None
self._site_role = None
Expand Down Expand Up @@ -128,6 +130,22 @@ def signal_options_update(self):
"""Event specific per UniFi entry to signal new options."""
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."""
wireless_clients = set()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you prefer sets, just convert it to sets when you load the data and convert it to lists when you save it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just a local list to know which new wireless clients we see from last update


for client_id in self.api.clients:
if (
client_id not in self.wireless_clients
and not self.api.clients[client_id].is_wired
):
wireless_clients.add(client_id)

if wireless_clients:
self.wireless_clients |= 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):
"""Request an update."""
if self.progress is not None:
Expand Down Expand Up @@ -170,6 +188,8 @@ async def async_update(self):
LOGGER.info("Reconnected to controller %s", self.host)
self.available = True

self.update_wireless_clients()

async_dispatcher_send(self.hass, self.signal_update)

async def async_setup(self):
Expand Down Expand Up @@ -197,6 +217,10 @@ async def async_setup(self):
LOGGER.error("Unknown error connecting with UniFi controller: %s", err)
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.config_entry.add_update_listener(self.async_options_updated)
Expand Down
31 changes: 23 additions & 8 deletions homeassistant/components/unifi/device_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
"ip",
"is_11r",
"is_guest",
"is_wired",
"mac",
"name",
"noted",
Expand Down Expand Up @@ -121,6 +120,7 @@ def __init__(self, client, controller):
"""Set up tracked client."""
self.client = client
self.controller = controller
self.is_wired = self.client.mac not in controller.wireless_clients

@property
def entity_registry_enabled_default(self):
Expand All @@ -129,13 +129,13 @@ def entity_registry_enabled_default(self):
return False

if (
not self.client.is_wired
not self.is_wired
and self.controller.option_ssid_filter
and self.client.essid not in self.controller.option_ssid_filter
):
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 True
Expand All @@ -145,18 +145,31 @@ async def async_added_to_hass(self):
LOGGER.debug("New UniFi client tracker %s (%s)", self.name, self.client.mac)

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(
"Updating UniFi tracked client %s (%s)", self.entity_id, self.client.mac
)
await self.controller.request_update()

if self.is_wired and self.client.mac in self.controller.wireless_clients:
self.is_wired = False

@property
def is_connected(self):
"""Return true if the client is connected to the network."""
if (
dt_util.utcnow() - dt_util.utc_from_timestamp(float(self.client.last_seen))
) < self.controller.option_detection_time:
"""Return true if the client is connected to the network.

If is_wired and client.is_wired differ it means that the device is offline and UniFi bug shows device as wired.
"""
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 False
Expand Down Expand Up @@ -195,6 +208,8 @@ def device_state_attributes(self):
if variable in self.client.raw:
attributes[variable] = self.client.raw[variable]

attributes["is_wired"] = self.is_wired

return attributes


Expand Down
5 changes: 3 additions & 2 deletions homeassistant/components/unifi/switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ def update_items(controller, async_add_entities, switches, switches_off):
new_switches.append(switches[block_client_id])
LOGGER.debug("New UniFi Block switch %s (%s)", client.hostname, client.mac)

# control poe
# control POE
for client_id in controller.api.clients:

poe_client_id = f"poe-{client_id}"
Expand All @@ -108,9 +108,10 @@ def update_items(controller, async_add_entities, switches, switches_off):
pass
# Network device with active POE
elif (
not client.is_wired
client_id in controller.wireless_clients
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].poe_enable
or controller.mac == client.mac
):
continue
Expand Down
45 changes: 43 additions & 2 deletions tests/components/unifi/test_device_tracker.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
"""The tests for the UniFi device tracker platform."""
from collections import deque
from copy import copy
from unittest.mock import Mock

from datetime import timedelta

from asynctest import Mock

import pytest

from aiounifi.clients import Clients, ClientsAll
Expand Down Expand Up @@ -96,7 +98,7 @@
CONF_PASSWORD: "mock-pswd",
CONF_PORT: 1234,
CONF_SITE_ID: "mock-site",
CONF_VERIFY_SSL: True,
CONF_VERIFY_SSL: False,
}

ENTRY_CONFIG = {CONF_CONTROLLER: CONTROLLER_DATA}
Expand Down Expand Up @@ -253,6 +255,45 @@ async def test_tracked_devices(hass, mock_controller):
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):
"""Test the update_items function with some clients."""
mock_controller.mock_client_responses.append([CLIENT_2])
Expand Down