Add Shelly integration (#39178)

This commit is contained in:
Paulus Schoutsen 2020-08-24 12:43:31 +02:00 committed by GitHub
parent 6d95ee7a00
commit ca2bc9906d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 624 additions and 0 deletions

View File

@ -754,6 +754,8 @@ omit =
homeassistant/components/seventeentrack/sensor.py
homeassistant/components/shiftr/*
homeassistant/components/shodan/sensor.py
homeassistant/components/shelly/__init__.py
homeassistant/components/shelly/switch.py
homeassistant/components/sht31/sensor.py
homeassistant/components/sigfox/sensor.py
homeassistant/components/simplepush/notify.py

View File

@ -367,6 +367,7 @@ homeassistant/components/serial/* @fabaff
homeassistant/components/seven_segments/* @fabaff
homeassistant/components/seventeentrack/* @bachya
homeassistant/components/shell_command/* @home-assistant/core
homeassistant/components/shelly/* @balloob
homeassistant/components/shiftr/* @fabaff
homeassistant/components/shodan/* @fabaff
homeassistant/components/sighthound/* @robmarkcole

View File

@ -0,0 +1,187 @@
"""The Shelly integration."""
import asyncio
from datetime import timedelta
import logging
from aiocoap import error as aiocoap_error
import aioshelly
import async_timeout
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import (
aiohttp_client,
device_registry,
entity,
update_coordinator,
)
from .const import DOMAIN
PLATFORMS = ["switch"]
_LOGGER = logging.getLogger(__name__)
async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the Shelly component."""
hass.data[DOMAIN] = {}
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up Shelly from a config entry."""
try:
async with async_timeout.timeout(5):
device = await aioshelly.Device.create(
entry.data["host"], aiohttp_client.async_get_clientsession(hass)
)
except (asyncio.TimeoutError, OSError):
raise ConfigEntryNotReady
wrapper = hass.data[DOMAIN][entry.entry_id] = ShellyDeviceWrapper(
hass, entry, device
)
await wrapper.async_setup()
for component in PLATFORMS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, component)
)
return True
class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator):
"""Wrapper for a Shelly device with Home Assistant specific functions."""
def __init__(self, hass, entry, device: aioshelly.Device):
"""Initialize the Shelly device wrapper."""
super().__init__(
hass,
_LOGGER,
name=device.settings["name"] or entry.title,
update_interval=timedelta(seconds=5),
)
self.hass = hass
self.entry = entry
self.device = device
self._unsub_stop = None
async def _async_update_data(self):
"""Fetch data."""
# Race condition on shutdown. Stop all the fetches.
if self._unsub_stop is None:
return None
try:
async with async_timeout.timeout(5):
return await self.device.update()
except aiocoap_error.Error:
raise update_coordinator.UpdateFailed("Error fetching data")
@property
def model(self):
"""Model of the device."""
return self.device.settings["device"]["type"]
@property
def mac(self):
"""Mac address of the device."""
return self.device.settings["device"]["mac"]
async def async_setup(self):
"""Set up the wrapper."""
self._unsub_stop = self.hass.bus.async_listen(
EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop
)
dev_reg = await device_registry.async_get_registry(self.hass)
model_type = self.device.settings["device"]["type"]
dev_reg.async_get_or_create(
config_entry_id=self.entry.entry_id,
name=self.name,
connections={(device_registry.CONNECTION_NETWORK_MAC, self.mac)},
# This is duplicate but otherwise via_device can't work
identifiers={(DOMAIN, self.mac)},
manufacturer="Shelly",
model=aioshelly.MODEL_NAMES.get(model_type, model_type),
sw_version=self.device.settings["fw"],
)
async def shutdown(self):
"""Shutdown the device wrapper."""
if self._unsub_stop:
self._unsub_stop()
self._unsub_stop = None
await self.device.shutdown()
async def _handle_ha_stop(self, _):
"""Handle Home Assistant stopping."""
self._unsub_stop = None
await self.shutdown()
class ShellyBlockEntity(entity.Entity):
"""Helper class to represent a block."""
def __init__(self, wrapper: ShellyDeviceWrapper, block):
"""Initialize Shelly entity."""
self.wrapper = wrapper
self.block = block
@property
def name(self):
"""Name of entity."""
return f"{self.wrapper.name} - {self.block.description}"
@property
def should_poll(self):
"""If device should be polled."""
return False
@property
def device_info(self):
"""Device info."""
return {
"connections": {(device_registry.CONNECTION_NETWORK_MAC, self.wrapper.mac)}
}
@property
def available(self):
"""Available."""
return self.wrapper.last_update_success
@property
def unique_id(self):
"""Return unique ID of entity."""
return f"{self.wrapper.mac}-{self.block.index}"
async def async_added_to_hass(self):
"""When entity is added to HASS."""
self.async_on_remove(self.wrapper.async_add_listener(self._update_callback))
async def async_update(self):
"""Update entity with latest info."""
await self.wrapper.async_request_refresh()
@callback
def _update_callback(self):
"""Handle device update."""
self.async_write_ha_state()
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:
await hass.data[DOMAIN].pop(entry.entry_id).shutdown()
return unload_ok

View File

@ -0,0 +1,129 @@
"""Config flow for Shelly integration."""
import asyncio
import logging
import aiohttp
import aioshelly
import async_timeout
import voluptuous as vol
from homeassistant import config_entries, core
from homeassistant.helpers import aiohttp_client
from .const import DOMAIN # pylint:disable=unused-import
_LOGGER = logging.getLogger(__name__)
DATA_SCHEMA = vol.Schema({"host": str})
HTTP_CONNECT_ERRORS = (asyncio.TimeoutError, aiohttp.ClientError)
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.
"""
async with async_timeout.timeout(5):
device = await aioshelly.Device.create(
data["host"], aiohttp_client.async_get_clientsession(hass)
)
await device.shutdown()
# Return info that you want to store in the config entry.
return {"title": device.settings["name"], "mac": device.settings["device"]["mac"]}
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Shelly."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
host = None
info = None
async def async_step_user(self, user_input=None):
"""Handle the initial step."""
errors = {}
if user_input is not None:
try:
info = await self._async_get_info(user_input["host"])
except HTTP_CONNECT_ERRORS:
errors["base"] = "cannot_connect"
else:
if info["auth"]:
return self.async_abort(reason="auth_not_supported")
try:
device_info = await validate_input(self.hass, user_input)
except asyncio.TimeoutError:
errors["base"] = "cannot_connect"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(device_info["mac"])
return self.async_create_entry(
title=device_info["title"] or user_input["host"],
data=user_input,
)
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
async def async_step_zeroconf(self, zeroconf_info):
"""Handle zeroconf discovery."""
if not zeroconf_info.get("name", "").startswith("shelly"):
return self.async_abort(reason="not_shelly")
try:
self.info = info = await self._async_get_info(zeroconf_info["host"])
except HTTP_CONNECT_ERRORS:
return self.async_abort(reason="cannot_connect")
if info["auth"]:
return self.async_abort(reason="auth_not_supported")
await self.async_set_unique_id(info["mac"])
self._abort_if_unique_id_configured({"host": zeroconf_info["host"]})
self.host = zeroconf_info["host"]
# pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
self.context["title_placeholders"] = {"name": zeroconf_info["host"]}
return await self.async_step_confirm_discovery()
async def async_step_confirm_discovery(self, user_input=None):
"""Handle discovery confirm."""
errors = {}
if user_input is not None:
try:
device_info = await validate_input(self.hass, {"host": self.host})
except asyncio.TimeoutError:
errors["base"] = "cannot_connect"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(
title=device_info["title"] or self.host, data={"host": self.host}
)
return self.async_show_form(
step_id="confirm_discovery",
description_placeholders={
"model": aioshelly.MODEL_NAMES.get(
self.info["type"], self.info["type"]
),
"host": self.host,
},
errors=errors,
)
async def _async_get_info(self, host):
"""Get info from shelly device."""
async with async_timeout.timeout(5):
return await aioshelly.get_info(
aiohttp_client.async_get_clientsession(self.hass), host,
)

View File

@ -0,0 +1,3 @@
"""Constants for the Shelly integration."""
DOMAIN = "shelly"

View File

@ -0,0 +1,9 @@
{
"domain": "shelly",
"name": "Shelly",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/shelly2",
"requirements": ["aioshelly==0.1.2"],
"zeroconf": ["_http._tcp.local."],
"codeowners": ["@balloob"]
}

View File

@ -0,0 +1,24 @@
{
"title": "Shelly",
"config": {
"flow_title": "Shelly: {name}",
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
}
},
"confirm_discovery": {
"description": "Do you want to set up the {model} at {host}?"
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
"auth_not_supported": "Shelly devices requiring authentication are not currently supported."
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
}
}

View File

@ -0,0 +1,71 @@
"""Switch for Shelly."""
from homeassistant.components.shelly import ShellyBlockEntity
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import callback
from .const import DOMAIN
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up switches for device."""
wrapper = hass.data[DOMAIN][config_entry.entry_id]
relay_blocks = [block for block in wrapper.device.blocks if block.type == "relay"]
if not relay_blocks:
return
if wrapper.model == "SHSW-25" and wrapper.device.settings["mode"] != "relay":
return
multiple_blocks = len(relay_blocks) > 1
async_add_entities(
RelaySwitch(wrapper, block, multiple_blocks=multiple_blocks)
for block in relay_blocks
)
class RelaySwitch(ShellyBlockEntity, SwitchEntity):
"""Switch that controls a relay block on Shelly devices."""
def __init__(self, *args, multiple_blocks) -> None:
"""Initialize relay switch."""
super().__init__(*args)
self.multiple_blocks = multiple_blocks
self.control_result = None
@property
def is_on(self) -> bool:
"""If switch is on."""
if self.control_result:
return self.control_result["ison"]
return self.block.output
@property
def device_info(self):
"""Device info."""
if not self.multiple_blocks:
return super().device_info
# If a device has multiple relays, we want to expose as separate device
return {
"name": self.name,
"identifiers": {(DOMAIN, self.wrapper.mac, self.block.index)},
"via_device": (DOMAIN, self.wrapper.mac),
}
async def async_turn_on(self, **kwargs):
"""Turn on relay."""
self.control_result = await self.block.turn_on()
self.async_write_ha_state()
async def async_turn_off(self, **kwargs):
"""Turn off relay."""
self.control_result = await self.block.turn_off()
self.async_write_ha_state()
@callback
def _update_callback(self):
"""When device updates, clear control result that overrides state."""
self.control_result = None
super()._update_callback()

View File

@ -0,0 +1,24 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"auth_not_supported": "Authenticated Shelly devices are not currently supported.",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"flow_title": "Shelly: {name}",
"step": {
"confirm_discovery": {
"description": "Do you want to set up the {model} at {host}?"
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
}
}
}
},
"title": "Shelly"
}

View File

@ -151,6 +151,7 @@ FLOWS = [
"samsungtv",
"sense",
"sentry",
"shelly",
"shopping_list",
"simplisafe",
"smappee",

View File

@ -37,6 +37,9 @@ ZEROCONF = {
"_hap._tcp.local.": [
"homekit_controller"
],
"_http._tcp.local.": [
"shelly"
],
"_ipp._tcp.local.": [
"ipp"
],

View File

@ -221,6 +221,9 @@ aiopvpc==2.0.2
# homeassistant.components.webostv
aiopylgtv==0.3.3
# homeassistant.components.shelly
aioshelly==0.1.2
# homeassistant.components.switcher_kis
aioswitcher==1.2.0

View File

@ -131,6 +131,9 @@ aiopvpc==2.0.2
# homeassistant.components.webostv
aiopylgtv==0.3.3
# homeassistant.components.shelly
aioshelly==0.1.2
# homeassistant.components.switcher_kis
aioswitcher==1.2.0

View File

@ -0,0 +1 @@
"""Tests for the Shelly integration."""

View File

@ -0,0 +1,162 @@
"""Test the Shelly config flow."""
import asyncio
from homeassistant import config_entries, setup
from homeassistant.components.shelly.const import DOMAIN
from tests.async_mock import AsyncMock, Mock, patch
from tests.common import MockConfigEntry
async def test_form(hass):
"""Test we get the 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(
"aioshelly.get_info",
return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False},
), patch(
"aioshelly.Device.create",
return_value=Mock(
shutdown=AsyncMock(),
settings={"name": "Test name", "device": {"mac": "test-mac"}},
),
), patch(
"homeassistant.components.shelly.async_setup", return_value=True
) as mock_setup, patch(
"homeassistant.components.shelly.async_setup_entry", return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], {"host": "1.1.1.1"},
)
assert result2["type"] == "create_entry"
assert result2["title"] == "Test name"
assert result2["data"] == {
"host": "1.1.1.1",
}
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_auth(hass):
"""Test we can't manually configure if auth is required."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == "form"
assert result["errors"] == {}
with patch(
"aioshelly.get_info",
return_value={"mac": "test-mac", "type": "SHSW-1", "auth": True},
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], {"host": "1.1.1.1"},
)
assert result2["type"] == "abort"
assert result2["reason"] == "auth_not_supported"
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(
"aioshelly.get_info", side_effect=asyncio.TimeoutError,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], {"host": "1.1.1.1"},
)
assert result2["type"] == "form"
assert result2["errors"] == {"base": "cannot_connect"}
async def test_zeroconf(hass):
"""Test we get the form."""
await setup.async_setup_component(hass, "persistent_notification", {})
with patch(
"aioshelly.get_info",
return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False},
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
data={"host": "1.1.1.1", "name": "shelly1pm-12345"},
context={"source": config_entries.SOURCE_ZEROCONF},
)
assert result["type"] == "form"
assert result["errors"] == {}
with patch(
"aioshelly.Device.create",
return_value=Mock(
shutdown=AsyncMock(),
settings={"name": "Test name", "device": {"mac": "test-mac"}},
),
), patch(
"homeassistant.components.shelly.async_setup", return_value=True
) as mock_setup, patch(
"homeassistant.components.shelly.async_setup_entry", return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {},)
assert result2["type"] == "create_entry"
assert result2["title"] == "Test name"
assert result2["data"] == {
"host": "1.1.1.1",
}
await hass.async_block_till_done()
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_zeroconf_already_configured(hass):
"""Test we get the form."""
await setup.async_setup_component(hass, "persistent_notification", {})
entry = MockConfigEntry(
domain="shelly", unique_id="test-mac", data={"host": "0.0.0.0"}
)
entry.add_to_hass(hass)
with patch(
"aioshelly.get_info",
return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False},
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
data={"host": "1.1.1.1", "name": "shelly1pm-12345"},
context={"source": config_entries.SOURCE_ZEROCONF},
)
assert result["type"] == "abort"
assert result["reason"] == "already_configured"
# Test config entry got updated with latest IP
assert entry.data["host"] == "1.1.1.1"
async def test_zeroconf_require_auth(hass):
"""Test we get the form."""
await setup.async_setup_component(hass, "persistent_notification", {})
with patch(
"aioshelly.get_info",
return_value={"mac": "test-mac", "type": "SHSW-1", "auth": True},
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
data={"host": "1.1.1.1", "name": "shelly1pm-12345"},
context={"source": config_entries.SOURCE_ZEROCONF},
)
assert result["type"] == "abort"
assert result["reason"] == "auth_not_supported"

View File

@ -1127,6 +1127,7 @@ async def test_unique_id_update_existing_entry_without_reload(hass, manager):
domain="comp",
data={"additional": "data", "host": "0.0.0.0"},
unique_id="mock-unique-id",
state=config_entries.ENTRY_STATE_LOADED,
)
entry.add_to_hass(hass)