From 19be4a5d6de1356d84ff1ff81a5b72f50b8bb4e7 Mon Sep 17 00:00:00 2001 From: Quentame Date: Wed, 11 Mar 2020 22:15:59 +0100 Subject: [PATCH] Refactor Freebox : add config flow + temperature sensor + signal dispatch (#30334) * Add config flow to Freebox * Add manufacturer in device_tracker info * Add device_info to sensor + switch * Add device_info: connections * Add config_flow test + update .coveragerc * Typing * Add device_type icon * Remove one error log * Fix pylint * Add myself as CODEOWNER * Handle sync in one place * Separate the Freebox[Router/Device/Sensor] from __init__.py * Add link step to config flow * Make temperature sensors auto-discovered * Use device activity instead of reachablility for device_tracker * Store token file in .storage Depending on host if list of Freebox integration on the future without breaking change * Remove IP sensors + add Freebox router as a device with attrs : IPs, conection type, uptime, version & serial * Add sensor should_poll=False * Test typing * Handle devices with no name * None is the default for data * Fix comment * Use config_entry.unique_id * Add async_unload_entry with asyncio * Add and use bunch of data size and rate related constants (#31781) * Review * Remove useless "already_configured" error string * Review : merge 2 device & 2 sensor classes * Entities from platforms * Fix unload + add device after setup + clean loggers * async_add_entities True * Review * Use pathlib + refactor get_api * device_tracker set + tests with CoroutineMock() * Removing active & reachable from tracker attrs * Review * Fix pipeline * typing * typing * typing * Raise ConfigEntryNotReady when HttpRequestError at setup * Review * Multiple Freebox s * Review: store sensors in router * Freebox: a sensor story --- .coveragerc | 6 +- CODEOWNERS | 2 +- .../components/freebox/.translations/en.json | 26 +++ homeassistant/components/freebox/__init__.py | 115 ++++++----- .../components/freebox/config_flow.py | 110 ++++++++++ homeassistant/components/freebox/const.py | 75 +++++++ .../components/freebox/device_tracker.py | 171 ++++++++++++---- .../components/freebox/manifest.json | 3 +- homeassistant/components/freebox/router.py | 193 ++++++++++++++++++ homeassistant/components/freebox/sensor.py | 146 ++++++++----- homeassistant/components/freebox/strings.json | 26 +++ homeassistant/components/freebox/switch.py | 49 +++-- homeassistant/generated/config_flows.py | 1 + requirements_test_all.txt | 3 + tests/components/freebox/__init__.py | 1 + tests/components/freebox/conftest.py | 11 + tests/components/freebox/test_config_flow.py | 144 +++++++++++++ 17 files changed, 917 insertions(+), 165 deletions(-) create mode 100644 homeassistant/components/freebox/.translations/en.json create mode 100644 homeassistant/components/freebox/config_flow.py create mode 100644 homeassistant/components/freebox/const.py create mode 100644 homeassistant/components/freebox/router.py create mode 100644 homeassistant/components/freebox/strings.json create mode 100644 tests/components/freebox/__init__.py create mode 100644 tests/components/freebox/conftest.py create mode 100644 tests/components/freebox/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 2716a1fed44..c94199d6451 100644 --- a/.coveragerc +++ b/.coveragerc @@ -242,7 +242,11 @@ omit = homeassistant/components/foscam/const.py homeassistant/components/foursquare/* homeassistant/components/free_mobile/notify.py - homeassistant/components/freebox/* + homeassistant/components/freebox/__init__.py + homeassistant/components/freebox/device_tracker.py + homeassistant/components/freebox/router.py + homeassistant/components/freebox/sensor.py + homeassistant/components/freebox/switch.py homeassistant/components/fritz/device_tracker.py homeassistant/components/fritzbox/* homeassistant/components/fritzbox_callmonitor/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 97b347b8415..8b85278b4bb 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -122,7 +122,7 @@ homeassistant/components/fortigate/* @kifeo homeassistant/components/fortios/* @kimfrellsen homeassistant/components/foscam/* @skgsergio homeassistant/components/foursquare/* @robbiet480 -homeassistant/components/freebox/* @snoof85 +homeassistant/components/freebox/* @snoof85 @Quentame homeassistant/components/fronius/* @nielstron homeassistant/components/frontend/* @home-assistant/frontend homeassistant/components/garmin_connect/* @cyberjunky diff --git a/homeassistant/components/freebox/.translations/en.json b/homeassistant/components/freebox/.translations/en.json new file mode 100644 index 00000000000..75d925e2f7a --- /dev/null +++ b/homeassistant/components/freebox/.translations/en.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Host already configured" + }, + "error": { + "connection_failed": "Failed to connect, please try again", + "register_failed": "Failed to register, please try again", + "unknown": "Unknown error: please retry later" + }, + "step": { + "link": { + "description": "Click \"Submit\", then touch the right arrow on the router to register Freebox with Home Assistant.\n\n![Location of button on the router](/static/images/config_freebox.png)", + "title": "Link Freebox router" + }, + "user": { + "data": { + "host": "Host", + "port": "Port" + }, + "title": "Freebox" + } + }, + "title": "Freebox" + } +} \ No newline at end of file diff --git a/homeassistant/components/freebox/__init__.py b/homeassistant/components/freebox/__init__.py index 58426334dea..9e303c75e7a 100644 --- a/homeassistant/components/freebox/__init__.py +++ b/homeassistant/components/freebox/__init__.py @@ -1,29 +1,26 @@ """Support for Freebox devices (Freebox v6 and Freebox mini 4K).""" +import asyncio import logging -import socket -from aiofreepybox import Freepybox -from aiofreepybox.exceptions import HttpRequestError import voluptuous as vol from homeassistant.components.discovery import SERVICE_FREEBOX +from homeassistant.config_entries import SOURCE_DISCOVERY, SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP from homeassistant.helpers import config_validation as cv, discovery -from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.typing import HomeAssistantType + +from .const import DOMAIN, PLATFORMS +from .router import FreeboxRouter _LOGGER = logging.getLogger(__name__) -DOMAIN = "freebox" -DATA_FREEBOX = DOMAIN - -FREEBOX_CONFIG_FILE = "freebox.conf" +FREEBOX_SCHEMA = vol.Schema( + {vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PORT): cv.port} +) CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - {vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PORT): cv.port} - ) - }, + {DOMAIN: vol.Schema(vol.All(cv.ensure_list, [FREEBOX_SCHEMA]))}, extra=vol.ALLOW_EXTRA, ) @@ -37,54 +34,70 @@ async def async_setup(hass, config): host = discovery_info.get("properties", {}).get("api_domain") port = discovery_info.get("properties", {}).get("https_port") _LOGGER.info("Discovered Freebox server: %s:%s", host, port) - await async_setup_freebox(hass, config, host, port) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DISCOVERY}, + data={CONF_HOST: host, CONF_PORT: port}, + ) + ) discovery.async_listen(hass, SERVICE_FREEBOX, discovery_dispatch) - if conf is not None: - host = conf.get(CONF_HOST) - port = conf.get(CONF_PORT) - await async_setup_freebox(hass, config, host, port) + if conf is None: + return True + + for freebox_conf in conf: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=freebox_conf, + ) + ) return True -async def async_setup_freebox(hass, config, host, port): - """Start up the Freebox component platforms.""" +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Set up Freebox component.""" + router = FreeboxRouter(hass, entry) + await router.setup() - app_desc = { - "app_id": "hass", - "app_name": "Home Assistant", - "app_version": "0.65", - "device_name": socket.gethostname(), - } + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.unique_id] = router - token_file = hass.config.path(FREEBOX_CONFIG_FILE) - api_version = "v6" - - fbx = Freepybox(app_desc=app_desc, token_file=token_file, api_version=api_version) - - try: - await fbx.open(host, port) - except HttpRequestError: - _LOGGER.exception("Failed to connect to Freebox") - else: - hass.data[DATA_FREEBOX] = fbx - - async def async_freebox_reboot(call): - """Handle reboot service call.""" - await fbx.system.reboot() - - hass.services.async_register(DOMAIN, "reboot", async_freebox_reboot) - - hass.async_create_task(async_load_platform(hass, "sensor", DOMAIN, {}, config)) + for platform in PLATFORMS: hass.async_create_task( - async_load_platform(hass, "device_tracker", DOMAIN, {}, config) + hass.config_entries.async_forward_entry_setup(entry, platform) ) - hass.async_create_task(async_load_platform(hass, "switch", DOMAIN, {}, config)) - async def close_fbx(event): - """Close Freebox connection on HA Stop.""" - await fbx.close() + # Services + async def async_reboot(call): + """Handle reboot service call.""" + await router.reboot() - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, close_fbx) + hass.services.async_register(DOMAIN, "reboot", async_reboot) + + async def async_close_connection(event): + """Close Freebox connection on HA Stop.""" + await router.close() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_close_connection) + + return True + + +async def async_unload_entry(hass: HomeAssistantType, 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: + router = hass.data[DOMAIN].pop(entry.unique_id) + await router.close() + + return unload_ok diff --git a/homeassistant/components/freebox/config_flow.py b/homeassistant/components/freebox/config_flow.py new file mode 100644 index 00000000000..b2d1a0ab771 --- /dev/null +++ b/homeassistant/components/freebox/config_flow.py @@ -0,0 +1,110 @@ +"""Config flow to configure the Freebox integration.""" +import logging + +from aiofreepybox.exceptions import AuthorizationError, HttpRequestError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PORT + +from .const import DOMAIN # pylint: disable=unused-import +from .router import get_api + +_LOGGER = logging.getLogger(__name__) + + +class FreeboxFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self): + """Initialize Freebox config flow.""" + self._host = None + self._port = None + + def _show_setup_form(self, user_input=None, errors=None): + """Show the setup form to the user.""" + + if user_input is None: + user_input = {} + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST, default=user_input.get(CONF_HOST, "")): str, + vol.Required(CONF_PORT, default=user_input.get(CONF_PORT, "")): int, + } + ), + errors=errors or {}, + ) + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + errors = {} + + if user_input is None: + return self._show_setup_form(user_input, errors) + + self._host = user_input[CONF_HOST] + self._port = user_input[CONF_PORT] + + # Check if already configured + await self.async_set_unique_id(self._host) + self._abort_if_unique_id_configured() + + return await self.async_step_link() + + async def async_step_link(self, user_input=None): + """Attempt to link with the Freebox router. + + Given a configured host, will ask the user to press the button + to connect to the router. + """ + if user_input is None: + return self.async_show_form(step_id="link") + + errors = {} + + fbx = await get_api(self.hass, self._host) + try: + # Open connection and check authentication + await fbx.open(self._host, self._port) + + # Check permissions + await fbx.system.get_config() + await fbx.lan.get_hosts_list() + await self.hass.async_block_till_done() + + # Close connection + await fbx.close() + + return self.async_create_entry( + title=self._host, data={CONF_HOST: self._host, CONF_PORT: self._port}, + ) + + except AuthorizationError as error: + _LOGGER.error(error) + errors["base"] = "register_failed" + + except HttpRequestError: + _LOGGER.error("Error connecting to the Freebox router at %s", self._host) + errors["base"] = "connection_failed" + + except Exception: # pylint: disable=broad-except + _LOGGER.exception( + "Unknown error connecting with Freebox router at %s", self._host + ) + errors["base"] = "unknown" + + return self.async_show_form(step_id="link", errors=errors) + + async def async_step_import(self, user_input=None): + """Import a config entry.""" + return await self.async_step_user(user_input) + + async def async_step_discovery(self, user_input=None): + """Initialize step from discovery.""" + return await self.async_step_user(user_input) diff --git a/homeassistant/components/freebox/const.py b/homeassistant/components/freebox/const.py new file mode 100644 index 00000000000..0612e4e76f1 --- /dev/null +++ b/homeassistant/components/freebox/const.py @@ -0,0 +1,75 @@ +"""Freebox component constants.""" +import socket + +from homeassistant.const import ( + DATA_RATE_KILOBYTES_PER_SECOND, + DEVICE_CLASS_TEMPERATURE, + TEMP_CELSIUS, +) + +DOMAIN = "freebox" + +APP_DESC = { + "app_id": "hass", + "app_name": "Home Assistant", + "app_version": "0.106", + "device_name": socket.gethostname(), +} +API_VERSION = "v6" + +PLATFORMS = ["device_tracker", "sensor", "switch"] + +DEFAULT_DEVICE_NAME = "Unknown device" + +# to store the cookie +STORAGE_KEY = DOMAIN +STORAGE_VERSION = 1 + +# Sensor +SENSOR_NAME = "name" +SENSOR_UNIT = "unit" +SENSOR_ICON = "icon" +SENSOR_DEVICE_CLASS = "device_class" + +CONNECTION_SENSORS = { + "rate_down": { + SENSOR_NAME: "Freebox download speed", + SENSOR_UNIT: DATA_RATE_KILOBYTES_PER_SECOND, + SENSOR_ICON: "mdi:download-network", + SENSOR_DEVICE_CLASS: None, + }, + "rate_up": { + SENSOR_NAME: "Freebox upload speed", + SENSOR_UNIT: DATA_RATE_KILOBYTES_PER_SECOND, + SENSOR_ICON: "mdi:upload-network", + SENSOR_DEVICE_CLASS: None, + }, +} + +TEMPERATURE_SENSOR_TEMPLATE = { + SENSOR_NAME: None, + SENSOR_UNIT: TEMP_CELSIUS, + SENSOR_ICON: "mdi:thermometer", + SENSOR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, +} + +# Icons +DEVICE_ICONS = { + "freebox_delta": "mdi:television-guide", + "freebox_hd": "mdi:television-guide", + "freebox_mini": "mdi:television-guide", + "freebox_player": "mdi:television-guide", + "ip_camera": "mdi:cctv", + "ip_phone": "mdi:phone-voip", + "laptop": "mdi:laptop", + "multimedia_device": "mdi:play-network", + "nas": "mdi:nas", + "networking_device": "mdi:network", + "printer": "mdi:printer", + "router": "mdi:router-wireless", + "smartphone": "mdi:cellphone", + "tablet": "mdi:tablet", + "television": "mdi:television", + "vg_console": "mdi:gamepad-variant", + "workstation": "mdi:desktop-tower-monitor", +} diff --git a/homeassistant/components/freebox/device_tracker.py b/homeassistant/components/freebox/device_tracker.py index 63cf869990d..ea9919f5742 100644 --- a/homeassistant/components/freebox/device_tracker.py +++ b/homeassistant/components/freebox/device_tracker.py @@ -1,65 +1,148 @@ """Support for Freebox devices (Freebox v6 and Freebox mini 4K).""" -from collections import namedtuple +from datetime import datetime import logging +from typing import Dict -from homeassistant.components.device_tracker import DeviceScanner +from homeassistant.components.device_tracker import SOURCE_TYPE_ROUTER +from homeassistant.components.device_tracker.config_entry import ScannerEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import callback +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.typing import HomeAssistantType -from . import DATA_FREEBOX +from .const import DEFAULT_DEVICE_NAME, DEVICE_ICONS, DOMAIN +from .router import FreeboxRouter _LOGGER = logging.getLogger(__name__) -async def async_get_scanner(hass, config): - """Validate the configuration and return a Freebox scanner.""" - scanner = FreeboxDeviceScanner(hass.data[DATA_FREEBOX]) - await scanner.async_connect() - return scanner if scanner.success_init else None +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up device tracker for Freebox component.""" + router = hass.data[DOMAIN][entry.unique_id] + tracked = set() + @callback + def update_router(): + """Update the values of the router.""" + add_entities(router, async_add_entities, tracked) -Device = namedtuple("Device", ["id", "name", "ip"]) - - -def _build_device(device_dict): - return Device( - device_dict["l2ident"]["id"], - device_dict["primary_name"], - device_dict["l3connectivities"][0]["addr"], + router.listeners.append( + async_dispatcher_connect(hass, router.signal_device_new, update_router) ) + update_router() -class FreeboxDeviceScanner(DeviceScanner): - """Queries the Freebox device.""" - def __init__(self, fbx): - """Initialize the scanner.""" - self.last_results = {} - self.success_init = False - self.connection = fbx +@callback +def add_entities(router, async_add_entities, tracked): + """Add new tracker entities from the router.""" + new_tracked = [] - async def async_connect(self): - """Initialize connection to the router.""" - # Test the router is accessible. - data = await self.connection.lan.get_hosts_list() - self.success_init = data is not None + for mac, device in router.devices.items(): + if mac in tracked: + continue - async def async_scan_devices(self): - """Scan for new devices and return a list with found device IDs.""" - await self.async_update_info() - return [device.id for device in self.last_results] + new_tracked.append(FreeboxDevice(router, device)) + tracked.add(mac) - async def get_device_name(self, device): - """Return the name of the given device or None if we don't know.""" - name = next( - (result.name for result in self.last_results if result.id == device), None + if new_tracked: + async_add_entities(new_tracked, True) + + +class FreeboxDevice(ScannerEntity): + """Representation of a Freebox device.""" + + def __init__(self, router: FreeboxRouter, device: Dict[str, any]) -> None: + """Initialize a Freebox device.""" + self._router = router + self._name = device["primary_name"].strip() or DEFAULT_DEVICE_NAME + self._mac = device["l2ident"]["id"] + self._manufacturer = device["vendor_name"] + self._icon = icon_for_freebox_device(device) + self._active = False + self._attrs = {} + + self._unsub_dispatcher = None + + def update(self) -> None: + """Update the Freebox device.""" + device = self._router.devices[self._mac] + self._active = device["active"] + if device.get("attrs") is None: + # device + self._attrs = { + "last_time_reachable": datetime.fromtimestamp( + device["last_time_reachable"] + ), + "last_time_activity": datetime.fromtimestamp(device["last_activity"]), + } + else: + # router + self._attrs = device["attrs"] + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._mac + + @property + def name(self) -> str: + """Return the name.""" + return self._name + + @property + def is_connected(self): + """Return true if the device is connected to the network.""" + return self._active + + @property + def source_type(self) -> str: + """Return the source type.""" + return SOURCE_TYPE_ROUTER + + @property + def icon(self) -> str: + """Return the icon.""" + return self._icon + + @property + def device_state_attributes(self) -> Dict[str, any]: + """Return the attributes.""" + return self._attrs + + @property + def device_info(self) -> Dict[str, any]: + """Return the device information.""" + return { + "connections": {(CONNECTION_NETWORK_MAC, self._mac)}, + "identifiers": {(DOMAIN, self.unique_id)}, + "name": self.name, + "manufacturer": self._manufacturer, + } + + @property + def should_poll(self) -> bool: + """No polling needed.""" + return False + + async def async_on_demand_update(self): + """Update state.""" + self.async_schedule_update_ha_state(True) + + async def async_added_to_hass(self): + """Register state update callback.""" + self._unsub_dispatcher = async_dispatcher_connect( + self.hass, self._router.signal_device_update, self.async_on_demand_update ) - return name - async def async_update_info(self): - """Ensure the information from the Freebox router is up to date.""" - _LOGGER.debug("Checking Devices") + async def async_will_remove_from_hass(self): + """Clean up after entity before removal.""" + self._unsub_dispatcher() - hosts = await self.connection.lan.get_hosts_list() - last_results = [_build_device(device) for device in hosts if device["active"]] - - self.last_results = last_results +def icon_for_freebox_device(device) -> str: + """Return a host icon from his type.""" + return DEVICE_ICONS.get(device["host_type"], "mdi:help-network") diff --git a/homeassistant/components/freebox/manifest.json b/homeassistant/components/freebox/manifest.json index 7a66490c90d..1bfb4924a78 100644 --- a/homeassistant/components/freebox/manifest.json +++ b/homeassistant/components/freebox/manifest.json @@ -1,9 +1,10 @@ { "domain": "freebox", "name": "Freebox", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/freebox", "requirements": ["aiofreepybox==0.0.8"], "dependencies": [], "after_dependencies": ["discovery"], - "codeowners": ["@snoof85"] + "codeowners": ["@snoof85", "@Quentame"] } diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py new file mode 100644 index 00000000000..7b4784c6ca4 --- /dev/null +++ b/homeassistant/components/freebox/router.py @@ -0,0 +1,193 @@ +"""Represent the Freebox router and its devices and sensors.""" +from datetime import datetime, timedelta +import logging +from pathlib import Path +from typing import Dict, Optional + +from aiofreepybox import Freepybox +from aiofreepybox.api.wifi import Wifi +from aiofreepybox.exceptions import HttpRequestError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.util import slugify + +from .const import ( + API_VERSION, + APP_DESC, + CONNECTION_SENSORS, + DOMAIN, + STORAGE_KEY, + STORAGE_VERSION, +) + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=30) + + +class FreeboxRouter: + """Representation of a Freebox router.""" + + def __init__(self, hass: HomeAssistantType, entry: ConfigEntry) -> None: + """Initialize a Freebox router.""" + self.hass = hass + self._entry = entry + self._host = entry.data[CONF_HOST] + self._port = entry.data[CONF_PORT] + + self._api: Freepybox = None + self._name = None + self.mac = None + self._sw_v = None + self._attrs = {} + + self.devices: Dict[str, any] = {} + self.sensors_temperature: Dict[str, int] = {} + self.sensors_connection: Dict[str, float] = {} + + self.listeners = [] + + async def setup(self) -> None: + """Set up a Freebox router.""" + self._api = await get_api(self.hass, self._host) + + try: + await self._api.open(self._host, self._port) + except HttpRequestError: + _LOGGER.exception("Failed to connect to Freebox") + return ConfigEntryNotReady + + # System + fbx_config = await self._api.system.get_config() + self.mac = fbx_config["mac"] + self._name = fbx_config["model_info"]["pretty_name"] + self._sw_v = fbx_config["firmware_version"] + + # Devices & sensors + await self.update_all() + async_track_time_interval(self.hass, self.update_all, SCAN_INTERVAL) + + async def update_all(self, now: Optional[datetime] = None) -> None: + """Update all Freebox platforms.""" + await self.update_sensors() + await self.update_devices() + + async def update_devices(self) -> None: + """Update Freebox devices.""" + new_device = False + fbx_devices: Dict[str, any] = await self._api.lan.get_hosts_list() + + # Adds the Freebox itself + fbx_devices.append( + { + "primary_name": self._name, + "l2ident": {"id": self.mac}, + "vendor_name": "Freebox SAS", + "host_type": "router", + "active": True, + "attrs": self._attrs, + } + ) + + for fbx_device in fbx_devices: + device_mac = fbx_device["l2ident"]["id"] + + if self.devices.get(device_mac) is None: + new_device = True + + self.devices[device_mac] = fbx_device + + async_dispatcher_send(self.hass, self.signal_device_update) + + if new_device: + async_dispatcher_send(self.hass, self.signal_device_new) + + async def update_sensors(self) -> None: + """Update Freebox sensors.""" + # System sensors + syst_datas: Dict[str, any] = await self._api.system.get_config() + + # According to the doc `syst_datas["sensors"]` is temperature sensors in celsius degree. + # Name and id of sensors may vary under Freebox devices. + for sensor in syst_datas["sensors"]: + self.sensors_temperature[sensor["name"]] = sensor["value"] + + # Connection sensors + connection_datas: Dict[str, any] = await self._api.connection.get_status() + for sensor_key in CONNECTION_SENSORS: + self.sensors_connection[sensor_key] = connection_datas[sensor_key] + + self._attrs = { + "IPv4": connection_datas.get("ipv4"), + "IPv6": connection_datas.get("ipv6"), + "connection_type": connection_datas["media"], + "uptime": datetime.fromtimestamp( + round(datetime.now().timestamp()) - syst_datas["uptime_val"] + ), + "firmware_version": self._sw_v, + "serial": syst_datas["serial"], + } + + async_dispatcher_send(self.hass, self.signal_sensor_update) + + async def reboot(self) -> None: + """Reboot the Freebox.""" + await self._api.system.reboot() + + async def close(self) -> None: + """Close the connection.""" + if self._api is not None: + await self._api.close() + self._api = None + + @property + def device_info(self) -> Dict[str, any]: + """Return the device information.""" + return { + "connections": {(CONNECTION_NETWORK_MAC, self.mac)}, + "identifiers": {(DOMAIN, self.mac)}, + "name": self._name, + "manufacturer": "Freebox SAS", + "sw_version": self._sw_v, + } + + @property + def signal_device_new(self) -> str: + """Event specific per Freebox entry to signal new device.""" + return f"{DOMAIN}-{self._host}-device-new" + + @property + def signal_device_update(self) -> str: + """Event specific per Freebox entry to signal updates in devices.""" + return f"{DOMAIN}-{self._host}-device-update" + + @property + def signal_sensor_update(self) -> str: + """Event specific per Freebox entry to signal updates in sensors.""" + return f"{DOMAIN}-{self._host}-sensor-update" + + @property + def sensors(self) -> Wifi: + """Return the wifi.""" + return {**self.sensors_temperature, **self.sensors_connection} + + @property + def wifi(self) -> Wifi: + """Return the wifi.""" + return self._api.wifi + + +async def get_api(hass: HomeAssistantType, host: str) -> Freepybox: + """Get the Freebox API.""" + freebox_path = Path(hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY).path) + freebox_path.mkdir(exist_ok=True) + + token_file = Path(f"{freebox_path}/{slugify(host)}.conf") + + return Freepybox(APP_DESC, token_file, API_VERSION) diff --git a/homeassistant/components/freebox/sensor.py b/homeassistant/components/freebox/sensor.py index 0653120b49c..a3c5c32901c 100644 --- a/homeassistant/components/freebox/sensor.py +++ b/homeassistant/components/freebox/sensor.py @@ -1,81 +1,127 @@ """Support for Freebox devices (Freebox v6 and Freebox mini 4K).""" import logging +from typing import Dict +from homeassistant.config_entries import ConfigEntry from homeassistant.const import DATA_RATE_KILOBYTES_PER_SECOND +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import HomeAssistantType -from . import DATA_FREEBOX +from .const import ( + CONNECTION_SENSORS, + DOMAIN, + SENSOR_DEVICE_CLASS, + SENSOR_ICON, + SENSOR_NAME, + SENSOR_UNIT, + TEMPERATURE_SENSOR_TEMPLATE, +) +from .router import FreeboxRouter _LOGGER = logging.getLogger(__name__) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: """Set up the sensors.""" - fbx = hass.data[DATA_FREEBOX] - async_add_entities([FbxRXSensor(fbx), FbxTXSensor(fbx)], True) + router = hass.data[DOMAIN][entry.unique_id] + entities = [] + + for sensor_name in router.sensors_temperature: + entities.append( + FreeboxSensor( + router, + sensor_name, + {**TEMPERATURE_SENSOR_TEMPLATE, SENSOR_NAME: f"Freebox {sensor_name}"}, + ) + ) + + for sensor_key in CONNECTION_SENSORS: + entities.append( + FreeboxSensor(router, sensor_key, CONNECTION_SENSORS[sensor_key]) + ) + + async_add_entities(entities, True) -class FbxSensor(Entity): - """Representation of a freebox sensor.""" +class FreeboxSensor(Entity): + """Representation of a Freebox sensor.""" - _name = "generic" - _unit = None - _icon = None - - def __init__(self, fbx): - """Initialize the sensor.""" - self._fbx = fbx + def __init__( + self, router: FreeboxRouter, sensor_type: str, sensor: Dict[str, any] + ) -> None: + """Initialize a Freebox sensor.""" self._state = None - self._datas = None + self._router = router + self._sensor_type = sensor_type + self._name = sensor[SENSOR_NAME] + self._unit = sensor[SENSOR_UNIT] + self._icon = sensor[SENSOR_ICON] + self._device_class = sensor[SENSOR_DEVICE_CLASS] + self._unique_id = f"{self._router.mac} {self._name}" + + self._unsub_dispatcher = None + + def update(self) -> None: + """Update the Freebox sensor.""" + state = self._router.sensors[self._sensor_type] + if self._unit == DATA_RATE_KILOBYTES_PER_SECOND: + self._state = round(state / 1000, 2) + else: + self._state = state @property - def name(self): - """Return the name of the sensor.""" + def unique_id(self) -> str: + """Return a unique ID.""" + return self._unique_id + + @property + def name(self) -> str: + """Return the name.""" return self._name @property - def unit_of_measurement(self): - """Return the unit of the sensor.""" + def state(self) -> str: + """Return the state.""" + return self._state + + @property + def unit_of_measurement(self) -> str: + """Return the unit.""" return self._unit @property - def icon(self): - """Return the icon of the sensor.""" + def icon(self) -> str: + """Return the icon.""" return self._icon @property - def state(self): - """Return the state of the sensor.""" - return self._state + def device_class(self) -> str: + """Return the device_class.""" + return self._device_class - async def async_update(self): - """Fetch status from freebox.""" - self._datas = await self._fbx.connection.get_status() + @property + def device_info(self) -> Dict[str, any]: + """Return the device information.""" + return self._router.device_info + @property + def should_poll(self) -> bool: + """No polling needed.""" + return False -class FbxRXSensor(FbxSensor): - """Update the Freebox RxSensor.""" + async def async_on_demand_update(self): + """Update state.""" + self.async_schedule_update_ha_state(True) - _name = "Freebox download speed" - _unit = DATA_RATE_KILOBYTES_PER_SECOND - _icon = "mdi:download-network" + async def async_added_to_hass(self): + """Register state update callback.""" + self._unsub_dispatcher = async_dispatcher_connect( + self.hass, self._router.signal_sensor_update, self.async_on_demand_update + ) - async def async_update(self): - """Get the value from fetched datas.""" - await super().async_update() - if self._datas is not None: - self._state = round(self._datas["rate_down"] / 1000, 2) - - -class FbxTXSensor(FbxSensor): - """Update the Freebox TxSensor.""" - - _name = "Freebox upload speed" - _unit = DATA_RATE_KILOBYTES_PER_SECOND - _icon = "mdi:upload-network" - - async def async_update(self): - """Get the value from fetched datas.""" - await super().async_update() - if self._datas is not None: - self._state = round(self._datas["rate_up"] / 1000, 2) + async def async_will_remove_from_hass(self): + """Clean up after entity before removal.""" + self._unsub_dispatcher() diff --git a/homeassistant/components/freebox/strings.json b/homeassistant/components/freebox/strings.json new file mode 100644 index 00000000000..867a497d02f --- /dev/null +++ b/homeassistant/components/freebox/strings.json @@ -0,0 +1,26 @@ +{ + "config": { + "title": "Freebox", + "step": { + "user": { + "title": "Freebox", + "data": { + "host": "Host", + "port": "Port" + } + }, + "link": { + "title": "Link Freebox router", + "description": "Click \"Submit\", then touch the right arrow on the router to register Freebox with Home Assistant.\n\n![Location of button on the router](/static/images/config_freebox.png)" + } + }, + "error":{ + "register_failed": "Failed to register, please try again", + "connection_failed": "Failed to connect, please try again", + "unknown": "Unknown error: please retry later" + }, + "abort":{ + "already_configured": "Host already configured" + } + } +} diff --git a/homeassistant/components/freebox/switch.py b/homeassistant/components/freebox/switch.py index 062d6a699fe..9e1011d5d3c 100644 --- a/homeassistant/components/freebox/switch.py +++ b/homeassistant/components/freebox/switch.py @@ -1,50 +1,65 @@ """Support for Freebox Delta, Revolution and Mini 4K.""" import logging +from typing import Dict + +from aiofreepybox.exceptions import InsufficientPermissionsError from homeassistant.components.switch import SwitchDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.typing import HomeAssistantType -from . import DATA_FREEBOX +from .const import DOMAIN +from .router import FreeboxRouter _LOGGER = logging.getLogger(__name__) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: """Set up the switch.""" - fbx = hass.data[DATA_FREEBOX] - async_add_entities([FbxWifiSwitch(fbx)], True) + router = hass.data[DOMAIN][entry.unique_id] + async_add_entities([FreeboxWifiSwitch(router)], True) -class FbxWifiSwitch(SwitchDevice): +class FreeboxWifiSwitch(SwitchDevice): """Representation of a freebox wifi switch.""" - def __init__(self, fbx): + def __init__(self, router: FreeboxRouter) -> None: """Initialize the Wifi switch.""" self._name = "Freebox WiFi" self._state = None - self._fbx = fbx + self._router = router + self._unique_id = f"{self._router.mac} {self._name}" @property - def name(self): + def unique_id(self) -> str: + """Return a unique ID.""" + return self._unique_id + + @property + def name(self) -> str: """Return the name of the switch.""" return self._name @property - def is_on(self): + def is_on(self) -> bool: """Return true if device is on.""" return self._state - async def _async_set_state(self, enabled): - """Turn the switch on or off.""" - from aiofreepybox.exceptions import InsufficientPermissionsError + @property + def device_info(self) -> Dict[str, any]: + """Return the device information.""" + return self._router.device_info + async def _async_set_state(self, enabled: bool): + """Turn the switch on or off.""" wifi_config = {"enabled": enabled} try: - await self._fbx.wifi.set_global_config(wifi_config) + await self._router.wifi.set_global_config(wifi_config) except InsufficientPermissionsError: _LOGGER.warning( - "Home Assistant does not have permissions to" - " modify the Freebox settings. Please refer" - " to documentation." + "Home Assistant does not have permissions to modify the Freebox settings. Please refer to documentation." ) async def async_turn_on(self, **kwargs): @@ -57,6 +72,6 @@ class FbxWifiSwitch(SwitchDevice): async def async_update(self): """Get the state and update it.""" - datas = await self._fbx.wifi.get_global_config() + datas = await self._router.wifi.get_global_config() active = datas["enabled"] self._state = bool(active) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b281a322b23..a7e9b63c1a5 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -29,6 +29,7 @@ FLOWS = [ "elgato", "emulated_roku", "esphome", + "freebox", "garmin_connect", "gdacs", "geofency", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 71aa2004eef..23caf750147 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -61,6 +61,9 @@ aiobotocore==0.11.1 # homeassistant.components.esphome aioesphomeapi==2.6.1 +# homeassistant.components.freebox +aiofreepybox==0.0.8 + # homeassistant.components.homekit_controller aiohomekit[IP]==0.2.29 diff --git a/tests/components/freebox/__init__.py b/tests/components/freebox/__init__.py new file mode 100644 index 00000000000..727b60ae78a --- /dev/null +++ b/tests/components/freebox/__init__.py @@ -0,0 +1 @@ +"""Tests for the Freebox component.""" diff --git a/tests/components/freebox/conftest.py b/tests/components/freebox/conftest.py new file mode 100644 index 00000000000..e813469cbbf --- /dev/null +++ b/tests/components/freebox/conftest.py @@ -0,0 +1,11 @@ +"""Test helpers for Freebox.""" +from unittest.mock import patch + +import pytest + + +@pytest.fixture(autouse=True) +def mock_path(): + """Mock path lib.""" + with patch("homeassistant.components.freebox.router.Path"): + yield diff --git a/tests/components/freebox/test_config_flow.py b/tests/components/freebox/test_config_flow.py new file mode 100644 index 00000000000..68e787e1ba0 --- /dev/null +++ b/tests/components/freebox/test_config_flow.py @@ -0,0 +1,144 @@ +"""Tests for the Freebox config flow.""" +from aiofreepybox.exceptions import ( + AuthorizationError, + HttpRequestError, + InvalidTokenError, +) +from asynctest import CoroutineMock, patch +import pytest + +from homeassistant import data_entry_flow +from homeassistant.components.freebox.const import DOMAIN +from homeassistant.config_entries import SOURCE_DISCOVERY, SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_PORT + +from tests.common import MockConfigEntry + +HOST = "myrouter.freeboxos.fr" +PORT = 1234 + + +@pytest.fixture(name="connect") +def mock_controller_connect(): + """Mock a successful connection.""" + with patch("homeassistant.components.freebox.router.Freepybox") as service_mock: + service_mock.return_value.open = CoroutineMock() + service_mock.return_value.system.get_config = CoroutineMock() + service_mock.return_value.lan.get_hosts_list = CoroutineMock() + service_mock.return_value.connection.get_status = CoroutineMock() + service_mock.return_value.close = CoroutineMock() + yield service_mock + + +async def test_user(hass): + """Test user config.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + # test with all provided + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: HOST, CONF_PORT: PORT}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "link" + + +async def test_import(hass): + """Test import step.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_HOST: HOST, CONF_PORT: PORT}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "link" + + +async def test_discovery(hass): + """Test discovery step.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DISCOVERY}, + data={CONF_HOST: HOST, CONF_PORT: PORT}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "link" + + +async def test_link(hass, connect): + """Test linking.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: HOST, CONF_PORT: PORT}, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["result"].unique_id == HOST + assert result["title"] == HOST + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + +async def test_abort_if_already_setup(hass): + """Test we abort if component is already setup.""" + MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: HOST, CONF_PORT: PORT}, unique_id=HOST + ).add_to_hass(hass) + + # Should fail, same HOST (import) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_HOST: HOST, CONF_PORT: PORT}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + # Should fail, same HOST (flow) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: HOST, CONF_PORT: PORT}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_on_link_failed(hass): + """Test when we have errors during linking the router.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: HOST, CONF_PORT: PORT}, + ) + + with patch( + "homeassistant.components.freebox.router.Freepybox.open", + side_effect=AuthorizationError(), + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "register_failed"} + + with patch( + "homeassistant.components.freebox.router.Freepybox.open", + side_effect=HttpRequestError(), + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "connection_failed"} + + with patch( + "homeassistant.components.freebox.router.Freepybox.open", + side_effect=InvalidTokenError(), + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "unknown"}