diff --git a/.coveragerc b/.coveragerc index bd9bf196321..88ffa7ef150 100644 --- a/.coveragerc +++ b/.coveragerc @@ -788,6 +788,9 @@ omit = homeassistant/components/vesync/switch.py homeassistant/components/viaggiatreno/sensor.py homeassistant/components/vicare/* + homeassistant/components/vilfo/__init__.py + homeassistant/components/vilfo/sensor.py + homeassistant/components/vilfo/const.py homeassistant/components/vivotek/camera.py homeassistant/components/vlc/media_player.py homeassistant/components/vlc_telnet/media_player.py diff --git a/CODEOWNERS b/CODEOWNERS index 48a23bba619..a96f3bb5b82 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -385,6 +385,7 @@ homeassistant/components/versasense/* @flamm3blemuff1n homeassistant/components/version/* @fabaff homeassistant/components/vesync/* @markperdue @webdjoe homeassistant/components/vicare/* @oischinger +homeassistant/components/vilfo/* @ManneW homeassistant/components/vivotek/* @HarlemSquirrel homeassistant/components/vizio/* @raman325 homeassistant/components/vlc_telnet/* @rodripf diff --git a/homeassistant/components/vilfo/__init__.py b/homeassistant/components/vilfo/__init__.py new file mode 100644 index 00000000000..ffa628d6db2 --- /dev/null +++ b/homeassistant/components/vilfo/__init__.py @@ -0,0 +1,125 @@ +"""The Vilfo Router integration.""" +import asyncio +from datetime import timedelta +import logging + +from vilfo import Client as VilfoClient +from vilfo.exceptions import VilfoException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.typing import ConfigType, HomeAssistantType +from homeassistant.util import Throttle + +from .const import ATTR_BOOT_TIME, ATTR_LOAD, DOMAIN, ROUTER_DEFAULT_HOST + +PLATFORMS = ["sensor"] + +DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistantType, config: ConfigType): + """Set up the Vilfo Router component.""" + hass.data.setdefault(DOMAIN, {}) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Vilfo Router from a config entry.""" + host = entry.data[CONF_HOST] + access_token = entry.data[CONF_ACCESS_TOKEN] + + vilfo_router = VilfoRouterData(hass, host, access_token) + + await vilfo_router.async_update() + + if not vilfo_router.available: + raise ConfigEntryNotReady + + hass.data[DOMAIN][entry.entry_id] = vilfo_router + + for platform in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, platform) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class VilfoRouterData: + """Define an object to hold sensor data.""" + + def __init__(self, hass, host, access_token): + """Initialize.""" + self._vilfo = VilfoClient(host, access_token) + self.hass = hass + self.host = host + self.available = False + self.firmware_version = None + self.mac_address = self._vilfo.mac + self.data = {} + self._unavailable_logged = False + + @property + def unique_id(self): + """Get the unique_id for the Vilfo Router.""" + if self.mac_address: + return self.mac_address + + if self.host == ROUTER_DEFAULT_HOST: + return self.host + + return self.host + + def _fetch_data(self): + board_information = self._vilfo.get_board_information() + load = self._vilfo.get_load() + + return { + "board_information": board_information, + "load": load, + } + + @Throttle(DEFAULT_SCAN_INTERVAL) + async def async_update(self): + """Update data using calls to VilfoClient library.""" + try: + data = await self.hass.async_add_executor_job(self._fetch_data) + + self.firmware_version = data["board_information"]["version"] + self.data[ATTR_BOOT_TIME] = data["board_information"]["bootTime"] + self.data[ATTR_LOAD] = data["load"] + + self.available = True + except VilfoException as error: + if not self._unavailable_logged: + _LOGGER.error( + "Could not fetch data from %s, error: %s", self.host, error + ) + self._unavailable_logged = True + self.available = False + return + + if self.available and self._unavailable_logged: + _LOGGER.info("Vilfo Router %s is available again", self.host) + self._unavailable_logged = False diff --git a/homeassistant/components/vilfo/config_flow.py b/homeassistant/components/vilfo/config_flow.py new file mode 100644 index 00000000000..2b9df3d9195 --- /dev/null +++ b/homeassistant/components/vilfo/config_flow.py @@ -0,0 +1,147 @@ +"""Config flow for Vilfo Router integration.""" +import ipaddress +import logging +import re + +from vilfo import Client as VilfoClient +from vilfo.exceptions import ( + AuthenticationException as VilfoAuthenticationException, + VilfoException, +) +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_ID, CONF_MAC + +from .const import DOMAIN # pylint:disable=unused-import +from .const import ROUTER_DEFAULT_HOST + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST, default=ROUTER_DEFAULT_HOST): str, + vol.Required(CONF_ACCESS_TOKEN, default=""): str, + } +) + +RESULT_SUCCESS = "success" +RESULT_CANNOT_CONNECT = "cannot_connect" +RESULT_INVALID_AUTH = "invalid_auth" + + +def host_valid(host): + """Return True if hostname or IP address is valid.""" + try: + if ipaddress.ip_address(host).version == (4 or 6): + return True + except ValueError: + disallowed = re.compile(r"[^a-zA-Z\d\-]") + return all(x and not disallowed.search(x) for x in host.split(".")) + + +def _try_connect_and_fetch_basic_info(host, token): + """Attempt to connect and call the ping endpoint and, if successful, fetch basic information.""" + + # Perform the ping. This doesn't validate authentication. + controller = VilfoClient(host=host, token=token) + result = {"type": None, "data": {}} + + try: + controller.ping() + except VilfoException: + result["type"] = RESULT_CANNOT_CONNECT + result["data"] = CannotConnect + return result + + # Perform a call that requires authentication. + try: + controller.get_board_information() + except VilfoAuthenticationException: + result["type"] = RESULT_INVALID_AUTH + result["data"] = InvalidAuth + return result + + if controller.mac: + result["data"][CONF_ID] = controller.mac + result["data"][CONF_MAC] = controller.mac + else: + result["data"][CONF_ID] = host + result["data"][CONF_MAC] = None + + result["type"] = RESULT_SUCCESS + + return result + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + + # Validate the host before doing anything else. + if not host_valid(data[CONF_HOST]): + raise InvalidHost + + config = {} + + result = await hass.async_add_executor_job( + _try_connect_and_fetch_basic_info, data[CONF_HOST], data[CONF_ACCESS_TOKEN] + ) + + if result["type"] != RESULT_SUCCESS: + raise result["data"] + + # Return some info we want to store in the config entry. + result_data = result["data"] + config["title"] = f"{data[CONF_HOST]}" + config[CONF_MAC] = result_data[CONF_MAC] + config[CONF_HOST] = data[CONF_HOST] + config[CONF_ID] = result_data[CONF_ID] + + return config + + +class DomainConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Vilfo Router.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + except InvalidHost: + errors[CONF_HOST] = "wrong_host" + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception as err: # pylint: disable=broad-except + _LOGGER.error("Unexpected exception: %s", err) + errors["base"] = "unknown" + else: + await self.async_set_unique_id(info[CONF_ID]) + self._abort_if_unique_id_configured() + + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" + + +class InvalidHost(exceptions.HomeAssistantError): + """Error to indicate that hostname/IP address is invalid.""" diff --git a/homeassistant/components/vilfo/const.py b/homeassistant/components/vilfo/const.py new file mode 100644 index 00000000000..1a40b8430d7 --- /dev/null +++ b/homeassistant/components/vilfo/const.py @@ -0,0 +1,36 @@ +"""Constants for the Vilfo Router integration.""" +from homeassistant.const import DEVICE_CLASS_TIMESTAMP + +DOMAIN = "vilfo" + +ATTR_API_DATA_FIELD = "api_data_field" +ATTR_API_DATA_FIELD_LOAD = "load" +ATTR_API_DATA_FIELD_BOOT_TIME = "boot_time" +ATTR_DEVICE_CLASS = "device_class" +ATTR_ICON = "icon" +ATTR_LABEL = "label" +ATTR_LOAD = "load" +ATTR_UNIT = "unit" +ATTR_BOOT_TIME = "boot_time" + +ROUTER_DEFAULT_HOST = "admin.vilfo.com" +ROUTER_DEFAULT_MODEL = "Vilfo Router" +ROUTER_DEFAULT_NAME = "Vilfo Router" +ROUTER_MANUFACTURER = "Vilfo AB" + +UNIT_PERCENT = "%" + +SENSOR_TYPES = { + ATTR_LOAD: { + ATTR_LABEL: "Load", + ATTR_UNIT: UNIT_PERCENT, + ATTR_ICON: "mdi:memory", + ATTR_API_DATA_FIELD: ATTR_API_DATA_FIELD_LOAD, + }, + ATTR_BOOT_TIME: { + ATTR_LABEL: "Boot time", + ATTR_ICON: "mdi:timer", + ATTR_API_DATA_FIELD: ATTR_API_DATA_FIELD_BOOT_TIME, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP, + }, +} diff --git a/homeassistant/components/vilfo/manifest.json b/homeassistant/components/vilfo/manifest.json new file mode 100644 index 00000000000..cedb485fab3 --- /dev/null +++ b/homeassistant/components/vilfo/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "vilfo", + "name": "Vilfo Router", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/vilfo", + "requirements": ["vilfo-api-client==0.3.2"], + "dependencies": [], + "codeowners": ["@ManneW"] +} diff --git a/homeassistant/components/vilfo/sensor.py b/homeassistant/components/vilfo/sensor.py new file mode 100644 index 00000000000..e2909647c2d --- /dev/null +++ b/homeassistant/components/vilfo/sensor.py @@ -0,0 +1,94 @@ +"""Support for Vilfo Router sensors.""" +from homeassistant.helpers.entity import Entity + +from .const import ( + ATTR_API_DATA_FIELD, + ATTR_DEVICE_CLASS, + ATTR_ICON, + ATTR_LABEL, + ATTR_UNIT, + DOMAIN, + ROUTER_DEFAULT_MODEL, + ROUTER_DEFAULT_NAME, + ROUTER_MANUFACTURER, + SENSOR_TYPES, +) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Add Vilfo Router entities from a config_entry.""" + vilfo = hass.data[DOMAIN][config_entry.entry_id] + + sensors = [] + + for sensor_type in SENSOR_TYPES: + sensors.append(VilfoRouterSensor(sensor_type, vilfo)) + + async_add_entities(sensors, True) + + +class VilfoRouterSensor(Entity): + """Define a Vilfo Router Sensor.""" + + def __init__(self, sensor_type, api): + """Initialize.""" + self.api = api + self.sensor_type = sensor_type + self._device_info = { + "identifiers": {(DOMAIN, api.host, api.mac_address)}, + "name": ROUTER_DEFAULT_NAME, + "manufacturer": ROUTER_MANUFACTURER, + "model": ROUTER_DEFAULT_MODEL, + "sw_version": api.firmware_version, + } + self._unique_id = f"{self.api.unique_id}_{self.sensor_type}" + self._state = None + + @property + def available(self): + """Return whether the sensor is available or not.""" + return self.api.available + + @property + def device_info(self): + """Return the device info.""" + return self._device_info + + @property + def device_class(self): + """Return the device class.""" + return SENSOR_TYPES[self.sensor_type].get(ATTR_DEVICE_CLASS) + + @property + def icon(self): + """Return the icon for the sensor.""" + return SENSOR_TYPES[self.sensor_type][ATTR_ICON] + + @property + def name(self): + """Return the name of the sensor.""" + parent_device_name = self._device_info["name"] + sensor_name = SENSOR_TYPES[self.sensor_type][ATTR_LABEL] + return f"{parent_device_name} {sensor_name}" + + @property + def state(self): + """Return the state.""" + return self._state + + @property + def unique_id(self): + """Return a unique_id for this entity.""" + return self._unique_id + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity.""" + return SENSOR_TYPES[self.sensor_type].get(ATTR_UNIT) + + async def async_update(self): + """Update the router data.""" + await self.api.async_update() + self._state = self.api.data.get( + SENSOR_TYPES[self.sensor_type][ATTR_API_DATA_FIELD] + ) diff --git a/homeassistant/components/vilfo/strings.json b/homeassistant/components/vilfo/strings.json new file mode 100644 index 00000000000..e7a55c55f1f --- /dev/null +++ b/homeassistant/components/vilfo/strings.json @@ -0,0 +1,23 @@ +{ + "config": { + "title": "Vilfo Router", + "step": { + "user": { + "title": "Connect to the Vilfo Router", + "description": "Set up the Vilfo Router integration. You need your Vilfo Router hostname/IP and an API access token. For additional information on this integration and how to get those details, visit: https://www.home-assistant.io/integrations/vilfo", + "data": { + "host": "Router hostname or IP", + "access_token": "Access token for the Vilfo Router API" + } + } + }, + "error": { + "cannot_connect": "Failed to connect. Please check the information you provided and try again.", + "invalid_auth": "Invalid authentication. Please check the access token and try again.", + "unknown": "An unexpected error occurred while setting up the integration." + }, + "abort": { + "already_configured": "This Vilfo Router is already configured." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 4c5449d5b2a..39a9bccf607 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -101,6 +101,7 @@ FLOWS = [ "upnp", "velbus", "vesync", + "vilfo", "vizio", "wemo", "withings", diff --git a/requirements_all.txt b/requirements_all.txt index 0dac9a0b481..303fe886716 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2032,6 +2032,9 @@ venstarcolortouch==0.12 # homeassistant.components.meteo_france vigilancemeteo==3.0.0 +# homeassistant.components.vilfo +vilfo-api-client==0.3.2 + # homeassistant.components.volkszaehler volkszaehler==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index da710af4722..890bd1e96e9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -688,6 +688,9 @@ uvcclient==0.11.0 # homeassistant.components.meteo_france vigilancemeteo==3.0.0 +# homeassistant.components.vilfo +vilfo-api-client==0.3.2 + # homeassistant.components.verisure vsure==1.5.4 diff --git a/tests/components/vilfo/__init__.py b/tests/components/vilfo/__init__.py new file mode 100644 index 00000000000..680b556fc12 --- /dev/null +++ b/tests/components/vilfo/__init__.py @@ -0,0 +1 @@ +"""Tests for the Vilfo Router integration.""" diff --git a/tests/components/vilfo/test_config_flow.py b/tests/components/vilfo/test_config_flow.py new file mode 100644 index 00000000000..d73d15df8dd --- /dev/null +++ b/tests/components/vilfo/test_config_flow.py @@ -0,0 +1,184 @@ +"""Test the Vilfo Router config flow.""" +from unittest.mock import patch + +import vilfo + +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.vilfo.const import DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_ID, CONF_MAC + +from tests.common import mock_coro + + +async def test_form(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch("vilfo.Client.ping", return_value=None), patch( + "vilfo.Client.get_board_information", return_value=None, + ), patch( + "homeassistant.components.vilfo.async_setup", return_value=mock_coro(True) + ) as mock_setup, patch( + "homeassistant.components.vilfo.async_setup_entry", + return_value=mock_coro(True), + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "testadmin.vilfo.com", CONF_ACCESS_TOKEN: "test-token"}, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "testadmin.vilfo.com" + assert result2["data"] == { + "host": "testadmin.vilfo.com", + "access_token": "test-token", + } + + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass): + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch("vilfo.Client.ping", return_value=None), patch( + "vilfo.Client.get_board_information", + side_effect=vilfo.exceptions.AuthenticationException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "testadmin.vilfo.com", "access_token": "test-token"}, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch("vilfo.Client.ping", side_effect=vilfo.exceptions.VilfoException): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "testadmin.vilfo.com", "access_token": "test-token"}, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + with patch("vilfo.Client.ping", side_effect=vilfo.exceptions.VilfoException): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "testadmin.vilfo.com", "access_token": "test-token"}, + ) + + assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result3["errors"] == {"base": "cannot_connect"} + + +async def test_form_wrong_host(hass): + """Test we handle wrong host errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={"host": "this is an invalid hostname", "access_token": "test-token"}, + ) + + assert result["errors"] == {"host": "wrong_host"} + + +async def test_form_already_configured(hass): + """Test that we handle already configured exceptions appropriately.""" + first_flow_result1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch("vilfo.Client.ping", return_value=None), patch( + "vilfo.Client.get_board_information", return_value=None, + ): + first_flow_result2 = await hass.config_entries.flow.async_configure( + first_flow_result1["flow_id"], + {CONF_HOST: "testadmin.vilfo.com", CONF_ACCESS_TOKEN: "test-token"}, + ) + + second_flow_result1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch("vilfo.Client.ping", return_value=None), patch( + "vilfo.Client.get_board_information", return_value=None, + ): + second_flow_result2 = await hass.config_entries.flow.async_configure( + second_flow_result1["flow_id"], + {CONF_HOST: "testadmin.vilfo.com", CONF_ACCESS_TOKEN: "test-token"}, + ) + + assert first_flow_result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert second_flow_result2["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert second_flow_result2["reason"] == "already_configured" + + +async def test_form_unexpected_exception(hass): + """Test that we handle unexpected exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch("vilfo.Client.ping", side_effect=Exception): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "testadmin.vilfo.com", "access_token": "test-token"}, + ) + + assert result2["errors"] == {"base": "unknown"} + + +async def test_validate_input_returns_data(hass): + """Test we handle the MAC address being resolved or not.""" + mock_data = {"host": "testadmin.vilfo.com", "access_token": "test-token"} + mock_data_with_ip = {"host": "192.168.0.1", "access_token": "test-token"} + mock_mac = "FF-00-00-00-00-00" + + with patch("vilfo.Client.ping", return_value=None), patch( + "vilfo.Client.get_board_information", return_value=None + ): + result = await hass.components.vilfo.config_flow.validate_input( + hass, data=mock_data + ) + + assert result["title"] == mock_data["host"] + assert result[CONF_HOST] == mock_data["host"] + assert result[CONF_MAC] is None + assert result[CONF_ID] == mock_data["host"] + + with patch("vilfo.Client.ping", return_value=None), patch( + "vilfo.Client.get_board_information", return_value=None + ), patch("vilfo.Client.resolve_mac_address", return_value=mock_mac): + result2 = await hass.components.vilfo.config_flow.validate_input( + hass, data=mock_data + ) + result3 = await hass.components.vilfo.config_flow.validate_input( + hass, data=mock_data_with_ip + ) + + assert result2["title"] == mock_data["host"] + assert result2[CONF_HOST] == mock_data["host"] + assert result2[CONF_MAC] == mock_mac + assert result2[CONF_ID] == mock_mac + + assert result3["title"] == mock_data_with_ip["host"] + assert result3[CONF_HOST] == mock_data_with_ip["host"] + assert result3[CONF_MAC] == mock_mac + assert result3[CONF_ID] == mock_mac