Add Motion Blinds integration (#42989)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
starkillerOG
2020-11-23 21:33:14 +01:00
committed by GitHub
parent 66efe92b3f
commit 0c30abda61
16 changed files with 789 additions and 0 deletions

View File

@ -542,6 +542,10 @@ omit =
homeassistant/components/modbus/cover.py
homeassistant/components/modbus/switch.py
homeassistant/components/modem_callerid/sensor.py
homeassistant/components/motion_blinds/__init__.py
homeassistant/components/motion_blinds/const.py
homeassistant/components/motion_blinds/cover.py
homeassistant/components/motion_blinds/sensor.py
homeassistant/components/mpchc/media_player.py
homeassistant/components/mpd/media_player.py
homeassistant/components/mqtt_room/sensor.py

View File

@ -277,6 +277,7 @@ homeassistant/components/mobile_app/* @robbiet480
homeassistant/components/modbus/* @adamchengtkc @janiversen @vzahradnik
homeassistant/components/monoprice/* @etsinko @OnFreund
homeassistant/components/moon/* @fabaff
homeassistant/components/motion_blinds/* @starkillerOG
homeassistant/components/mpd/* @fabaff
homeassistant/components/mqtt/* @home-assistant/core @emontnemery
homeassistant/components/msteams/* @peroyvind

View File

@ -0,0 +1,101 @@
"""The motion_blinds component."""
from asyncio import TimeoutError as AsyncioTimeoutError
from datetime import timedelta
import logging
from socket import timeout
from homeassistant import config_entries, core
from homeassistant.const import CONF_API_KEY, CONF_HOST
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN, KEY_COORDINATOR, KEY_GATEWAY, MANUFACTURER
from .gateway import ConnectMotionGateway
_LOGGER = logging.getLogger(__name__)
MOTION_PLATFORMS = ["cover", "sensor"]
async def async_setup(hass: core.HomeAssistant, config: dict):
"""Set up the Motion Blinds component."""
return True
async def async_setup_entry(
hass: core.HomeAssistant, entry: config_entries.ConfigEntry
):
"""Set up the motion_blinds components from a config entry."""
hass.data.setdefault(DOMAIN, {})
host = entry.data[CONF_HOST]
key = entry.data[CONF_API_KEY]
# Connect to motion gateway
connect_gateway_class = ConnectMotionGateway(hass)
if not await connect_gateway_class.async_connect_gateway(host, key):
raise ConfigEntryNotReady
motion_gateway = connect_gateway_class.gateway_device
def update_gateway():
"""Call all updates using one async_add_executor_job."""
motion_gateway.Update()
for blind in motion_gateway.device_list.values():
blind.Update()
async def async_update_data():
"""Fetch data from the gateway and blinds."""
try:
await hass.async_add_executor_job(update_gateway)
except timeout as socket_timeout:
raise AsyncioTimeoutError from socket_timeout
coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
# Name of the data. For logging purposes.
name=entry.title,
update_method=async_update_data,
# Polling interval. Will only be polled if there are subscribers.
update_interval=timedelta(seconds=10),
)
# Fetch initial data so we have data when entities subscribe
await coordinator.async_refresh()
hass.data[DOMAIN][entry.entry_id] = {
KEY_GATEWAY: motion_gateway,
KEY_COORDINATOR: coordinator,
}
device_registry = await dr.async_get_registry(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, motion_gateway.mac)},
identifiers={(DOMAIN, entry.unique_id)},
manufacturer=MANUFACTURER,
name=entry.title,
model="Wi-Fi bridge",
sw_version=motion_gateway.protocol,
)
for component in MOTION_PLATFORMS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, component)
)
return True
async def async_unload_entry(
hass: core.HomeAssistant, config_entry: config_entries.ConfigEntry
):
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_forward_entry_unload(
config_entry, "cover"
)
if unload_ok:
hass.data[DOMAIN].pop(config_entry.entry_id)
return unload_ok

View File

@ -0,0 +1,64 @@
"""Config flow to configure Motion Blinds using their WLAN API."""
import logging
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_API_KEY, CONF_HOST
# pylint: disable=unused-import
from .const import DOMAIN
from .gateway import ConnectMotionGateway
_LOGGER = logging.getLogger(__name__)
DEFAULT_GATEWAY_NAME = "Motion Gateway"
CONFIG_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_API_KEY): vol.All(str, vol.Length(min=16, max=16)),
}
)
class MotionBlindsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a Motion Blinds config flow."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
def __init__(self):
"""Initialize the Motion Blinds flow."""
self.host = None
self.key = None
async def async_step_user(self, user_input=None):
"""Handle a flow initialized by the user."""
errors = {}
if user_input is not None:
self.host = user_input[CONF_HOST]
self.key = user_input[CONF_API_KEY]
return await self.async_step_connect()
return self.async_show_form(
step_id="user", data_schema=CONFIG_SCHEMA, errors=errors
)
async def async_step_connect(self, user_input=None):
"""Connect to the Motion Gateway."""
connect_gateway_class = ConnectMotionGateway(self.hass)
if not await connect_gateway_class.async_connect_gateway(self.host, self.key):
return self.async_abort(reason="connection_error")
motion_gateway = connect_gateway_class.gateway_device
mac_address = motion_gateway.mac
await self.async_set_unique_id(mac_address)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=DEFAULT_GATEWAY_NAME,
data={CONF_HOST: self.host, CONF_API_KEY: self.key},
)

View File

@ -0,0 +1,6 @@
"""Constants for the Motion Blinds component."""
DOMAIN = "motion_blinds"
MANUFACTURER = "Motion, Coulisse B.V."
KEY_GATEWAY = "gateway"
KEY_COORDINATOR = "coordinator"

View File

@ -0,0 +1,256 @@
"""Support for Motion Blinds using their WLAN API."""
import logging
from motionblinds import BlindType
from homeassistant.components.cover import (
ATTR_POSITION,
ATTR_TILT_POSITION,
DEVICE_CLASS_AWNING,
DEVICE_CLASS_BLIND,
DEVICE_CLASS_CURTAIN,
DEVICE_CLASS_GATE,
DEVICE_CLASS_SHADE,
DEVICE_CLASS_SHUTTER,
CoverEntity,
)
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, KEY_COORDINATOR, KEY_GATEWAY, MANUFACTURER
_LOGGER = logging.getLogger(__name__)
POSITION_DEVICE_MAP = {
BlindType.RollerBlind: DEVICE_CLASS_SHADE,
BlindType.RomanBlind: DEVICE_CLASS_SHADE,
BlindType.HoneycombBlind: DEVICE_CLASS_SHADE,
BlindType.DimmingBlind: DEVICE_CLASS_SHADE,
BlindType.DayNightBlind: DEVICE_CLASS_SHADE,
BlindType.RollerShutter: DEVICE_CLASS_SHUTTER,
BlindType.Switch: DEVICE_CLASS_SHUTTER,
BlindType.RollerGate: DEVICE_CLASS_GATE,
BlindType.Awning: DEVICE_CLASS_AWNING,
BlindType.Curtain: DEVICE_CLASS_CURTAIN,
BlindType.CurtainLeft: DEVICE_CLASS_CURTAIN,
BlindType.CurtainRight: DEVICE_CLASS_CURTAIN,
}
TILT_DEVICE_MAP = {
BlindType.VenetianBlind: DEVICE_CLASS_BLIND,
BlindType.ShangriLaBlind: DEVICE_CLASS_BLIND,
BlindType.DoubleRoller: DEVICE_CLASS_SHADE,
}
TDBU_DEVICE_MAP = {
BlindType.TopDownBottomUp: DEVICE_CLASS_SHADE,
}
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Motion Blind from a config entry."""
entities = []
motion_gateway = hass.data[DOMAIN][config_entry.entry_id][KEY_GATEWAY]
coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR]
for blind in motion_gateway.device_list.values():
if blind.type in POSITION_DEVICE_MAP:
entities.append(
MotionPositionDevice(
coordinator, blind, POSITION_DEVICE_MAP[blind.type], config_entry
)
)
elif blind.type in TILT_DEVICE_MAP:
entities.append(
MotionTiltDevice(
coordinator, blind, TILT_DEVICE_MAP[blind.type], config_entry
)
)
elif blind.type in TDBU_DEVICE_MAP:
entities.append(
MotionTDBUDevice(
coordinator, blind, TDBU_DEVICE_MAP[blind.type], config_entry, "Top"
)
)
entities.append(
MotionTDBUDevice(
coordinator,
blind,
TDBU_DEVICE_MAP[blind.type],
config_entry,
"Bottom",
)
)
else:
_LOGGER.warning("Blind type '%s' not yet supported", blind.blind_type)
async_add_entities(entities)
class MotionPositionDevice(CoordinatorEntity, CoverEntity):
"""Representation of a Motion Blind Device."""
def __init__(self, coordinator, blind, device_class, config_entry):
"""Initialize the blind."""
super().__init__(coordinator)
self._blind = blind
self._device_class = device_class
self._config_entry = config_entry
@property
def unique_id(self):
"""Return the unique id of the blind."""
return self._blind.mac
@property
def device_info(self):
"""Return the device info of the blind."""
device_info = {
"identifiers": {(DOMAIN, self._blind.mac)},
"manufacturer": MANUFACTURER,
"name": f"{self._blind.blind_type}-{self._blind.mac[12:]}",
"model": self._blind.blind_type,
"via_device": (DOMAIN, self._config_entry.unique_id),
}
return device_info
@property
def name(self):
"""Return the name of the blind."""
return f"{self._blind.blind_type}-{self._blind.mac[12:]}"
@property
def current_cover_position(self):
"""
Return current position of cover.
None is unknown, 0 is open, 100 is closed.
"""
if self._blind.position is None:
return None
return 100 - self._blind.position
@property
def device_class(self):
"""Return the device class."""
return self._device_class
@property
def is_closed(self):
"""Return if the cover is closed or not."""
return self._blind.position == 100
def open_cover(self, **kwargs):
"""Open the cover."""
self._blind.Open()
def close_cover(self, **kwargs):
"""Close cover."""
self._blind.Close()
def set_cover_position(self, **kwargs):
"""Move the cover to a specific position."""
position = kwargs[ATTR_POSITION]
self._blind.Set_position(100 - position)
def stop_cover(self, **kwargs):
"""Stop the cover."""
self._blind.Stop()
class MotionTiltDevice(MotionPositionDevice):
"""Representation of a Motion Blind Device."""
@property
def current_cover_tilt_position(self):
"""
Return current angle of cover.
None is unknown, 0 is closed/minimum tilt, 100 is fully open/maximum tilt.
"""
if self._blind.angle is None:
return None
return self._blind.angle * 100 / 180
def open_cover_tilt(self, **kwargs):
"""Open the cover tilt."""
self._blind.Set_angle(180)
def close_cover_tilt(self, **kwargs):
"""Close the cover tilt."""
self._blind.Set_angle(0)
def set_cover_tilt_position(self, **kwargs):
"""Move the cover tilt to a specific position."""
angle = kwargs[ATTR_TILT_POSITION] * 180 / 100
self._blind.Set_angle(angle)
def stop_cover_tilt(self, **kwargs):
"""Stop the cover."""
self._blind.Stop()
class MotionTDBUDevice(MotionPositionDevice):
"""Representation of a Motion Top Down Bottom Up blind Device."""
def __init__(self, coordinator, blind, device_class, config_entry, motor):
"""Initialize the blind."""
super().__init__(coordinator, blind, device_class, config_entry)
self._motor = motor
self._motor_key = motor[0]
if self._motor not in ["Bottom", "Top"]:
_LOGGER.error("Unknown motor '%s'", self._motor)
@property
def unique_id(self):
"""Return the unique id of the blind."""
return f"{self._blind.mac}-{self._motor}"
@property
def name(self):
"""Return the name of the blind."""
return f"{self._blind.blind_type}-{self._motor}-{self._blind.mac[12:]}"
@property
def current_cover_position(self):
"""
Return current position of cover.
None is unknown, 0 is open, 100 is closed.
"""
if self._blind.position is None:
return None
return 100 - self._blind.position[self._motor_key]
@property
def is_closed(self):
"""Return if the cover is closed or not."""
if self._blind.position is None:
return None
return self._blind.position[self._motor_key] == 100
def open_cover(self, **kwargs):
"""Open the cover."""
self._blind.Open(motor=self._motor_key)
def close_cover(self, **kwargs):
"""Close cover."""
self._blind.Close(motor=self._motor_key)
def set_cover_position(self, **kwargs):
"""Move the cover to a specific position."""
position = kwargs[ATTR_POSITION]
self._blind.Set_position(100 - position, motor=self._motor_key)
def stop_cover(self, **kwargs):
"""Stop the cover."""
self._blind.Stop(motor=self._motor_key)

View File

@ -0,0 +1,45 @@
"""Code to handle a Motion Gateway."""
import logging
import socket
from motionblinds import MotionGateway
_LOGGER = logging.getLogger(__name__)
class ConnectMotionGateway:
"""Class to async connect to a Motion Gateway."""
def __init__(self, hass):
"""Initialize the entity."""
self._hass = hass
self._gateway_device = None
@property
def gateway_device(self):
"""Return the class containing all connections to the gateway."""
return self._gateway_device
def update_gateway(self):
"""Update all information of the gateway."""
self.gateway_device.GetDeviceList()
self.gateway_device.Update()
async def async_connect_gateway(self, host, key):
"""Connect to the Motion Gateway."""
_LOGGER.debug("Initializing with host %s (key %s...)", host, key[:3])
self._gateway_device = MotionGateway(ip=host, key=key)
try:
# update device info and get the connected sub devices
await self._hass.async_add_executor_job(self.update_gateway)
except socket.timeout:
_LOGGER.error(
"Timeout trying to connect to Motion Gateway with host %s", host
)
return False
_LOGGER.debug(
"Motion gateway mac: %s, protocol: %s detected",
self.gateway_device.mac,
self.gateway_device.protocol,
)
return True

View File

@ -0,0 +1,8 @@
{
"domain": "motion_blinds",
"name": "Motion Blinds",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/motion_blinds",
"requirements": ["motionblinds==0.1.6"],
"codeowners": ["@starkillerOG"]
}

View File

@ -0,0 +1,181 @@
"""Support for Motion Blinds sensors."""
import logging
from motionblinds import BlindType
from homeassistant.const import (
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_SIGNAL_STRENGTH,
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
)
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, KEY_COORDINATOR, KEY_GATEWAY
_LOGGER = logging.getLogger(__name__)
ATTR_BATTERY_VOLTAGE = "battery_voltage"
TYPE_BLIND = "blind"
TYPE_GATEWAY = "gateway"
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Perform the setup for Motion Blinds."""
entities = []
motion_gateway = hass.data[DOMAIN][config_entry.entry_id][KEY_GATEWAY]
coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR]
for blind in motion_gateway.device_list.values():
entities.append(MotionSignalStrengthSensor(coordinator, blind, TYPE_BLIND))
if blind.type == BlindType.TopDownBottomUp:
entities.append(MotionTDBUBatterySensor(coordinator, blind, "Bottom"))
entities.append(MotionTDBUBatterySensor(coordinator, blind, "Top"))
elif blind.battery_voltage > 0:
# Only add battery powered blinds
entities.append(MotionBatterySensor(coordinator, blind))
entities.append(
MotionSignalStrengthSensor(coordinator, motion_gateway, TYPE_GATEWAY)
)
async_add_entities(entities)
class MotionBatterySensor(CoordinatorEntity, Entity):
"""
Representation of a Motion Battery Sensor.
Updates are done by the cover platform.
"""
def __init__(self, coordinator, blind):
"""Initialize the Motion Battery Sensor."""
super().__init__(coordinator)
self._blind = blind
@property
def unique_id(self):
"""Return the unique id of the blind."""
return f"{self._blind.mac}-battery"
@property
def device_info(self):
"""Return the device info of the blind."""
return {"identifiers": {(DOMAIN, self._blind.mac)}}
@property
def name(self):
"""Return the name of the blind battery sensor."""
return f"{self._blind.blind_type}-battery-{self._blind.mac[12:]}"
@property
def unit_of_measurement(self):
"""Return the unit of measurement of this entity, if any."""
return PERCENTAGE
@property
def device_class(self):
"""Return the device class of this entity."""
return DEVICE_CLASS_BATTERY
@property
def state(self):
"""Return the state of the sensor."""
return self._blind.battery_level
@property
def device_state_attributes(self):
"""Return device specific state attributes."""
return {ATTR_BATTERY_VOLTAGE: self._blind.battery_voltage}
class MotionTDBUBatterySensor(MotionBatterySensor):
"""
Representation of a Motion Battery Sensor for a Top Down Bottom Up blind.
Updates are done by the cover platform.
"""
def __init__(self, coordinator, blind, motor):
"""Initialize the Motion Battery Sensor."""
super().__init__(coordinator, blind)
self._motor = motor
@property
def unique_id(self):
"""Return the unique id of the blind."""
return f"{self._blind.mac}-{self._motor}-battery"
@property
def name(self):
"""Return the name of the blind battery sensor."""
return f"{self._blind.blind_type}-{self._motor}-battery-{self._blind.mac[12:]}"
@property
def state(self):
"""Return the state of the sensor."""
if self._blind.battery_level is None:
return None
return self._blind.battery_level[self._motor[0]]
@property
def device_state_attributes(self):
"""Return device specific state attributes."""
attributes = {}
if self._blind.battery_voltage is not None:
attributes[ATTR_BATTERY_VOLTAGE] = self._blind.battery_voltage[
self._motor[0]
]
return attributes
class MotionSignalStrengthSensor(CoordinatorEntity, Entity):
"""Representation of a Motion Signal Strength Sensor."""
def __init__(self, coordinator, device, device_type):
"""Initialize the Motion Signal Strength Sensor."""
super().__init__(coordinator)
self._device = device
self._device_type = device_type
@property
def unique_id(self):
"""Return the unique id of the blind."""
return f"{self._device.mac}-RSSI"
@property
def device_info(self):
"""Return the device info of the blind."""
return {"identifiers": {(DOMAIN, self._device.mac)}}
@property
def name(self):
"""Return the name of the blind signal strength sensor."""
if self._device_type == TYPE_GATEWAY:
return "Motion gateway signal strength"
return f"{self._device.blind_type} signal strength - {self._device.mac[12:]}"
@property
def unit_of_measurement(self):
"""Return the unit of measurement of this entity, if any."""
return SIGNAL_STRENGTH_DECIBELS_MILLIWATT
@property
def device_class(self):
"""Return the device class of this entity."""
return DEVICE_CLASS_SIGNAL_STRENGTH
@property
def entity_registry_enabled_default(self):
"""Return if the entity should be enabled when first added to the entity registry."""
return False
@property
def state(self):
"""Return the state of the sensor."""
return self._device.RSSI

View File

@ -0,0 +1,20 @@
{
"config": {
"flow_title": "Motion Blinds",
"step": {
"user": {
"title": "Motion Blinds",
"description": "You will need the 16 character API Key, see https://www.home-assistant.io/integrations/motion_blinds/#retrieving-the-key for instructions",
"data": {
"host": "[%key:common::config_flow::data::ip%]",
"api_key": "[%key:common::config_flow::data::api_key%]"
}
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"connection_error": "[%key:common::config_flow::error::cannot_connect%]"
}
}
}

View File

@ -0,0 +1,20 @@
{
"config": {
"abort": {
"already_configured": "Device is already configured",
"already_in_progress": "Config flow for this Motion gateway is already in progress",
"connection_error": "Failed to connect, please try again"
},
"flow_title": "Motion Blinds",
"step": {
"user": {
"data": {
"host": "IP address",
"api_key": "API key"
},
"description": "You will need the 16 character API Key, see https://www.home-assistant.io/integrations/motion_blinds/#retrieving-the-key for instructions",
"title": "Motion Blinds"
}
}
}
}

View File

@ -121,6 +121,7 @@ FLOWS = [
"minecraft_server",
"mobile_app",
"monoprice",
"motion_blinds",
"mqtt",
"myq",
"neato",

View File

@ -951,6 +951,9 @@ minio==4.0.9
# homeassistant.components.mitemp_bt
mitemp_bt==0.0.3
# homeassistant.components.motion_blinds
motionblinds==0.1.6
# homeassistant.components.tts
mutagen==1.45.1

View File

@ -473,6 +473,9 @@ millheater==0.4.0
# homeassistant.components.minio
minio==4.0.9
# homeassistant.components.motion_blinds
motionblinds==0.1.6
# homeassistant.components.tts
mutagen==1.45.1

View File

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

View File

@ -0,0 +1,75 @@
"""Test the Motion Blinds config flow."""
import socket
import pytest
from homeassistant import config_entries
from homeassistant.components.motion_blinds.config_flow import DEFAULT_GATEWAY_NAME
from homeassistant.components.motion_blinds.const import DOMAIN
from homeassistant.const import CONF_API_KEY, CONF_HOST
from tests.async_mock import patch
TEST_HOST = "1.2.3.4"
TEST_API_KEY = "12ab345c-d67e-8f"
@pytest.fixture(name="motion_blinds_connect", autouse=True)
def motion_blinds_connect_fixture():
"""Mock motion blinds connection and entry setup."""
with patch(
"homeassistant.components.motion_blinds.gateway.MotionGateway.GetDeviceList",
return_value=True,
), patch(
"homeassistant.components.motion_blinds.gateway.MotionGateway.Update",
return_value=True,
), patch(
"homeassistant.components.motion_blinds.async_setup_entry", return_value=True
):
yield
async def test_config_flow_manual_host_success(hass):
"""Successful flow manually initialized by the user."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == "form"
assert result["step_id"] == "user"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: TEST_HOST, CONF_API_KEY: TEST_API_KEY},
)
assert result["type"] == "create_entry"
assert result["title"] == DEFAULT_GATEWAY_NAME
assert result["data"] == {
CONF_HOST: TEST_HOST,
CONF_API_KEY: TEST_API_KEY,
}
async def test_config_flow_connection_error(hass):
"""Failed flow manually initialized by the user with connection timeout."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == "form"
assert result["step_id"] == "user"
assert result["errors"] == {}
with patch(
"homeassistant.components.motion_blinds.gateway.MotionGateway.GetDeviceList",
side_effect=socket.timeout,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: TEST_HOST, CONF_API_KEY: TEST_API_KEY},
)
assert result["type"] == "abort"
assert result["reason"] == "connection_error"