mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 13:17:32 +00:00
Add Motion Blinds integration (#42989)
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
parent
66efe92b3f
commit
0c30abda61
@ -542,6 +542,10 @@ omit =
|
|||||||
homeassistant/components/modbus/cover.py
|
homeassistant/components/modbus/cover.py
|
||||||
homeassistant/components/modbus/switch.py
|
homeassistant/components/modbus/switch.py
|
||||||
homeassistant/components/modem_callerid/sensor.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/mpchc/media_player.py
|
||||||
homeassistant/components/mpd/media_player.py
|
homeassistant/components/mpd/media_player.py
|
||||||
homeassistant/components/mqtt_room/sensor.py
|
homeassistant/components/mqtt_room/sensor.py
|
||||||
|
@ -277,6 +277,7 @@ homeassistant/components/mobile_app/* @robbiet480
|
|||||||
homeassistant/components/modbus/* @adamchengtkc @janiversen @vzahradnik
|
homeassistant/components/modbus/* @adamchengtkc @janiversen @vzahradnik
|
||||||
homeassistant/components/monoprice/* @etsinko @OnFreund
|
homeassistant/components/monoprice/* @etsinko @OnFreund
|
||||||
homeassistant/components/moon/* @fabaff
|
homeassistant/components/moon/* @fabaff
|
||||||
|
homeassistant/components/motion_blinds/* @starkillerOG
|
||||||
homeassistant/components/mpd/* @fabaff
|
homeassistant/components/mpd/* @fabaff
|
||||||
homeassistant/components/mqtt/* @home-assistant/core @emontnemery
|
homeassistant/components/mqtt/* @home-assistant/core @emontnemery
|
||||||
homeassistant/components/msteams/* @peroyvind
|
homeassistant/components/msteams/* @peroyvind
|
||||||
|
101
homeassistant/components/motion_blinds/__init__.py
Normal file
101
homeassistant/components/motion_blinds/__init__.py
Normal 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
|
64
homeassistant/components/motion_blinds/config_flow.py
Normal file
64
homeassistant/components/motion_blinds/config_flow.py
Normal 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},
|
||||||
|
)
|
6
homeassistant/components/motion_blinds/const.py
Normal file
6
homeassistant/components/motion_blinds/const.py
Normal 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"
|
256
homeassistant/components/motion_blinds/cover.py
Normal file
256
homeassistant/components/motion_blinds/cover.py
Normal 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)
|
45
homeassistant/components/motion_blinds/gateway.py
Normal file
45
homeassistant/components/motion_blinds/gateway.py
Normal 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
|
8
homeassistant/components/motion_blinds/manifest.json
Normal file
8
homeassistant/components/motion_blinds/manifest.json
Normal 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"]
|
||||||
|
}
|
181
homeassistant/components/motion_blinds/sensor.py
Normal file
181
homeassistant/components/motion_blinds/sensor.py
Normal 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
|
20
homeassistant/components/motion_blinds/strings.json
Normal file
20
homeassistant/components/motion_blinds/strings.json
Normal 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%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
20
homeassistant/components/motion_blinds/translations/en.json
Normal file
20
homeassistant/components/motion_blinds/translations/en.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -121,6 +121,7 @@ FLOWS = [
|
|||||||
"minecraft_server",
|
"minecraft_server",
|
||||||
"mobile_app",
|
"mobile_app",
|
||||||
"monoprice",
|
"monoprice",
|
||||||
|
"motion_blinds",
|
||||||
"mqtt",
|
"mqtt",
|
||||||
"myq",
|
"myq",
|
||||||
"neato",
|
"neato",
|
||||||
|
@ -951,6 +951,9 @@ minio==4.0.9
|
|||||||
# homeassistant.components.mitemp_bt
|
# homeassistant.components.mitemp_bt
|
||||||
mitemp_bt==0.0.3
|
mitemp_bt==0.0.3
|
||||||
|
|
||||||
|
# homeassistant.components.motion_blinds
|
||||||
|
motionblinds==0.1.6
|
||||||
|
|
||||||
# homeassistant.components.tts
|
# homeassistant.components.tts
|
||||||
mutagen==1.45.1
|
mutagen==1.45.1
|
||||||
|
|
||||||
|
@ -473,6 +473,9 @@ millheater==0.4.0
|
|||||||
# homeassistant.components.minio
|
# homeassistant.components.minio
|
||||||
minio==4.0.9
|
minio==4.0.9
|
||||||
|
|
||||||
|
# homeassistant.components.motion_blinds
|
||||||
|
motionblinds==0.1.6
|
||||||
|
|
||||||
# homeassistant.components.tts
|
# homeassistant.components.tts
|
||||||
mutagen==1.45.1
|
mutagen==1.45.1
|
||||||
|
|
||||||
|
1
tests/components/motion_blinds/__init__.py
Normal file
1
tests/components/motion_blinds/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Tests for the Motion Blinds integration."""
|
75
tests/components/motion_blinds/test_config_flow.py
Normal file
75
tests/components/motion_blinds/test_config_flow.py
Normal 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"
|
Loading…
x
Reference in New Issue
Block a user