Add support for SOMA Smartshades devices (#26226)
* Add Soma integration * Fixed cover position get/set * Try to list devices before creating config entries to see if Soma Connect can be polled * Style fixes * Updated requirements * Updated .coveragerc to ignore Soma component * Fixed linter errors * Implemented stop command * Test coverage fixes according to feedback * Fixes to code according to feedback * Added error logging and tested config from yaml * Indentation fix * Removed unnecessary method * Wrong indentation * Added some tests * Added test for import step leading to entry creation * Added feedback to user form in case of connection error * Minor fixes according to feedback * Changed exception type in error handling for connection to Connect * To keep API consistent for Google Home and Alexa we swapped the open/closed position values back and I reversed them in this integration as well * regenerated requirements, ran black, addde __init__.py to ignore file * Added pysoma library to gen_requirements_all.py * Added missing test case * removed useless return value
This commit is contained in:
parent
21453df73e
commit
48d07467d9
15 changed files with 374 additions and 0 deletions
|
@ -599,6 +599,8 @@ omit =
|
||||||
homeassistant/components/solaredge/sensor.py
|
homeassistant/components/solaredge/sensor.py
|
||||||
homeassistant/components/solaredge_local/sensor.py
|
homeassistant/components/solaredge_local/sensor.py
|
||||||
homeassistant/components/solax/sensor.py
|
homeassistant/components/solax/sensor.py
|
||||||
|
homeassistant/components/soma/cover.py
|
||||||
|
homeassistant/components/soma/__init__.py
|
||||||
homeassistant/components/somfy/*
|
homeassistant/components/somfy/*
|
||||||
homeassistant/components/somfy_mylink/*
|
homeassistant/components/somfy_mylink/*
|
||||||
homeassistant/components/sonarr/sensor.py
|
homeassistant/components/sonarr/sensor.py
|
||||||
|
|
|
@ -254,6 +254,7 @@ homeassistant/components/smarty/* @z0mbieprocess
|
||||||
homeassistant/components/smtp/* @fabaff
|
homeassistant/components/smtp/* @fabaff
|
||||||
homeassistant/components/solaredge_local/* @drobtravels @scheric
|
homeassistant/components/solaredge_local/* @drobtravels @scheric
|
||||||
homeassistant/components/solax/* @squishykid
|
homeassistant/components/solax/* @squishykid
|
||||||
|
homeassistant/components/soma/* @ratsept
|
||||||
homeassistant/components/somfy/* @tetienne
|
homeassistant/components/somfy/* @tetienne
|
||||||
homeassistant/components/songpal/* @rytilahti
|
homeassistant/components/songpal/* @rytilahti
|
||||||
homeassistant/components/spaceapi/* @fabaff
|
homeassistant/components/spaceapi/* @fabaff
|
||||||
|
|
24
homeassistant/components/soma/.translations/en.json
Normal file
24
homeassistant/components/soma/.translations/en.json
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"abort": {
|
||||||
|
"already_setup": "You can only configure one Soma Connect.",
|
||||||
|
"missing_configuration": "The Soma component is not configured. Please follow the documentation.",
|
||||||
|
"connection_error": "Connection to the specified device failed."
|
||||||
|
},
|
||||||
|
"create_entry": {
|
||||||
|
"default": "Successfully authenticated with Soma."
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"data": {
|
||||||
|
"host": "Host",
|
||||||
|
"password": "Password",
|
||||||
|
"port": "Port",
|
||||||
|
"username": "Username"
|
||||||
|
},
|
||||||
|
"title": "Set up Soma Connect"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "Soma"
|
||||||
|
}
|
||||||
|
}
|
111
homeassistant/components/soma/__init__.py
Normal file
111
homeassistant/components/soma/__init__.py
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
"""Support for Soma Smartshades."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
from api.soma_api import SomaApi
|
||||||
|
|
||||||
|
import homeassistant.helpers.config_validation as cv
|
||||||
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.helpers.entity import Entity
|
||||||
|
from homeassistant.helpers.typing import HomeAssistantType
|
||||||
|
|
||||||
|
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||||
|
|
||||||
|
from .const import DOMAIN, HOST, PORT, API
|
||||||
|
|
||||||
|
|
||||||
|
DEVICES = "devices"
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CONFIG_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
DOMAIN: vol.Schema(
|
||||||
|
{vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PORT): cv.string}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
extra=vol.ALLOW_EXTRA,
|
||||||
|
)
|
||||||
|
|
||||||
|
SOMA_COMPONENTS = ["cover"]
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup(hass, config):
|
||||||
|
"""Set up the Soma component."""
|
||||||
|
if DOMAIN not in config:
|
||||||
|
return True
|
||||||
|
|
||||||
|
hass.async_create_task(
|
||||||
|
hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
data=config[DOMAIN],
|
||||||
|
context={"source": config_entries.SOURCE_IMPORT},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry):
|
||||||
|
"""Set up Soma from a config entry."""
|
||||||
|
hass.data[DOMAIN] = {}
|
||||||
|
hass.data[DOMAIN][API] = SomaApi(entry.data[HOST], entry.data[PORT])
|
||||||
|
devices = await hass.async_add_executor_job(hass.data[DOMAIN][API].list_devices)
|
||||||
|
hass.data[DOMAIN][DEVICES] = devices["shades"]
|
||||||
|
|
||||||
|
for component in SOMA_COMPONENTS:
|
||||||
|
hass.async_create_task(
|
||||||
|
hass.config_entries.async_forward_entry_setup(entry, component)
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry):
|
||||||
|
"""Unload a config entry."""
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class SomaEntity(Entity):
|
||||||
|
"""Representation of a generic Soma device."""
|
||||||
|
|
||||||
|
def __init__(self, device, api):
|
||||||
|
"""Initialize the Soma device."""
|
||||||
|
self.device = device
|
||||||
|
self.api = api
|
||||||
|
self.current_position = 50
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unique_id(self):
|
||||||
|
"""Return the unique id base on the id returned by pysoma API."""
|
||||||
|
return self.device["mac"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
"""Return the name of the device."""
|
||||||
|
return self.device["name"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_info(self):
|
||||||
|
"""Return device specific attributes.
|
||||||
|
|
||||||
|
Implemented by platform classes.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"identifiers": {(DOMAIN, self.unique_id)},
|
||||||
|
"name": self.name,
|
||||||
|
"manufacturer": "Wazombi Labs",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def async_update(self):
|
||||||
|
"""Update the device with the latest data."""
|
||||||
|
response = await self.hass.async_add_executor_job(
|
||||||
|
self.api.get_shade_state, self.device["mac"]
|
||||||
|
)
|
||||||
|
if response["result"] != "success":
|
||||||
|
_LOGGER.error(
|
||||||
|
"Unable to reach device %s (%s)", self.device["name"], response["msg"]
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self.current_position = 100 - response["position"]
|
56
homeassistant/components/soma/config_flow.py
Normal file
56
homeassistant/components/soma/config_flow.py
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
"""Config flow for Soma."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
from api.soma_api import SomaApi
|
||||||
|
from requests import RequestException
|
||||||
|
|
||||||
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||||
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_PORT = 3000
|
||||||
|
|
||||||
|
|
||||||
|
class SomaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle a config flow."""
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Instantiate config flow."""
|
||||||
|
|
||||||
|
async def async_step_user(self, user_input=None):
|
||||||
|
"""Handle a flow start."""
|
||||||
|
if user_input is None:
|
||||||
|
data = {
|
||||||
|
vol.Required(CONF_HOST): str,
|
||||||
|
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
|
||||||
|
}
|
||||||
|
|
||||||
|
return self.async_show_form(step_id="user", data_schema=vol.Schema(data))
|
||||||
|
|
||||||
|
return await self.async_step_creation(user_input)
|
||||||
|
|
||||||
|
async def async_step_creation(self, user_input=None):
|
||||||
|
"""Finish config flow."""
|
||||||
|
api = SomaApi(user_input["host"], user_input["port"])
|
||||||
|
try:
|
||||||
|
await self.hass.async_add_executor_job(api.list_devices)
|
||||||
|
_LOGGER.info("Successfully set up Soma Connect")
|
||||||
|
return self.async_create_entry(
|
||||||
|
title="Soma Connect",
|
||||||
|
data={"host": user_input["host"], "port": user_input["port"]},
|
||||||
|
)
|
||||||
|
except RequestException:
|
||||||
|
_LOGGER.error("Connection to SOMA Connect failed")
|
||||||
|
return self.async_abort(reason="connection_error")
|
||||||
|
|
||||||
|
async def async_step_import(self, user_input=None):
|
||||||
|
"""Handle flow start from existing config section."""
|
||||||
|
if self.hass.config_entries.async_entries(DOMAIN):
|
||||||
|
return self.async_abort(reason="already_setup")
|
||||||
|
return await self.async_step_creation(user_input)
|
6
homeassistant/components/soma/const.py
Normal file
6
homeassistant/components/soma/const.py
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
"""Define constants for the Soma component."""
|
||||||
|
|
||||||
|
DOMAIN = "soma"
|
||||||
|
HOST = "host"
|
||||||
|
PORT = "port"
|
||||||
|
API = "api"
|
79
homeassistant/components/soma/cover.py
Normal file
79
homeassistant/components/soma/cover.py
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
"""Support for Soma Covers."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from homeassistant.components.cover import CoverDevice, ATTR_POSITION
|
||||||
|
from homeassistant.components.soma import DOMAIN, SomaEntity, DEVICES, API
|
||||||
|
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||||
|
"""Set up the Soma cover platform."""
|
||||||
|
|
||||||
|
devices = hass.data[DOMAIN][DEVICES]
|
||||||
|
|
||||||
|
async_add_entities(
|
||||||
|
[SomaCover(cover, hass.data[DOMAIN][API]) for cover in devices], True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
|
||||||
|
"""Old way of setting up platform.
|
||||||
|
|
||||||
|
Can only be called when a user accidentally mentions the platform in their
|
||||||
|
config. But even in that case it would have been ignored.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SomaCover(SomaEntity, CoverDevice):
|
||||||
|
"""Representation of a Soma cover device."""
|
||||||
|
|
||||||
|
def close_cover(self, **kwargs):
|
||||||
|
"""Close the cover."""
|
||||||
|
response = self.api.set_shade_position(self.device["mac"], 100)
|
||||||
|
if response["result"] != "success":
|
||||||
|
_LOGGER.error(
|
||||||
|
"Unable to reach device %s (%s)", self.device["name"], response["msg"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def open_cover(self, **kwargs):
|
||||||
|
"""Open the cover."""
|
||||||
|
response = self.api.set_shade_position(self.device["mac"], 0)
|
||||||
|
if response["result"] != "success":
|
||||||
|
_LOGGER.error(
|
||||||
|
"Unable to reach device %s (%s)", self.device["name"], response["msg"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def stop_cover(self, **kwargs):
|
||||||
|
"""Stop the cover."""
|
||||||
|
# Set cover position to some value where up/down are both enabled
|
||||||
|
self.current_position = 50
|
||||||
|
response = self.api.stop_shade(self.device["mac"])
|
||||||
|
if response["result"] != "success":
|
||||||
|
_LOGGER.error(
|
||||||
|
"Unable to reach device %s (%s)", self.device["name"], response["msg"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_cover_position(self, **kwargs):
|
||||||
|
"""Move the cover shutter to a specific position."""
|
||||||
|
self.current_position = kwargs[ATTR_POSITION]
|
||||||
|
response = self.api.set_shade_position(
|
||||||
|
self.device["mac"], 100 - kwargs[ATTR_POSITION]
|
||||||
|
)
|
||||||
|
if response["result"] != "success":
|
||||||
|
_LOGGER.error(
|
||||||
|
"Unable to reach device %s (%s)", self.device["name"], response["msg"]
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_cover_position(self):
|
||||||
|
"""Return the current position of cover shutter."""
|
||||||
|
return self.current_position
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_closed(self):
|
||||||
|
"""Return if the cover is closed."""
|
||||||
|
return self.current_position == 0
|
13
homeassistant/components/soma/manifest.json
Normal file
13
homeassistant/components/soma/manifest.json
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"domain": "soma",
|
||||||
|
"name": "Soma Open API",
|
||||||
|
"config_flow": true,
|
||||||
|
"documentation": "",
|
||||||
|
"dependencies": [],
|
||||||
|
"codeowners": [
|
||||||
|
"@ratsept"
|
||||||
|
],
|
||||||
|
"requirements": [
|
||||||
|
"pysoma==0.0.10"
|
||||||
|
]
|
||||||
|
}
|
13
homeassistant/components/soma/strings.json
Normal file
13
homeassistant/components/soma/strings.json
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"abort": {
|
||||||
|
"already_setup": "You can only configure one Soma account.",
|
||||||
|
"authorize_url_timeout": "Timeout generating authorize url.",
|
||||||
|
"missing_configuration": "The Soma component is not configured. Please follow the documentation."
|
||||||
|
},
|
||||||
|
"create_entry": {
|
||||||
|
"default": "Successfully authenticated with Soma."
|
||||||
|
},
|
||||||
|
"title": "Soma"
|
||||||
|
}
|
||||||
|
}
|
|
@ -55,6 +55,7 @@ FLOWS = [
|
||||||
"smartthings",
|
"smartthings",
|
||||||
"smhi",
|
"smhi",
|
||||||
"solaredge",
|
"solaredge",
|
||||||
|
"soma",
|
||||||
"somfy",
|
"somfy",
|
||||||
"sonos",
|
"sonos",
|
||||||
"tellduslive",
|
"tellduslive",
|
||||||
|
|
|
@ -1443,6 +1443,9 @@ pysmarty==0.8
|
||||||
# homeassistant.components.snmp
|
# homeassistant.components.snmp
|
||||||
pysnmp==4.4.11
|
pysnmp==4.4.11
|
||||||
|
|
||||||
|
# homeassistant.components.soma
|
||||||
|
pysoma==0.0.10
|
||||||
|
|
||||||
# homeassistant.components.sonos
|
# homeassistant.components.sonos
|
||||||
pysonos==0.0.23
|
pysonos==0.0.23
|
||||||
|
|
||||||
|
|
|
@ -349,6 +349,9 @@ pysmartapp==0.3.2
|
||||||
# homeassistant.components.smartthings
|
# homeassistant.components.smartthings
|
||||||
pysmartthings==0.6.9
|
pysmartthings==0.6.9
|
||||||
|
|
||||||
|
# homeassistant.components.soma
|
||||||
|
pysoma==0.0.10
|
||||||
|
|
||||||
# homeassistant.components.sonos
|
# homeassistant.components.sonos
|
||||||
pysonos==0.0.23
|
pysonos==0.0.23
|
||||||
|
|
||||||
|
|
|
@ -143,6 +143,7 @@ TEST_REQUIREMENTS = (
|
||||||
"pysma",
|
"pysma",
|
||||||
"pysmartapp",
|
"pysmartapp",
|
||||||
"pysmartthings",
|
"pysmartthings",
|
||||||
|
"pysoma",
|
||||||
"pysonos",
|
"pysonos",
|
||||||
"pyspcwebgw",
|
"pyspcwebgw",
|
||||||
"python_awair",
|
"python_awair",
|
||||||
|
|
1
tests/components/soma/__init__.py
Normal file
1
tests/components/soma/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
"""Tests for the Soma component."""
|
60
tests/components/soma/test_config_flow.py
Normal file
60
tests/components/soma/test_config_flow.py
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
"""Tests for the Soma config flow."""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from api.soma_api import SomaApi
|
||||||
|
from requests import RequestException
|
||||||
|
|
||||||
|
from homeassistant import data_entry_flow
|
||||||
|
from homeassistant.components.soma import config_flow, DOMAIN
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
|
||||||
|
MOCK_HOST = "123.45.67.89"
|
||||||
|
MOCK_PORT = 3000
|
||||||
|
|
||||||
|
|
||||||
|
async def test_form(hass):
|
||||||
|
"""Test user form showing."""
|
||||||
|
flow = config_flow.SomaFlowHandler()
|
||||||
|
flow.hass = hass
|
||||||
|
result = await flow.async_step_user()
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
|
||||||
|
|
||||||
|
async def test_import_abort(hass):
|
||||||
|
"""Test configuration from YAML aborting with existing entity."""
|
||||||
|
flow = config_flow.SomaFlowHandler()
|
||||||
|
flow.hass = hass
|
||||||
|
MockConfigEntry(domain=DOMAIN).add_to_hass(hass)
|
||||||
|
result = await flow.async_step_import()
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||||
|
assert result["reason"] == "already_setup"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_import_create(hass):
|
||||||
|
"""Test configuration from YAML."""
|
||||||
|
flow = config_flow.SomaFlowHandler()
|
||||||
|
flow.hass = hass
|
||||||
|
with patch.object(SomaApi, "list_devices", return_value={}):
|
||||||
|
result = await flow.async_step_import({"host": MOCK_HOST, "port": MOCK_PORT})
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||||
|
|
||||||
|
|
||||||
|
async def test_exception(hass):
|
||||||
|
"""Test if RequestException fires when no connection can be made."""
|
||||||
|
flow = config_flow.SomaFlowHandler()
|
||||||
|
flow.hass = hass
|
||||||
|
with patch.object(SomaApi, "list_devices", side_effect=RequestException()):
|
||||||
|
result = await flow.async_step_import({"host": MOCK_HOST, "port": MOCK_PORT})
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||||
|
assert result["reason"] == "connection_error"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_full_flow(hass):
|
||||||
|
"""Check classic use case."""
|
||||||
|
hass.data[DOMAIN] = {}
|
||||||
|
flow = config_flow.SomaFlowHandler()
|
||||||
|
flow.hass = hass
|
||||||
|
with patch.object(SomaApi, "list_devices", return_value={}):
|
||||||
|
result = await flow.async_step_user({"host": MOCK_HOST, "port": MOCK_PORT})
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
Loading…
Add table
Add a link
Reference in a new issue