diff --git a/.coveragerc b/.coveragerc index 51a539b4aba..2b71ba546cc 100644 --- a/.coveragerc +++ b/.coveragerc @@ -787,6 +787,8 @@ omit = homeassistant/components/rest/switch.py homeassistant/components/ring/camera.py homeassistant/components/ripple/sensor.py + homeassistant/components/rituals_perfume_genie/switch.py + homeassistant/components/rituals_perfume_genie/__init__.py homeassistant/components/rocketchat/notify.py homeassistant/components/roomba/binary_sensor.py homeassistant/components/roomba/braava.py diff --git a/CODEOWNERS b/CODEOWNERS index ab10b4dfd60..f3c7487a520 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -383,6 +383,7 @@ homeassistant/components/rflink/* @javicalle homeassistant/components/rfxtrx/* @danielhiversen @elupus @RobBie1221 homeassistant/components/ring/* @balloob homeassistant/components/risco/* @OnFreund +homeassistant/components/rituals_perfume_genie/* @milanmeu homeassistant/components/rmvtransport/* @cgtobi homeassistant/components/roku/* @ctalkington homeassistant/components/roomba/* @pschmitt @cyr-ius @shenxn diff --git a/homeassistant/components/rituals_perfume_genie/__init__.py b/homeassistant/components/rituals_perfume_genie/__init__.py new file mode 100644 index 00000000000..ba11206d496 --- /dev/null +++ b/homeassistant/components/rituals_perfume_genie/__init__.py @@ -0,0 +1,61 @@ +"""The Rituals Perfume Genie integration.""" +import asyncio +import logging + +from aiohttp.client_exceptions import ClientConnectorError +from pyrituals import Account + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ACCOUNT_HASH, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +EMPTY_CREDENTIALS = "" + +PLATFORMS = ["switch"] + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Rituals Perfume Genie component.""" + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Rituals Perfume Genie from a config entry.""" + session = async_get_clientsession(hass) + account = Account(EMPTY_CREDENTIALS, EMPTY_CREDENTIALS, session) + account.data = {ACCOUNT_HASH: entry.data.get(ACCOUNT_HASH)} + + try: + await account.get_devices() + except ClientConnectorError as ex: + raise ConfigEntryNotReady from ex + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = account + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/rituals_perfume_genie/config_flow.py b/homeassistant/components/rituals_perfume_genie/config_flow.py new file mode 100644 index 00000000000..59e442df538 --- /dev/null +++ b/homeassistant/components/rituals_perfume_genie/config_flow.py @@ -0,0 +1,60 @@ +"""Config flow for Rituals Perfume Genie integration.""" +import logging + +from aiohttp import ClientResponseError +from pyrituals import Account, AuthenticationException +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ACCOUNT_HASH, DOMAIN # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Rituals Perfume Genie.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + if user_input is None: + return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA) + + errors = {} + + session = async_get_clientsession(self.hass) + account = Account(user_input[CONF_EMAIL], user_input[CONF_PASSWORD], session) + + try: + await account.authenticate() + except ClientResponseError: + errors["base"] = "cannot_connect" + except AuthenticationException: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(account.data[CONF_EMAIL]) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=account.data[CONF_EMAIL], + data={ACCOUNT_HASH: account.data[ACCOUNT_HASH]}, + ) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/rituals_perfume_genie/const.py b/homeassistant/components/rituals_perfume_genie/const.py new file mode 100644 index 00000000000..075d79ec8de --- /dev/null +++ b/homeassistant/components/rituals_perfume_genie/const.py @@ -0,0 +1,5 @@ +"""Constants for the Rituals Perfume Genie integration.""" + +DOMAIN = "rituals_perfume_genie" + +ACCOUNT_HASH = "account_hash" diff --git a/homeassistant/components/rituals_perfume_genie/manifest.json b/homeassistant/components/rituals_perfume_genie/manifest.json new file mode 100644 index 00000000000..8be7e98b939 --- /dev/null +++ b/homeassistant/components/rituals_perfume_genie/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "rituals_perfume_genie", + "name": "Rituals Perfume Genie", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/rituals_perfume_genie", + "requirements": [ + "pyrituals==0.0.2" + ], + "codeowners": [ + "@milanmeu" + ] +} diff --git a/homeassistant/components/rituals_perfume_genie/strings.json b/homeassistant/components/rituals_perfume_genie/strings.json new file mode 100644 index 00000000000..8824923c313 --- /dev/null +++ b/homeassistant/components/rituals_perfume_genie/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "title": "Connect to your Rituals account", + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "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%]" + } + } +} diff --git a/homeassistant/components/rituals_perfume_genie/switch.py b/homeassistant/components/rituals_perfume_genie/switch.py new file mode 100644 index 00000000000..7041d22f4b8 --- /dev/null +++ b/homeassistant/components/rituals_perfume_genie/switch.py @@ -0,0 +1,92 @@ +"""Support for Rituals Perfume Genie switches.""" +from datetime import timedelta + +from homeassistant.components.switch import SwitchEntity + +from .const import DOMAIN + +SCAN_INTERVAL = timedelta(seconds=30) + +ON_STATE = "1" +AVAILABLE_STATE = 1 + +MANUFACTURER = "Rituals Cosmetics" +MODEL = "Diffuser" +ICON = "mdi:fan" + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the diffuser switch.""" + account = hass.data[DOMAIN][config_entry.entry_id] + diffusers = await account.get_devices() + + entities = [] + for diffuser in diffusers: + entities.append(DiffuserSwitch(diffuser)) + + async_add_entities(entities, True) + + +class DiffuserSwitch(SwitchEntity): + """Representation of a diffuser switch.""" + + def __init__(self, diffuser): + """Initialize the switch.""" + self._diffuser = diffuser + + @property + def device_info(self): + """Return information about the device.""" + return { + "name": self._diffuser.data["hub"]["attributes"]["roomnamec"], + "identifiers": {(DOMAIN, self._diffuser.data["hub"]["hublot"])}, + "manufacturer": MANUFACTURER, + "model": MODEL, + "sw_version": self._diffuser.data["hub"]["sensors"]["versionc"], + } + + @property + def unique_id(self): + """Return the unique ID of the device.""" + return self._diffuser.data["hub"]["hublot"] + + @property + def available(self): + """Return if the device is available.""" + return self._diffuser.data["hub"]["status"] == AVAILABLE_STATE + + @property + def name(self): + """Return the name of the device.""" + return self._diffuser.data["hub"]["attributes"]["roomnamec"] + + @property + def icon(self): + """Return the icon of the device.""" + return ICON + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attributes = { + "fan_speed": self._diffuser.data["hub"]["attributes"]["speedc"], + "room_size": self._diffuser.data["hub"]["attributes"]["roomc"], + } + return attributes + + @property + def is_on(self): + """If the device is currently on or off.""" + return self._diffuser.data["hub"]["attributes"]["fanc"] == ON_STATE + + async def async_turn_on(self, **kwargs): + """Turn the device on.""" + await self._diffuser.turn_on() + + async def async_turn_off(self, **kwargs): + """Turn the device off.""" + await self._diffuser.turn_off() + + async def async_update(self): + """Update the data of the device.""" + await self._diffuser.update_data() diff --git a/homeassistant/components/rituals_perfume_genie/translations/en.json b/homeassistant/components/rituals_perfume_genie/translations/en.json new file mode 100644 index 00000000000..21207b1e7ed --- /dev/null +++ b/homeassistant/components/rituals_perfume_genie/translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "email": "Email", + "password": "Password" + }, + "title": "Connect to your Rituals account" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 53d3c8294d2..dfb2f56b29e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -182,6 +182,7 @@ FLOWS = [ "rfxtrx", "ring", "risco", + "rituals_perfume_genie", "roku", "roomba", "roon", diff --git a/requirements_all.txt b/requirements_all.txt index 45fd1d9850b..35a4d925225 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1652,6 +1652,9 @@ pyrepetier==3.0.5 # homeassistant.components.risco pyrisco==0.3.1 +# homeassistant.components.rituals_perfume_genie +pyrituals==0.0.2 + # homeassistant.components.ruckus_unleashed pyruckus==0.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cec8c469d4b..7f920b735e7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -876,6 +876,9 @@ pyqwikswitch==0.93 # homeassistant.components.risco pyrisco==0.3.1 +# homeassistant.components.rituals_perfume_genie +pyrituals==0.0.2 + # homeassistant.components.ruckus_unleashed pyruckus==0.12 diff --git a/tests/components/rituals_perfume_genie/__init__.py b/tests/components/rituals_perfume_genie/__init__.py new file mode 100644 index 00000000000..bd90242f14c --- /dev/null +++ b/tests/components/rituals_perfume_genie/__init__.py @@ -0,0 +1 @@ +"""Tests for the Rituals Perfume Genie integration.""" diff --git a/tests/components/rituals_perfume_genie/test_config_flow.py b/tests/components/rituals_perfume_genie/test_config_flow.py new file mode 100644 index 00000000000..60ec389a371 --- /dev/null +++ b/tests/components/rituals_perfume_genie/test_config_flow.py @@ -0,0 +1,109 @@ +"""Test the Rituals Perfume Genie config flow.""" +from unittest.mock import patch + +from aiohttp import ClientResponseError +from pyrituals import AuthenticationException + +from homeassistant import config_entries +from homeassistant.components.rituals_perfume_genie.const import ACCOUNT_HASH, DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD + +TEST_EMAIL = "rituals@example.com" +VALID_PASSWORD = "passw0rd" +WRONG_PASSWORD = "wrong-passw0rd" + + +async def test_form(hass): + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] is None + + with patch( + "homeassistant.components.rituals_perfume_genie.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.rituals_perfume_genie.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: VALID_PASSWORD, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == TEST_EMAIL + assert isinstance(result2["data"][ACCOUNT_HASH], str) + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass): + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.rituals_perfume_genie.config_flow.Account.authenticate", + side_effect=AuthenticationException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: WRONG_PASSWORD, + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_auth_exception(hass): + """Test we handle auth exception.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.rituals_perfume_genie.config_flow.Account.authenticate", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: VALID_PASSWORD, + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.rituals_perfume_genie.config_flow.Account.authenticate", + side_effect=ClientResponseError(None, None, status=500), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: TEST_EMAIL, + CONF_PASSWORD: VALID_PASSWORD, + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"}