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
pull/32708/head
Quentame 2020-03-11 22:15:59 +01:00 committed by GitHub
parent 180bcad477
commit 19be4a5d6d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 917 additions and 165 deletions

View File

@ -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

View File

@ -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

View File

@ -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"
}
}

View File

@ -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

View File

@ -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)

View File

@ -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",
}

View File

@ -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")

View File

@ -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"]
}

View File

@ -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)

View File

@ -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()

View File

@ -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"
}
}
}

View File

@ -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)

View File

@ -29,6 +29,7 @@ FLOWS = [
"elgato",
"emulated_roku",
"esphome",
"freebox",
"garmin_connect",
"gdacs",
"geofency",

View File

@ -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

View File

@ -0,0 +1 @@
"""Tests for the Freebox component."""

View File

@ -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

View File

@ -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"}