diff --git a/CODEOWNERS b/CODEOWNERS index 4d9ec3a2f0f..9adf5110b4f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -233,6 +233,7 @@ homeassistant/components/moon/* @fabaff homeassistant/components/mpd/* @fabaff homeassistant/components/mqtt/* @home-assistant/core homeassistant/components/msteams/* @peroyvind +homeassistant/components/myq/* @bdraco homeassistant/components/mysensors/* @MartinHjelmare homeassistant/components/mystrom/* @fabaff homeassistant/components/neato/* @dshokouhi @Santobert diff --git a/homeassistant/components/myq/.translations/en.json b/homeassistant/components/myq/.translations/en.json new file mode 100644 index 00000000000..c31162b2894 --- /dev/null +++ b/homeassistant/components/myq/.translations/en.json @@ -0,0 +1,22 @@ +{ + "config": { + "title": "MyQ", + "step": { + "user": { + "title": "Connect to the MyQ Gateway", + "data": { + "username": "Username", + "password": "Password" + } + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "MyQ is already configured" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/myq/__init__.py b/homeassistant/components/myq/__init__.py index e9fa7900d90..51ad9fb48f0 100644 --- a/homeassistant/components/myq/__init__.py +++ b/homeassistant/components/myq/__init__.py @@ -1 +1,64 @@ -"""The myq component.""" +"""The MyQ integration.""" +import asyncio +import logging + +import pymyq +from pymyq.errors import InvalidCredentialsError, MyQError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN, PLATFORMS + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the MyQ component.""" + + hass.data.setdefault(DOMAIN, {}) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up MyQ from a config entry.""" + + websession = aiohttp_client.async_get_clientsession(hass) + conf = entry.data + + try: + myq = await pymyq.login(conf[CONF_USERNAME], conf[CONF_PASSWORD], websession) + except InvalidCredentialsError as err: + _LOGGER.error("There was an error while logging in: %s", err) + return False + except MyQError: + raise ConfigEntryNotReady + + hass.data[DOMAIN][entry.entry_id] = myq + + 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/myq/config_flow.py b/homeassistant/components/myq/config_flow.py new file mode 100644 index 00000000000..baa7aad4cff --- /dev/null +++ b/homeassistant/components/myq/config_flow.py @@ -0,0 +1,84 @@ +"""Config flow for MyQ integration.""" +import logging + +import pymyq +from pymyq.errors import InvalidCredentialsError, MyQError +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} +) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + + websession = aiohttp_client.async_get_clientsession(hass) + + try: + await pymyq.login(data[CONF_USERNAME], data[CONF_PASSWORD], websession) + except InvalidCredentialsError: + raise InvalidAuth + except MyQError: + raise CannotConnect + + return {"title": data[CONF_USERNAME]} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for MyQ.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + if "base" not in errors: + await self.async_set_unique_id(user_input[CONF_USERNAME]) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_homekit(self, homekit_info): + """Handle HomeKit discovery.""" + return await self.async_step_user() + + async def async_step_import(self, user_input): + """Handle import.""" + await self.async_set_unique_id(user_input[CONF_USERNAME]) + self._abort_if_unique_id_configured() + return await self.async_step_user(user_input) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/myq/const.py b/homeassistant/components/myq/const.py new file mode 100644 index 00000000000..260811e54ce --- /dev/null +++ b/homeassistant/components/myq/const.py @@ -0,0 +1,18 @@ +"""The MyQ integration.""" +from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING + +DOMAIN = "myq" + +PLATFORMS = ["cover"] + +MYQ_DEVICE_TYPE = "device_type" +MYQ_DEVICE_TYPE_GATE = "gate" +MYQ_DEVICE_STATE = "state" +MYQ_DEVICE_STATE_ONLINE = "online" + +MYQ_TO_HASS = { + "closed": STATE_CLOSED, + "closing": STATE_CLOSING, + "open": STATE_OPEN, + "opening": STATE_OPENING, +} diff --git a/homeassistant/components/myq/cover.py b/homeassistant/components/myq/cover.py index 3f0895d9931..0df61b4d5db 100644 --- a/homeassistant/components/myq/cover.py +++ b/homeassistant/components/myq/cover.py @@ -1,8 +1,6 @@ """Support for MyQ-Enabled Garage Doors.""" import logging -from pymyq import login -from pymyq.errors import MyQError import voluptuous as vol from homeassistant.components.cover import ( @@ -11,25 +9,21 @@ from homeassistant.components.cover import ( SUPPORT_OPEN, CoverDevice, ) +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( CONF_PASSWORD, CONF_TYPE, CONF_USERNAME, STATE_CLOSED, STATE_CLOSING, - STATE_OPEN, STATE_OPENING, ) -from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN, MYQ_DEVICE_STATE, MYQ_DEVICE_STATE_ONLINE, MYQ_TO_HASS _LOGGER = logging.getLogger(__name__) -MYQ_TO_HASS = { - "closed": STATE_CLOSED, - "closing": STATE_CLOSING, - "open": STATE_OPEN, - "opening": STATE_OPENING, -} PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -38,23 +32,28 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( # This parameter is no longer used; keeping it to avoid a breaking change in # a hotfix, but in a future main release, this should be removed: vol.Optional(CONF_TYPE): cv.string, - } + }, ) async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the platform.""" - websession = aiohttp_client.async_get_clientsession(hass) - username = config[CONF_USERNAME] - password = config[CONF_PASSWORD] + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_USERNAME: config[CONF_USERNAME], + CONF_PASSWORD: config[CONF_PASSWORD], + }, + ) + ) - try: - myq = await login(username, password, websession) - except MyQError as err: - _LOGGER.error("There was an error while logging in: %s", err) - return +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up mysq covers.""" + myq = hass.data[DOMAIN][config_entry.entry_id] async_add_entities([MyQDevice(device) for device in myq.covers.values()], True) @@ -75,6 +74,14 @@ class MyQDevice(CoverDevice): """Return the name of the garage door if any.""" return self._device.name + @property + def available(self): + """Return if the device is online.""" + # Not all devices report online so assume True if its missing + return self._device.device_json[MYQ_DEVICE_STATE].get( + MYQ_DEVICE_STATE_ONLINE, True + ) + @property def is_closed(self): """Return true if cover is closed, else False.""" @@ -103,11 +110,28 @@ class MyQDevice(CoverDevice): async def async_close_cover(self, **kwargs): """Issue close command to cover.""" await self._device.close() + # Writes closing state + self.async_write_ha_state() async def async_open_cover(self, **kwargs): """Issue open command to cover.""" await self._device.open() + # Writes opening state + self.async_write_ha_state() async def async_update(self): """Update status of cover.""" await self._device.update() + + @property + def device_info(self): + """Return the device_info of the device.""" + device_info = { + "identifiers": {(DOMAIN, self._device.device_id)}, + "name": self._device.name, + "manufacturer": "The Chamberlain Group Inc.", + "sw_version": self._device.firmware_version, + } + if self._device.parent_device_id: + device_info["via_device"] = (DOMAIN, self._device.parent_device_id) + return device_info diff --git a/homeassistant/components/myq/manifest.json b/homeassistant/components/myq/manifest.json index 7e00e025bd3..afee7d4d77f 100644 --- a/homeassistant/components/myq/manifest.json +++ b/homeassistant/components/myq/manifest.json @@ -2,7 +2,15 @@ "domain": "myq", "name": "MyQ", "documentation": "https://www.home-assistant.io/integrations/myq", - "requirements": ["pymyq==2.0.1"], + "requirements": [ + "pymyq==2.0.1" + ], "dependencies": [], - "codeowners": [] + "codeowners": ["@bdraco"], + "config_flow": true, + "homekit": { + "models": [ + "819LMB" + ] + } } diff --git a/homeassistant/components/myq/strings.json b/homeassistant/components/myq/strings.json new file mode 100644 index 00000000000..c31162b2894 --- /dev/null +++ b/homeassistant/components/myq/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "title": "MyQ", + "step": { + "user": { + "title": "Connect to the MyQ Gateway", + "data": { + "username": "Username", + "password": "Password" + } + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "MyQ is already configured" + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index c981a88984e..0d59a67c665 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -70,6 +70,7 @@ FLOWS = [ "minecraft_server", "mobile_app", "mqtt", + "myq", "neato", "nest", "netatmo", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 1cf88a5c7ae..1a9972e9a6e 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -39,6 +39,7 @@ ZEROCONF = { } HOMEKIT = { + "819LMB": "myq", "BSB002": "hue", "LIFX": "lifx", "Netatmo Relay": "netatmo", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 33d81c03d5b..d1a159c2a3f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -535,6 +535,9 @@ pymodbus==1.5.2 # homeassistant.components.monoprice pymonoprice==0.3 +# homeassistant.components.myq +pymyq==2.0.1 + # homeassistant.components.nws pynws==0.10.4 diff --git a/tests/components/myq/__init__.py b/tests/components/myq/__init__.py new file mode 100644 index 00000000000..63dd25a4d0b --- /dev/null +++ b/tests/components/myq/__init__.py @@ -0,0 +1 @@ +"""Tests for the MyQ integration.""" diff --git a/tests/components/myq/test_config_flow.py b/tests/components/myq/test_config_flow.py new file mode 100644 index 00000000000..c0bae8c5225 --- /dev/null +++ b/tests/components/myq/test_config_flow.py @@ -0,0 +1,103 @@ +"""Test the MyQ config flow.""" +from asynctest import patch +from pymyq.errors import InvalidCredentialsError, MyQError + +from homeassistant import config_entries, setup +from homeassistant.components.myq.const import DOMAIN + + +async def test_form_user(hass): + """Test we get the user form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.myq.config_flow.pymyq.login", return_value=True, + ), patch( + "homeassistant.components.myq.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.myq.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-username", "password": "test-password"}, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "test-username" + assert result2["data"] == { + "username": "test-username", + "password": "test-password", + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import(hass): + """Test we can import.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with patch( + "homeassistant.components.myq.config_flow.pymyq.login", return_value=True, + ), patch( + "homeassistant.components.myq.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.myq.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={"username": "test-username", "password": "test-password"}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == "test-username" + assert result["data"] == { + "username": "test-username", + "password": "test-password", + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass): + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.myq.config_flow.pymyq.login", + side_effect=InvalidCredentialsError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-username", "password": "test-password"}, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.myq.config_flow.pymyq.login", side_effect=MyQError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-username", "password": "test-password"}, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"}