mirror of
https://github.com/home-assistant/core.git
synced 2025-04-24 09:17:53 +00:00
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
@ -599,6 +599,8 @@ omit =
|
||||
homeassistant/components/solaredge/sensor.py
|
||||
homeassistant/components/solaredge_local/sensor.py
|
||||
homeassistant/components/solax/sensor.py
|
||||
homeassistant/components/soma/cover.py
|
||||
homeassistant/components/soma/__init__.py
|
||||
homeassistant/components/somfy/*
|
||||
homeassistant/components/somfy_mylink/*
|
||||
homeassistant/components/sonarr/sensor.py
|
||||
|
@ -254,6 +254,7 @@ homeassistant/components/smarty/* @z0mbieprocess
|
||||
homeassistant/components/smtp/* @fabaff
|
||||
homeassistant/components/solaredge_local/* @drobtravels @scheric
|
||||
homeassistant/components/solax/* @squishykid
|
||||
homeassistant/components/soma/* @ratsept
|
||||
homeassistant/components/somfy/* @tetienne
|
||||
homeassistant/components/songpal/* @rytilahti
|
||||
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",
|
||||
"smhi",
|
||||
"solaredge",
|
||||
"soma",
|
||||
"somfy",
|
||||
"sonos",
|
||||
"tellduslive",
|
||||
|
@ -1443,6 +1443,9 @@ pysmarty==0.8
|
||||
# homeassistant.components.snmp
|
||||
pysnmp==4.4.11
|
||||
|
||||
# homeassistant.components.soma
|
||||
pysoma==0.0.10
|
||||
|
||||
# homeassistant.components.sonos
|
||||
pysonos==0.0.23
|
||||
|
||||
|
@ -349,6 +349,9 @@ pysmartapp==0.3.2
|
||||
# homeassistant.components.smartthings
|
||||
pysmartthings==0.6.9
|
||||
|
||||
# homeassistant.components.soma
|
||||
pysoma==0.0.10
|
||||
|
||||
# homeassistant.components.sonos
|
||||
pysonos==0.0.23
|
||||
|
||||
|
@ -143,6 +143,7 @@ TEST_REQUIREMENTS = (
|
||||
"pysma",
|
||||
"pysmartapp",
|
||||
"pysmartthings",
|
||||
"pysoma",
|
||||
"pysonos",
|
||||
"pyspcwebgw",
|
||||
"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…
x
Reference in New Issue
Block a user