Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add sharkiq integration for Shark IQ robot vacuums #38272

Merged
merged 26 commits into from Aug 30, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions .coveragerc
Expand Up @@ -752,6 +752,7 @@ omit =
homeassistant/components/sesame/lock.py
homeassistant/components/seven_segments/image_processing.py
homeassistant/components/seventeentrack/sensor.py
homeassistant/components/sharkiq/vacuum.py
homeassistant/components/shiftr/*
homeassistant/components/shodan/sensor.py
homeassistant/components/shelly/__init__.py
Expand Down
1 change: 1 addition & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Expand Up @@ -366,6 +366,7 @@ homeassistant/components/sentry/* @dcramer @frenck
homeassistant/components/serial/* @fabaff
homeassistant/components/seven_segments/* @fabaff
homeassistant/components/seventeentrack/* @bachya
homeassistant/components/sharkiq/* @ajmarks
homeassistant/components/shell_command/* @home-assistant/core
homeassistant/components/shelly/* @balloob
homeassistant/components/shiftr/* @fabaff
Expand Down
119 changes: 119 additions & 0 deletions homeassistant/components/sharkiq/__init__.py
@@ -0,0 +1,119 @@
"""Shark IQ Integration."""

import asyncio

import async_timeout
from sharkiqpy import (
AylaApi,
SharkIqAuthError,
SharkIqAuthExpiringError,
SharkIqNotAuthedError,
get_ayla_api,
)
import voluptuous as vol

from homeassistant import exceptions
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME

from .const import API_TIMEOUT, COMPONENTS, DOMAIN, LOGGER
from .update_coordinator import SharkIqUpdateCoordinator

CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA)
ajmarks marked this conversation as resolved.
Show resolved Hide resolved


class CannotConnect(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect."""


async def async_setup(hass, config):
"""Set up the sharkiq environment."""
hass.data.setdefault(DOMAIN, {})
if DOMAIN not in config:
ajmarks marked this conversation as resolved.
Show resolved Hide resolved
return True


async def async_connect_or_timeout(ayla_api: AylaApi) -> bool:
"""Connect to vacuum."""
try:
with async_timeout.timeout(API_TIMEOUT):
LOGGER.debug("Initialize connection to Ayla networks API")
await ayla_api.async_sign_in()
except SharkIqAuthError as exc:
LOGGER.error("Authentication error connecting to Shark IQ api", exc_info=exc)
ajmarks marked this conversation as resolved.
Show resolved Hide resolved
return False
except asyncio.TimeoutError as exc:
LOGGER.error("Timeout expired", exc_info=exc)
ajmarks marked this conversation as resolved.
Show resolved Hide resolved
raise CannotConnect from exc

return True


async def async_setup_entry(hass, config_entry):
"""Initialize the sharkiq platform via config entry."""
ayla_api = get_ayla_api(
username=config_entry.data[CONF_USERNAME],
password=config_entry.data[CONF_PASSWORD],
websession=hass.helpers.aiohttp_client.async_get_clientsession(),
)

try:
if not await async_connect_or_timeout(ayla_api):
return False
except CannotConnect as exc:
raise exceptions.ConfigEntryNotReady from exc

shark_vacs = await ayla_api.async_get_devices(False)
device_names = ", ".join([d.name for d in shark_vacs])
LOGGER.debug("Found %d Shark IQ device(s): %s", len(device_names), device_names)
coordinator = SharkIqUpdateCoordinator(hass, config_entry, ayla_api, shark_vacs)

await coordinator.async_refresh()

if not coordinator.last_update_success:
raise exceptions.ConfigEntryNotReady

hass.data[DOMAIN][config_entry.entry_id] = coordinator

for component in COMPONENTS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(config_entry, component)
)

return True


async def async_disconnect_or_timeout(coordinator: SharkIqUpdateCoordinator):
"""Disconnect to vacuum."""
LOGGER.debug("Disconnecting from Ayla Api")
with async_timeout.timeout(5):
try:
await coordinator.ayla_api.async_sign_out()
except (SharkIqAuthError, SharkIqAuthExpiringError, SharkIqNotAuthedError):
pass
return True
ajmarks marked this conversation as resolved.
Show resolved Hide resolved


async def async_update_options(hass, config_entry):
"""Update options."""
await hass.config_entries.async_reload(config_entry.entry_id)


async def async_unload_entry(hass, config_entry):
"""Unload a config entry."""
unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(config_entry, component)
for component in COMPONENTS
]
)
)
if unload_ok:
domain_data = hass.data[DOMAIN][config_entry.entry_id]
try:
await async_disconnect_or_timeout(coordinator=domain_data)
except SharkIqAuthError:
ajmarks marked this conversation as resolved.
Show resolved Hide resolved
pass
hass.data[DOMAIN].pop(config_entry.entry_id)

return unload_ok
107 changes: 107 additions & 0 deletions homeassistant/components/sharkiq/config_flow.py
@@ -0,0 +1,107 @@
"""Config flow for Shark IQ integration."""

import asyncio
from typing import Dict, Optional

import aiohttp
import async_timeout
from sharkiqpy import SharkIqAuthError, get_ayla_api
import voluptuous as vol

from homeassistant import config_entries, core, exceptions
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME

from .const import DOMAIN, LOGGER # pylint:disable=unused-import

SHARKIQ_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."""
ayla_api = get_ayla_api(
username=data[CONF_USERNAME],
password=data[CONF_PASSWORD],
websession=hass.helpers.aiohttp_client.async_get_clientsession(hass),
)

try:
with async_timeout.timeout(10):
LOGGER.debug("Initialize connection to Ayla networks API")
await ayla_api.async_sign_in()
except (asyncio.TimeoutError, aiohttp.ClientError):
raise CannotConnect
except SharkIqAuthError:
raise InvalidAuth

# Return info that you want to store in the config entry.
return {"title": data[CONF_USERNAME]}


class SharkIqConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Shark IQ."""

VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL

async def _async_validate_input(self, user_input):
"""Validate form input."""
errors = {}
info = None

if user_input is not None:
ajmarks marked this conversation as resolved.
Show resolved Hide resolved
# noinspection PyBroadException
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
ajmarks marked this conversation as resolved.
Show resolved Hide resolved
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
return info, errors

async def async_step_user(self, user_input: Optional[Dict] = None):
"""Handle the initial step."""
errors = {}
if user_input is not None:
info, errors = await self._async_validate_input(user_input)
if info:
return self.async_create_entry(title=info["title"], data=user_input)

return self.async_show_form(
step_id="user", data_schema=SHARKIQ_SCHEMA, errors=errors
)

async def async_step_reauth(self, user_input: Optional[dict] = None):
ajmarks marked this conversation as resolved.
Show resolved Hide resolved
"""Handle re-auth if login is invalid."""
errors = {}

if user_input is not None:
_, errors = await self._async_validate_input(user_input)

if not errors:
for entry in self._async_current_entries():
if entry.unique_id == self.unique_id:
ajmarks marked this conversation as resolved.
Show resolved Hide resolved
self.hass.config_entries.async_update_entry(
entry, data=user_input
)

return self.async_abort(reason="reauth_successful")

if errors["base"] != "invalid_auth":
return self.async_abort(reason=errors["base"])

return self.async_show_form(
step_id="reauth", data_schema=SHARKIQ_SCHEMA, errors=errors,
)


class CannotConnect(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect."""


class InvalidAuth(exceptions.HomeAssistantError):
"""Error to indicate there is invalid auth."""
11 changes: 11 additions & 0 deletions homeassistant/components/sharkiq/const.py
@@ -0,0 +1,11 @@
"""Shark IQ Constants."""

from datetime import timedelta
import logging

API_TIMEOUT = 20
COMPONENTS = ["vacuum"]
DOMAIN = "sharkiq"
LOGGER = logging.getLogger(__package__)
SHARK = "Shark"
UPDATE_INTERVAL = timedelta(seconds=30)
9 changes: 9 additions & 0 deletions homeassistant/components/sharkiq/manifest.json
@@ -0,0 +1,9 @@
{
"domain": "sharkiq",
"name": "Shark IQ",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sharkiq",
"requirements": ["sharkiqpy==0.1.8"],
"dependencies": [],
ajmarks marked this conversation as resolved.
Show resolved Hide resolved
"codeowners": ["@ajmarks"]
}
20 changes: 20 additions & 0 deletions homeassistant/components/sharkiq/strings.json
@@ -0,0 +1,20 @@
{
"config": {
"step": {
"user": {
"data": {
"username": "[%key:common::config_flow::data::username%]",
"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_account": "[%key:common::config_flow::abort::already_configured_account%]"
ajmarks marked this conversation as resolved.
Show resolved Hide resolved
}
ajmarks marked this conversation as resolved.
Show resolved Hide resolved
}
}
27 changes: 27 additions & 0 deletions homeassistant/components/sharkiq/translations/en.json
@@ -0,0 +1,27 @@
{
"title": "Shark IQ",
"config": {
"step": {
"init": {
"data": {
"username": "Username",
"password": "Password"
}
},
"user": {
"data": {
"username": "Username",
"password": "Password"
}
}
},
"error": {
"cannot_connect": "Failed to connect",
"invalid_auth": "Invalid authentication",
"unknown": "Unexpected error"
},
"abort": {
"already_configured_account": "[%key:common::config_flow::abort::already_configured_account%]"
}
}
}