diff --git a/.coveragerc b/.coveragerc index f38836f3a65..254515c3745 100644 --- a/.coveragerc +++ b/.coveragerc @@ -335,7 +335,16 @@ omit = homeassistant/components/faa_delays/binary_sensor.py homeassistant/components/fastdotcom/* homeassistant/components/ffmpeg/camera.py - homeassistant/components/fibaro/* + homeassistant/components/fibaro/__init__.py + homeassistant/components/fibaro/binary_sensor.py + homeassistant/components/fibaro/climate.py + homeassistant/components/fibaro/cover.py + homeassistant/components/fibaro/light.py + homeassistant/components/fibaro/lock.py + homeassistant/components/fibaro/scene.py + homeassistant/components/fibaro/sensor.py + homeassistant/components/fibaro/switch.py + homeassistant/components/filesize/sensor.py homeassistant/components/fints/sensor.py homeassistant/components/fireservicerota/__init__.py homeassistant/components/fireservicerota/binary_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 97ffa74b143..183d699d2ae 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -306,6 +306,8 @@ tests/components/faa_delays/* @ntilley905 homeassistant/components/fan/* @home-assistant/core tests/components/fan/* @home-assistant/core homeassistant/components/fastdotcom/* @rohankapoorcom +homeassistant/components/fibaro/* @rappenze +tests/components/fibaro/* @rappenze homeassistant/components/file/* @fabaff tests/components/file/* @fabaff homeassistant/components/filesize/* @gjohansson-ST diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index cfe39201913..00f8c4da92d 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -3,10 +3,13 @@ from __future__ import annotations from collections import defaultdict import logging +from typing import Any from fiblary3.client.v4.client import Client as FibaroClient, StateHandler +from fiblary3.common.exceptions import HTTPException import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_ARMED, ATTR_BATTERY_LEVEL, @@ -17,16 +20,17 @@ from homeassistant.const import ( CONF_URL, CONF_USERNAME, CONF_WHITE_VALUE, - EVENT_HOMEASSISTANT_STOP, Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType from homeassistant.util import convert, slugify +from .const import CONF_IMPORT_PLUGINS, DOMAIN + _LOGGER = logging.getLogger(__name__) ATTR_CURRENT_POWER_W = "current_power_w" @@ -37,8 +41,7 @@ CONF_DIMMING = "dimming" CONF_GATEWAYS = "gateways" CONF_PLUGINS = "plugins" CONF_RESET_COLOR = "reset_color" -DOMAIN = "fibaro" -FIBARO_CONTROLLERS = "fibaro_controllers" +FIBARO_CONTROLLER = "fibaro_controller" FIBARO_DEVICES = "fibaro_devices" PLATFORMS = [ Platform.BINARY_SENSOR, @@ -102,11 +105,14 @@ GATEWAY_CONFIG = vol.Schema( ) CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - {vol.Required(CONF_GATEWAYS): vol.All(cv.ensure_list, [GATEWAY_CONFIG])} - ) - }, + vol.All( + cv.deprecated(DOMAIN), + { + DOMAIN: vol.Schema( + {vol.Required(CONF_GATEWAYS): vol.All(cv.ensure_list, [GATEWAY_CONFIG])} + ) + }, + ), extra=vol.ALLOW_EXTRA, ) @@ -116,21 +122,19 @@ class FibaroController: def __init__(self, config): """Initialize the Fibaro controller.""" - self._client = FibaroClient( config[CONF_URL], config[CONF_USERNAME], config[CONF_PASSWORD] ) self._scene_map = None # Whether to import devices from plugins - self._import_plugins = config[CONF_PLUGINS] - self._device_config = config[CONF_DEVICE_CONFIG] + self._import_plugins = config[CONF_IMPORT_PLUGINS] self._room_map = None # Mapping roomId to room object self._device_map = None # Mapping deviceId to device object self.fibaro_devices = None # List of devices by type self._callbacks = {} # Update value callbacks by deviceId self._state_handler = None # Fiblary's StateHandler object - self._excluded_devices = config[CONF_EXCLUDE] self.hub_serial = None # Unique serial number of the hub + self.name = None # The friendly name of the hub def connect(self): """Start the communication with the Fibaro controller.""" @@ -138,6 +142,7 @@ class FibaroController: login = self._client.login.get() info = self._client.info.get() self.hub_serial = slugify(info.serialNumber) + self.name = slugify(info.hcName) except AssertionError: _LOGGER.error("Can't connect to Fibaro HC. Please check URL") return False @@ -152,6 +157,23 @@ class FibaroController: self._read_scenes() return True + def connect_with_error_handling(self) -> None: + """Translate connect errors to easily differentiate auth and connect failures. + + When there is a better error handling in the used library this can be improved. + """ + try: + connected = self.connect() + if not connected: + raise FibaroConnectFailed("Connect status is false") + except HTTPException as http_ex: + if http_ex.details == "Forbidden": + raise FibaroAuthFailed from http_ex + + raise FibaroConnectFailed from http_ex + except Exception as ex: + raise FibaroConnectFailed from ex + def enable_state_handler(self): """Start StateHandler thread for monitoring updates.""" self._state_handler = StateHandler(self._client, self._on_state_change) @@ -299,16 +321,11 @@ class FibaroController: device.ha_id = ( f"{slugify(room_name)}_{slugify(device.name)}_{device.id}" ) - if ( - device.enabled - and ( - "isPlugin" not in device - or (not device.isPlugin or self._import_plugins) - ) - and device.ha_id not in self._excluded_devices + if device.enabled and ( + "isPlugin" not in device + or (not device.isPlugin or self._import_plugins) ): device.mapped_type = self._map_device_to_type(device) - device.device_config = self._device_config.get(device.ha_id, {}) else: device.mapped_type = None if (dtype := device.mapped_type) is None: @@ -357,39 +374,78 @@ class FibaroController: pass -def setup(hass: HomeAssistant, base_config: ConfigType) -> bool: - """Set up the Fibaro Component.""" +async def async_setup(hass: HomeAssistant, base_config: ConfigType) -> bool: + """Migrate configuration from configuration.yaml.""" + if DOMAIN not in base_config: + return True gateways = base_config[DOMAIN][CONF_GATEWAYS] - hass.data[FIBARO_CONTROLLERS] = {} - - def stop_fibaro(event): - """Stop Fibaro Thread.""" - _LOGGER.info("Shutting down Fibaro connection") - for controller in hass.data[FIBARO_CONTROLLERS].values(): - controller.disable_state_handler() - - hass.data[FIBARO_DEVICES] = {} - for platform in PLATFORMS: - hass.data[FIBARO_DEVICES][platform] = [] - - for gateway in gateways: - controller = FibaroController(gateway) - if controller.connect(): - hass.data[FIBARO_CONTROLLERS][controller.hub_serial] = controller - for platform in PLATFORMS: - hass.data[FIBARO_DEVICES][platform].extend( - controller.fibaro_devices[platform] - ) - - if hass.data[FIBARO_CONTROLLERS]: - for platform in PLATFORMS: - discovery.load_platform(hass, platform, DOMAIN, {}, base_config) - for controller in hass.data[FIBARO_CONTROLLERS].values(): - controller.enable_state_handler() - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_fibaro) + if gateways is None: return True - return False + # check if already configured + if hass.config_entries.async_entries(DOMAIN): + return True + + for gateway in gateways: + # prepare new config based on configuration.yaml + conf = { + CONF_URL: gateway[CONF_URL], + CONF_USERNAME: gateway[CONF_USERNAME], + CONF_PASSWORD: gateway[CONF_PASSWORD], + CONF_IMPORT_PLUGINS: gateway[CONF_PLUGINS], + } + + # import into config flow based configuration + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=conf + ) + ) + + return True + + +def _init_controller(data: dict[str, Any]) -> FibaroController: + """Validate the user input allows us to connect to fibaro.""" + controller = FibaroController(data) + controller.connect_with_error_handling() + return controller + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up the Fibaro Component.""" + try: + controller = await hass.async_add_executor_job(_init_controller, entry.data) + except FibaroConnectFailed as connect_ex: + raise ConfigEntryNotReady( + f"Could not connect to controller at {entry.data[CONF_URL]}" + ) from connect_ex + except FibaroAuthFailed: + return False + + data: dict[str, Any] = {} + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = data + data[FIBARO_CONTROLLER] = controller + devices = data[FIBARO_DEVICES] = {} + for platform in PLATFORMS: + devices[platform] = [*controller.fibaro_devices[platform]] + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + controller.enable_state_handler() + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + _LOGGER.info("Shutting down Fibaro connection") + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + hass.data[DOMAIN][entry.entry_id][FIBARO_CONTROLLER].disable_state_handler() + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok class FibaroDevice(Entity): @@ -519,3 +575,11 @@ class FibaroDevice(Entity): pass return attr + + +class FibaroConnectFailed(HomeAssistantError): + """Error to indicate we cannot connect to fibaro home center.""" + + +class FibaroAuthFailed(HomeAssistantError): + """Error to indicate that authentication failed on fibaro home center.""" diff --git a/homeassistant/components/fibaro/binary_sensor.py b/homeassistant/components/fibaro/binary_sensor.py index f7b20ec9983..1b07d3671ae 100644 --- a/homeassistant/components/fibaro/binary_sensor.py +++ b/homeassistant/components/fibaro/binary_sensor.py @@ -2,16 +2,16 @@ from __future__ import annotations from homeassistant.components.binary_sensor import ( - DOMAIN, + ENTITY_ID_FORMAT, BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.const import CONF_DEVICE_CLASS, CONF_ICON +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import FIBARO_DEVICES, FibaroDevice +from .const import DOMAIN SENSOR_TYPES = { "com.fibaro.floodSensor": ["Flood", "mdi:water", "flood"], @@ -28,20 +28,18 @@ SENSOR_TYPES = { } -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Perform the setup for Fibaro controller devices.""" - if discovery_info is None: - return - - add_entities( + async_add_entities( [ FibaroBinarySensor(device) - for device in hass.data[FIBARO_DEVICES]["binary_sensor"] + for device in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES][ + "binary_sensor" + ] ], True, ) @@ -54,9 +52,8 @@ class FibaroBinarySensor(FibaroDevice, BinarySensorEntity): """Initialize the binary_sensor.""" self._state = None super().__init__(fibaro_device) - self.entity_id = f"{DOMAIN}.{self.ha_id}" + self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) stype = None - devconf = fibaro_device.device_config if fibaro_device.type in SENSOR_TYPES: stype = fibaro_device.type elif fibaro_device.baseType in SENSOR_TYPES: @@ -67,9 +64,6 @@ class FibaroBinarySensor(FibaroDevice, BinarySensorEntity): else: self._device_class = None self._icon = None - # device_config overrides: - self._device_class = devconf.get(CONF_DEVICE_CLASS, self._device_class) - self._icon = devconf.get(CONF_ICON, self._icon) @property def icon(self): diff --git a/homeassistant/components/fibaro/climate.py b/homeassistant/components/fibaro/climate.py index 1c065ca3fb4..b639324acd0 100644 --- a/homeassistant/components/fibaro/climate.py +++ b/homeassistant/components/fibaro/climate.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate import ENTITY_ID_FORMAT, ClimateEntity from homeassistant.components.climate.const import ( HVAC_MODE_AUTO, HVAC_MODE_COOL, @@ -17,12 +17,13 @@ from homeassistant.components.climate.const import ( SUPPORT_PRESET_MODE, SUPPORT_TARGET_TEMPERATURE, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import FIBARO_DEVICES, FibaroDevice +from .const import DOMAIN PRESET_RESUME = "resume" PRESET_MOIST = "moist" @@ -98,18 +99,17 @@ HA_OPMODES_HVAC = { } -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Perform the setup for Fibaro controller devices.""" - if discovery_info is None: - return - - add_entities( - [FibaroThermostat(device) for device in hass.data[FIBARO_DEVICES]["climate"]], + async_add_entities( + [ + FibaroThermostat(device) + for device in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES]["climate"] + ], True, ) @@ -125,7 +125,7 @@ class FibaroThermostat(FibaroDevice, ClimateEntity): self._op_mode_device = None self._fan_mode_device = None self._support_flags = 0 - self.entity_id = f"climate.{self.ha_id}" + self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) self._hvac_support = [] self._preset_support = [] self._fan_support = [] diff --git a/homeassistant/components/fibaro/config_flow.py b/homeassistant/components/fibaro/config_flow.py new file mode 100644 index 00000000000..f528fd8a184 --- /dev/null +++ b/homeassistant/components/fibaro/config_flow.py @@ -0,0 +1,81 @@ +"""Config flow for Fibaro integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.typing import ConfigType + +from . import FibaroAuthFailed, FibaroConnectFailed, FibaroController +from .const import CONF_IMPORT_PLUGINS, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_IMPORT_PLUGINS, default=False): bool, + } +) + + +def _connect_to_fibaro(data: dict[str, Any]) -> FibaroController: + """Validate the user input allows us to connect to fibaro.""" + controller = FibaroController(data) + controller.connect_with_error_handling() + return controller + + +async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + controller = await hass.async_add_executor_job(_connect_to_fibaro, data) + + _LOGGER.debug( + "Successfully connected to fibaro home center %s with name %s", + controller.hub_serial, + controller.name, + ) + return {"serial_number": controller.hub_serial, "name": controller.name} + + +class FibaroConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Fibaro.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors = {} + + if user_input is not None: + try: + info = await _validate_input(self.hass, user_input) + except FibaroConnectFailed: + errors["base"] = "cannot_connect" + except FibaroAuthFailed: + errors["base"] = "invalid_auth" + else: + await self.async_set_unique_id(info["serial_number"]) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=info["name"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_import(self, import_config: ConfigType | None) -> FlowResult: + """Import a config entry.""" + return await self.async_step_user(import_config) diff --git a/homeassistant/components/fibaro/const.py b/homeassistant/components/fibaro/const.py new file mode 100644 index 00000000000..cf564ab0bfc --- /dev/null +++ b/homeassistant/components/fibaro/const.py @@ -0,0 +1,4 @@ +"""Constants for the Fibaro integration.""" + +DOMAIN = "fibaro" +CONF_IMPORT_PLUGINS = "import_plugins" diff --git a/homeassistant/components/fibaro/cover.py b/homeassistant/components/fibaro/cover.py index f98d907945b..b87ae1cbf47 100644 --- a/homeassistant/components/fibaro/cover.py +++ b/homeassistant/components/fibaro/cover.py @@ -4,28 +4,29 @@ from __future__ import annotations from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, - DOMAIN, + ENTITY_ID_FORMAT, CoverEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import FIBARO_DEVICES, FibaroDevice +from .const import DOMAIN -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fibaro covers.""" - if discovery_info is None: - return - - add_entities( - [FibaroCover(device) for device in hass.data[FIBARO_DEVICES]["cover"]], True + async_add_entities( + [ + FibaroCover(device) + for device in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES]["cover"] + ], + True, ) @@ -35,7 +36,7 @@ class FibaroCover(FibaroDevice, CoverEntity): def __init__(self, fibaro_device): """Initialize the Vera device.""" super().__init__(fibaro_device) - self.entity_id = f"{DOMAIN}.{self.ha_id}" + self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) @staticmethod def bound(position): diff --git a/homeassistant/components/fibaro/light.py b/homeassistant/components/fibaro/light.py index f3f0ee23181..c825bec28c6 100644 --- a/homeassistant/components/fibaro/light.py +++ b/homeassistant/components/fibaro/light.py @@ -8,19 +8,19 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_WHITE_VALUE, - DOMAIN, + ENTITY_ID_FORMAT, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_WHITE_VALUE, LightEntity, ) -from homeassistant.const import CONF_WHITE_VALUE +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.color as color_util -from . import CONF_COLOR, CONF_DIMMING, CONF_RESET_COLOR, FIBARO_DEVICES, FibaroDevice +from . import FIBARO_DEVICES, FibaroDevice +from .const import DOMAIN def scaleto255(value): @@ -40,18 +40,18 @@ def scaleto100(value): return max(0, min(100, ((value * 100.0) / 255.0))) -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: """Perform the setup for Fibaro controller devices.""" - if discovery_info is None: - return - async_add_entities( - [FibaroLight(device) for device in hass.data[FIBARO_DEVICES]["light"]], True + [ + FibaroLight(device) + for device in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES]["light"] + ], + True, ) @@ -67,8 +67,7 @@ class FibaroLight(FibaroDevice, LightEntity): self._update_lock = asyncio.Lock() self._white = 0 - devconf = fibaro_device.device_config - self._reset_color = devconf.get(CONF_RESET_COLOR, False) + self._reset_color = False supports_color = ( "color" in fibaro_device.properties or "colorComponents" in fibaro_device.properties @@ -91,15 +90,15 @@ class FibaroLight(FibaroDevice, LightEntity): ) # Configuration can override default capability detection - if devconf.get(CONF_DIMMING, supports_dimming): + if supports_dimming: self._supported_flags |= SUPPORT_BRIGHTNESS - if devconf.get(CONF_COLOR, supports_color): + if supports_color: self._supported_flags |= SUPPORT_COLOR - if devconf.get(CONF_WHITE_VALUE, supports_white_v): + if supports_white_v: self._supported_flags |= SUPPORT_WHITE_VALUE super().__init__(fibaro_device) - self.entity_id = f"{DOMAIN}.{self.ha_id}" + self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) @property def brightness(self): diff --git a/homeassistant/components/fibaro/lock.py b/homeassistant/components/fibaro/lock.py index bcfd10767e2..5b86a99cbc9 100644 --- a/homeassistant/components/fibaro/lock.py +++ b/homeassistant/components/fibaro/lock.py @@ -1,26 +1,27 @@ """Support for Fibaro locks.""" from __future__ import annotations -from homeassistant.components.lock import DOMAIN, LockEntity +from homeassistant.components.lock import ENTITY_ID_FORMAT, LockEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import FIBARO_DEVICES, FibaroDevice +from .const import DOMAIN -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fibaro locks.""" - if discovery_info is None: - return - - add_entities( - [FibaroLock(device) for device in hass.data[FIBARO_DEVICES]["lock"]], True + async_add_entities( + [ + FibaroLock(device) + for device in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES]["lock"] + ], + True, ) @@ -31,7 +32,7 @@ class FibaroLock(FibaroDevice, LockEntity): """Initialize the Fibaro device.""" self._state = False super().__init__(fibaro_device) - self.entity_id = f"{DOMAIN}.{self.ha_id}" + self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) def lock(self, **kwargs): """Lock the device.""" diff --git a/homeassistant/components/fibaro/manifest.json b/homeassistant/components/fibaro/manifest.json index 7bc7d5a0e49..218a6aad857 100644 --- a/homeassistant/components/fibaro/manifest.json +++ b/homeassistant/components/fibaro/manifest.json @@ -3,7 +3,8 @@ "name": "Fibaro", "documentation": "https://www.home-assistant.io/integrations/fibaro", "requirements": ["fiblary3==0.1.8"], - "codeowners": [], + "codeowners": ["@rappenze"], "iot_class": "local_push", + "config_flow": true, "loggers": ["fiblary3"] } diff --git a/homeassistant/components/fibaro/scene.py b/homeassistant/components/fibaro/scene.py index 02ebd5cd99e..cc476a29cbb 100644 --- a/homeassistant/components/fibaro/scene.py +++ b/homeassistant/components/fibaro/scene.py @@ -4,25 +4,26 @@ from __future__ import annotations from typing import Any from homeassistant.components.scene import Scene +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import FIBARO_DEVICES, FibaroDevice +from .const import DOMAIN -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: """Perform the setup for Fibaro scenes.""" - if discovery_info is None: - return - async_add_entities( - [FibaroScene(scene) for scene in hass.data[FIBARO_DEVICES]["scene"]], True + [ + FibaroScene(scene) + for scene in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES]["scene"] + ], + True, ) diff --git a/homeassistant/components/fibaro/sensor.py b/homeassistant/components/fibaro/sensor.py index 901552d0363..9a7098b018c 100644 --- a/homeassistant/components/fibaro/sensor.py +++ b/homeassistant/components/fibaro/sensor.py @@ -4,11 +4,12 @@ from __future__ import annotations from contextlib import suppress from homeassistant.components.sensor import ( - DOMAIN, + ENTITY_ID_FORMAT, SensorDeviceClass, SensorEntity, SensorStateClass, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, ENERGY_KILO_WATT_HOUR, @@ -19,10 +20,10 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import convert from . import FIBARO_DEVICES, FibaroDevice +from .const import DOMAIN SENSOR_TYPES = { "com.fibaro.temperatureSensor": [ @@ -54,25 +55,21 @@ SENSOR_TYPES = { } -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fibaro controller devices.""" - if discovery_info is None: - return - entities: list[SensorEntity] = [] - for device in hass.data[FIBARO_DEVICES]["sensor"]: + for device in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES]["sensor"]: entities.append(FibaroSensor(device)) for device_type in ("cover", "light", "switch"): - for device in hass.data[FIBARO_DEVICES][device_type]: + for device in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES][device_type]: if "energy" in device.interfaces: entities.append(FibaroEnergySensor(device)) - add_entities(entities, True) + async_add_entities(entities, True) class FibaroSensor(FibaroDevice, SensorEntity): @@ -83,7 +80,7 @@ class FibaroSensor(FibaroDevice, SensorEntity): self.current_value = None self.last_changed_time = None super().__init__(fibaro_device) - self.entity_id = f"{DOMAIN}.{self.ha_id}" + self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) if fibaro_device.type in SENSOR_TYPES: self._unit = SENSOR_TYPES[fibaro_device.type][1] self._icon = SENSOR_TYPES[fibaro_device.type][2] @@ -139,7 +136,7 @@ class FibaroEnergySensor(FibaroDevice, SensorEntity): def __init__(self, fibaro_device): """Initialize the sensor.""" super().__init__(fibaro_device) - self.entity_id = f"{DOMAIN}.{self.ha_id}_energy" + self.entity_id = ENTITY_ID_FORMAT.format(f"{self.ha_id}_energy") self._attr_name = f"{fibaro_device.friendly_name} Energy" self._attr_unique_id = f"{fibaro_device.unique_id_str}_energy" diff --git a/homeassistant/components/fibaro/strings.json b/homeassistant/components/fibaro/strings.json new file mode 100644 index 00000000000..c81e41f04dc --- /dev/null +++ b/homeassistant/components/fibaro/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "user": { + "data": { + "url": "URL in the format http://HOST/api/", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "import_plugins": "Import entities from fibaro plugins?" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fibaro/switch.py b/homeassistant/components/fibaro/switch.py index 3de9235cb0e..a075c4e0704 100644 --- a/homeassistant/components/fibaro/switch.py +++ b/homeassistant/components/fibaro/switch.py @@ -1,27 +1,28 @@ """Support for Fibaro switches.""" from __future__ import annotations -from homeassistant.components.switch import DOMAIN, SwitchEntity +from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import convert from . import FIBARO_DEVICES, FibaroDevice +from .const import DOMAIN -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fibaro switches.""" - if discovery_info is None: - return - - add_entities( - [FibaroSwitch(device) for device in hass.data[FIBARO_DEVICES]["switch"]], True + async_add_entities( + [ + FibaroSwitch(device) + for device in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES]["switch"] + ], + True, ) @@ -32,7 +33,7 @@ class FibaroSwitch(FibaroDevice, SwitchEntity): """Initialize the Fibaro device.""" self._state = False super().__init__(fibaro_device) - self.entity_id = f"{DOMAIN}.{self.ha_id}" + self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) def turn_on(self, **kwargs): """Turn device on.""" diff --git a/homeassistant/components/fibaro/translations/en.json b/homeassistant/components/fibaro/translations/en.json new file mode 100644 index 00000000000..2baeb3a7213 --- /dev/null +++ b/homeassistant/components/fibaro/translations/en.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "url": "URL in the format http://HOST/api/", + "import_plugins": "Import entities from fibaro plugins?", + "password": "Password", + "username": "Username" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 5a8ea33c7bd..a8a5f492e9f 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -97,6 +97,7 @@ FLOWS = { "evil_genius_labs", "ezviz", "faa_delays", + "fibaro", "filesize", "fireservicerota", "fivem", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 98dfc5aef0a..28cfe61801b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -433,6 +433,9 @@ faadelays==0.0.7 # homeassistant.components.feedreader feedparser==6.0.2 +# homeassistant.components.fibaro +fiblary3==0.1.8 + # homeassistant.components.fivem fivem-api==0.1.2 diff --git a/tests/components/fibaro/__init__.py b/tests/components/fibaro/__init__.py new file mode 100644 index 00000000000..3787f00202e --- /dev/null +++ b/tests/components/fibaro/__init__.py @@ -0,0 +1 @@ +"""Tests for the Fibaro integration.""" diff --git a/tests/components/fibaro/test_config_flow.py b/tests/components/fibaro/test_config_flow.py new file mode 100644 index 00000000000..6f3e035a2f7 --- /dev/null +++ b/tests/components/fibaro/test_config_flow.py @@ -0,0 +1,204 @@ +"""Test the Fibaro config flow.""" +from unittest.mock import Mock, patch + +from fiblary3.common.exceptions import HTTPException +import pytest + +from homeassistant import config_entries +from homeassistant.components.fibaro import DOMAIN +from homeassistant.components.fibaro.const import CONF_IMPORT_PLUGINS +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME + +TEST_SERIALNUMBER = "HC2-111111" +TEST_NAME = "my_fibaro_home_center" +TEST_URL = "http://192.168.1.1/api/" +TEST_USERNAME = "user" +TEST_PASSWORD = "password" + + +@pytest.fixture(name="fibaro_client", autouse=True) +def fibaro_client_fixture(): + """Mock common methods and attributes of fibaro client.""" + info_mock = Mock() + info_mock.get.return_value = Mock(serialNumber=TEST_SERIALNUMBER, hcName=TEST_NAME) + + array_mock = Mock() + array_mock.list.return_value = [] + + with patch("fiblary3.client.v4.client.Client.__init__", return_value=None,), patch( + "fiblary3.client.v4.client.Client.info", + info_mock, + create=True, + ), patch("fiblary3.client.v4.client.Client.rooms", array_mock, create=True,), patch( + "fiblary3.client.v4.client.Client.devices", + array_mock, + create=True, + ), patch( + "fiblary3.client.v4.client.Client.scenes", + array_mock, + create=True, + ): + yield + + +async def test_config_flow_user_initiated_success(hass): + """Successful flow manually initialized by the user.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {} + + login_mock = Mock() + login_mock.get.return_value = Mock(status=True) + with patch("fiblary3.client.v4.client.Client.login", login_mock, create=True): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["type"] == "create_entry" + assert result["title"] == TEST_NAME + assert result["data"] == { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_IMPORT_PLUGINS: False, + } + + +async def test_config_flow_user_initiated_connect_failure(hass): + """Connect failure in flow manually initialized by the user.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {} + + login_mock = Mock() + login_mock.get.return_value = Mock(status=False) + with patch("fiblary3.client.v4.client.Client.login", login_mock, create=True): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_config_flow_user_initiated_auth_failure(hass): + """Authentication failure in flow manually initialized by the user.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {} + + login_mock = Mock() + login_mock.get.side_effect = HTTPException(details="Forbidden") + with patch("fiblary3.client.v4.client.Client.login", login_mock, create=True): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_config_flow_user_initiated_unknown_failure_1(hass): + """Unknown failure in flow manually initialized by the user.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {} + + login_mock = Mock() + login_mock.get.side_effect = HTTPException(details="Any") + with patch("fiblary3.client.v4.client.Client.login", login_mock, create=True): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_config_flow_user_initiated_unknown_failure_2(hass): + """Unknown failure in flow manually initialized by the user.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_config_flow_import(hass): + """Test for importing config from configuration.yaml.""" + login_mock = Mock() + login_mock.get.return_value = Mock(status=True) + with patch("fiblary3.client.v4.client.Client.login", login_mock, create=True): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_IMPORT_PLUGINS: False, + }, + ) + + assert result["type"] == "create_entry" + assert result["title"] == TEST_NAME + assert result["data"] == { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_IMPORT_PLUGINS: False, + }