Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rework UniFi websocket #100614

Merged
merged 8 commits into from
Sep 27, 2023
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion homeassistant/components/unifi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
if len(hass.data[UNIFI_DOMAIN]) == 1:
async_setup_services(hass)

api.start_websocket()
controller.start_websocket()

config_entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, controller.shutdown)
Expand Down
55 changes: 33 additions & 22 deletions homeassistant/components/unifi/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
from aiounifi.interfaces.api_handlers import ItemEvent
from aiounifi.models.configuration import Configuration
from aiounifi.models.device import DeviceSetPoePortModeRequest
from aiounifi.websocket import WebsocketState

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
Expand Down Expand Up @@ -81,7 +80,7 @@ def __init__(
self.config_entry = config_entry
self.api = api

api.ws_state_callback = self.async_unifi_ws_state_callback
self.ws_task: asyncio.Task | None = None

self.available = True
self.wireless_clients = hass.data[UNIFI_WIRELESS_CLIENTS]
Expand Down Expand Up @@ -223,23 +222,6 @@ def async_options_updated() -> None:
for description in descriptions:
async_load_entities(description)

@callback
def async_unifi_ws_state_callback(self, state: WebsocketState) -> None:
"""Handle messages back from UniFi library."""
if state == WebsocketState.DISCONNECTED and self.available:
LOGGER.warning("Lost connection to UniFi Network")

if (state == WebsocketState.RUNNING and not self.available) or (
state == WebsocketState.DISCONNECTED and self.available
):
self.available = state == WebsocketState.RUNNING
async_dispatcher_send(self.hass, self.signal_reachable)

if not self.available:
self.hass.loop.call_later(RETRY_TIMER, self.reconnect, True)
else:
LOGGER.info("Connected to UniFi Network")

@property
def signal_reachable(self) -> str:
"""Integration specific event to signal a change in connection status."""
Expand Down Expand Up @@ -367,6 +349,19 @@ async def async_config_entry_updated(
controller.load_config_entry_options()
async_dispatcher_send(hass, controller.signal_options_update)

@callback
def start_websocket(self) -> None:
"""Start up connection to websocket."""

async def _websocket_runner() -> None:
"""Start websocket."""
await self.api.start_websocket()
self.available = False
elupus marked this conversation as resolved.
Show resolved Hide resolved
async_dispatcher_send(self.hass, self.signal_reachable)
self.hass.loop.call_later(RETRY_TIMER, self.reconnect, True)

self.ws_task = self.hass.loop.create_task(_websocket_runner())

@callback
def reconnect(self, log: bool = False) -> None:
"""Prepare to reconnect UniFi session."""
Expand All @@ -379,7 +374,11 @@ async def async_reconnect(self) -> None:
try:
async with asyncio.timeout(5):
await self.api.login()
self.api.start_websocket()
self.start_websocket()

if not self.available:
self.available = True
async_dispatcher_send(self.hass, self.signal_reachable)

except (
asyncio.TimeoutError,
Expand All @@ -395,15 +394,27 @@ def shutdown(self, event: Event) -> None:

Used as an argument to EventBus.async_listen_once.
"""
self.api.stop_websocket()
if self.ws_task is not None:
self.ws_task.cancel()

async def async_reset(self) -> bool:
"""Reset this controller to default state.

Will cancel any scheduled setup retry and will unload
the config entry.
"""
self.api.stop_websocket()
if self.ws_task is not None:
self.ws_task.cancel()
elupus marked this conversation as resolved.
Show resolved Hide resolved

_, pending = await asyncio.wait([self.ws_task], timeout=10)

if pending:
LOGGER.warning(
"Unloading %s (%s) config entry. Task %s did not complete in time",
self.config_entry.title,
self.config_entry.domain,
self.ws_task,
elupus marked this conversation as resolved.
Show resolved Hide resolved
)

unload_ok = await self.hass.config_entries.async_unload_platforms(
self.config_entry, PLATFORMS
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/unifi/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"iot_class": "local_push",
"loggers": ["aiounifi"],
"quality_scale": "platinum",
"requirements": ["aiounifi==62"],
"requirements": ["aiounifi==63"],
"ssdp": [
{
"manufacturer": "Ubiquiti Networks",
Expand Down
2 changes: 1 addition & 1 deletion requirements_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,7 @@ aiosyncthing==0.5.1
aiotractive==0.5.6

# homeassistant.components.unifi
aiounifi==62
aiounifi==63

# homeassistant.components.vlc_telnet
aiovlc==0.1.0
Expand Down
2 changes: 1 addition & 1 deletion requirements_test_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ aiosyncthing==0.5.1
aiotractive==0.5.6

# homeassistant.components.unifi
aiounifi==62
aiounifi==63

# homeassistant.components.vlc_telnet
aiovlc==0.1.0
Expand Down
107 changes: 80 additions & 27 deletions tests/components/unifi/conftest.py
Original file line number Diff line number Diff line change
@@ -1,47 +1,100 @@
"""Fixtures for UniFi Network methods."""
from __future__ import annotations

import asyncio
from datetime import timedelta
from unittest.mock import patch

from aiounifi.models.message import MessageKey
from aiounifi.websocket import WebsocketSignal, WebsocketState
import pytest

from homeassistant.components.unifi.const import DOMAIN as UNIFI_DOMAIN
from homeassistant.components.unifi.controller import RETRY_TIMER
from homeassistant.const import CONTENT_TYPE_JSON
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
import homeassistant.util.dt as dt_util

from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, async_fire_time_changed
from tests.components.unifi.test_controller import DEFAULT_CONFIG_ENTRY_ID
from tests.test_util.aiohttp import AiohttpClientMocker


class WebsocketStateManager(asyncio.Event):
"""Keep an async event that simules websocket context manager.

Prepares disconnect and reconnect flows.
"""

def __init__(self, hass: HomeAssistant, aioclient_mock: AiohttpClientMocker):
"""Store hass object and initialize asyncio.Event."""
self.hass = hass
self.aioclient_mock = aioclient_mock
super().__init__()

async def disconnect(self):
"""Mark future as done to make 'await self.api.start_websocket' return."""
self.set()
await self.hass.async_block_till_done()

async def reconnect(self, fail=False):
"""Set up new future to make 'await self.api.start_websocket' block.

Mock api calls done by 'await self.api.login'.
Fail will make 'await self.api.start_websocket' return immediately.
"""
controller = self.hass.data[UNIFI_DOMAIN][DEFAULT_CONFIG_ENTRY_ID]
self.aioclient_mock.get(
f"https://{controller.host}:1234", status=302
) # Check UniFi OS
self.aioclient_mock.post(
f"https://{controller.host}:1234/api/login",
json={"data": "login successful", "meta": {"rc": "ok"}},
headers={"content-type": CONTENT_TYPE_JSON},
)

if not fail:
self.clear()
new_time = dt_util.utcnow() + timedelta(seconds=RETRY_TIMER)
async_fire_time_changed(self.hass, new_time)
await self.hass.async_block_till_done()


@pytest.fixture(autouse=True)
def websocket_mock(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker):
"""Mock 'await self.api.start_websocket' in 'UniFiController.start_websocket'."""
websocket_state_manager = WebsocketStateManager(hass, aioclient_mock)
with patch("aiounifi.Controller.start_websocket") as ws_mock:
ws_mock.side_effect = websocket_state_manager.wait
yield websocket_state_manager


@pytest.fixture(autouse=True)
def mock_unifi_websocket():
def mock_unifi_websocket(hass):
"""No real websocket allowed."""
with patch("aiounifi.controller.WSClient") as mock:

def make_websocket_call(
*,
message: MessageKey | None = None,
data: list[dict] | dict | None = None,
state: WebsocketState | None = None,
):
"""Generate a websocket call."""
if data and not message:
mock.return_value.data = data
mock.call_args[1]["callback"](WebsocketSignal.DATA)
elif data and message:
if not isinstance(data, list):
data = [data]
mock.return_value.data = {

def make_websocket_call(
*,
message: MessageKey | None = None,
data: list[dict] | dict | None = None,
):
"""Generate a websocket call."""
controller = hass.data[UNIFI_DOMAIN][DEFAULT_CONFIG_ENTRY_ID]
if data and not message:
controller.api.messages.handler(data)
elif data and message:
if not isinstance(data, list):
data = [data]
controller.api.messages.handler(
{
"meta": {"message": message.value},
"data": data,
}
mock.call_args[1]["callback"](WebsocketSignal.DATA)
elif state:
mock.return_value.state = state
mock.call_args[1]["callback"](WebsocketSignal.CONNECTION_STATE)
else:
raise NotImplementedError

yield make_websocket_call
)
else:
raise NotImplementedError

return make_websocket_call


@pytest.fixture(autouse=True)
Expand Down
10 changes: 3 additions & 7 deletions tests/components/unifi/test_button.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
"""UniFi Network button platform tests."""

from aiounifi.websocket import WebsocketState

from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, ButtonDeviceClass
from homeassistant.components.unifi.const import DOMAIN as UNIFI_DOMAIN
from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNAVAILABLE, EntityCategory
Expand All @@ -14,7 +12,7 @@


async def test_restart_device_button(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, websocket_mock
) -> None:
"""Test restarting device button."""
config_entry = await setup_unifi_integration(
Expand Down Expand Up @@ -71,11 +69,9 @@ async def test_restart_device_button(
# Availability signalling

# Controller disconnects
mock_unifi_websocket(state=WebsocketState.DISCONNECTED)
await hass.async_block_till_done()
await websocket_mock.disconnect()
assert hass.states.get("button.switch_restart").state == STATE_UNAVAILABLE

# Controller reconnects
mock_unifi_websocket(state=WebsocketState.RUNNING)
await hass.async_block_till_done()
await websocket_mock.reconnect()
assert hass.states.get("button.switch_restart").state != STATE_UNAVAILABLE
Loading