Brunt package update with async, data update coordinator and config flow (#49714)
* implemented config_flow and dataupdatecoordinator * implemented config flow, dataupdatecoordinator and tests. * undid extra vscode task * fixed pylint errors * updates based on review * fix mypy in reauth * fast interval to 5 sec * fixed test patches and others from review * added released package * deleted wrong line from coveragerc * updates to config and tests * fixed test patch
This commit is contained in:
parent
05eb2f3e5c
commit
958c199d80
12 changed files with 586 additions and 79 deletions
|
@ -138,7 +138,9 @@ omit =
|
||||||
homeassistant/components/broadlink/updater.py
|
homeassistant/components/broadlink/updater.py
|
||||||
homeassistant/components/brottsplatskartan/sensor.py
|
homeassistant/components/brottsplatskartan/sensor.py
|
||||||
homeassistant/components/browser/*
|
homeassistant/components/browser/*
|
||||||
|
homeassistant/components/brunt/__init__.py
|
||||||
homeassistant/components/brunt/cover.py
|
homeassistant/components/brunt/cover.py
|
||||||
|
homeassistant/components/brunt/const.py
|
||||||
homeassistant/components/bsblan/climate.py
|
homeassistant/components/bsblan/climate.py
|
||||||
homeassistant/components/bt_home_hub_5/device_tracker.py
|
homeassistant/components/bt_home_hub_5/device_tracker.py
|
||||||
homeassistant/components/bt_smarthub/device_tracker.py
|
homeassistant/components/bt_smarthub/device_tracker.py
|
||||||
|
|
|
@ -1 +1,78 @@
|
||||||
"""The brunt component."""
|
"""The brunt component."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from aiohttp.client_exceptions import ClientResponseError, ServerDisconnectedError
|
||||||
|
import async_timeout
|
||||||
|
from brunt import BruntClientAsync
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
|
from .const import DATA_BAPI, DATA_COOR, DOMAIN, PLATFORMS, REGULAR_INTERVAL
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Set up Brunt using config flow."""
|
||||||
|
session = async_get_clientsession(hass)
|
||||||
|
bapi = BruntClientAsync(
|
||||||
|
username=entry.data[CONF_USERNAME],
|
||||||
|
password=entry.data[CONF_PASSWORD],
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await bapi.async_login()
|
||||||
|
except ServerDisconnectedError as exc:
|
||||||
|
raise ConfigEntryNotReady("Brunt not ready to connect.") from exc
|
||||||
|
except ClientResponseError as exc:
|
||||||
|
raise ConfigEntryAuthFailed(
|
||||||
|
f"Brunt could not connect with username: {entry.data[CONF_USERNAME]}."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
async def async_update_data():
|
||||||
|
"""Fetch data from the Brunt endpoint for all Things.
|
||||||
|
|
||||||
|
Error 403 is the API response for any kind of authentication error (failed password or email)
|
||||||
|
Error 401 is the API response for things that are not part of the account, could happen when a device is deleted from the account.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with async_timeout.timeout(10):
|
||||||
|
things = await bapi.async_get_things(force=True)
|
||||||
|
return {thing.SERIAL: thing for thing in things}
|
||||||
|
except ServerDisconnectedError as err:
|
||||||
|
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||||
|
except ClientResponseError as err:
|
||||||
|
if err.status == 403:
|
||||||
|
raise ConfigEntryAuthFailed() from err
|
||||||
|
if err.status == 401:
|
||||||
|
_LOGGER.warning("Device not found, will reload Brunt integration")
|
||||||
|
await hass.config_entries.async_reload(entry.entry_id)
|
||||||
|
|
||||||
|
coordinator = DataUpdateCoordinator(
|
||||||
|
hass,
|
||||||
|
_LOGGER,
|
||||||
|
name="brunt",
|
||||||
|
update_method=async_update_data,
|
||||||
|
update_interval=REGULAR_INTERVAL,
|
||||||
|
)
|
||||||
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
|
||||||
|
hass.data.setdefault(DOMAIN, {})
|
||||||
|
hass.data[DOMAIN][entry.entry_id] = {DATA_BAPI: bapi, DATA_COOR: coordinator}
|
||||||
|
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Unload a config entry."""
|
||||||
|
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
|
if unload_ok:
|
||||||
|
hass.data[DOMAIN].pop(entry.entry_id)
|
||||||
|
return unload_ok
|
||||||
|
|
119
homeassistant/components/brunt/config_flow.py
Normal file
119
homeassistant/components/brunt/config_flow.py
Normal file
|
@ -0,0 +1,119 @@
|
||||||
|
"""Config flow for brunt integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aiohttp import ClientResponseError
|
||||||
|
from aiohttp.client_exceptions import ServerDisconnectedError
|
||||||
|
from brunt import BruntClientAsync
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry, ConfigFlow
|
||||||
|
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||||
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DATA_SCHEMA = vol.Schema(
|
||||||
|
{vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str}
|
||||||
|
)
|
||||||
|
REAUTH_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str})
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_input(user_input: dict[str, Any]) -> dict[str, str] | None:
|
||||||
|
"""Login to the brunt api and return errors if any."""
|
||||||
|
errors = None
|
||||||
|
bapi = BruntClientAsync(
|
||||||
|
username=user_input[CONF_USERNAME],
|
||||||
|
password=user_input[CONF_PASSWORD],
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await bapi.async_login()
|
||||||
|
except ClientResponseError as exc:
|
||||||
|
if exc.status == 403:
|
||||||
|
_LOGGER.warning("Brunt Credentials are incorrect")
|
||||||
|
errors = {"base": "invalid_auth"}
|
||||||
|
else:
|
||||||
|
_LOGGER.exception("Unknown error when trying to login to Brunt: %s", exc)
|
||||||
|
errors = {"base": "unknown"}
|
||||||
|
except ServerDisconnectedError:
|
||||||
|
_LOGGER.warning("Cannot connect to Brunt")
|
||||||
|
errors = {"base": "cannot_connect"}
|
||||||
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
|
_LOGGER.exception("Unknown error when trying to login to Brunt: %s", exc)
|
||||||
|
errors = {"base": "unknown"}
|
||||||
|
finally:
|
||||||
|
await bapi.async_close()
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
class BruntConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle a config flow for Brunt."""
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
|
||||||
|
_reauth_entry: ConfigEntry | None = None
|
||||||
|
|
||||||
|
async def async_step_user(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Handle the initial step."""
|
||||||
|
if user_input is None:
|
||||||
|
return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA)
|
||||||
|
|
||||||
|
errors = await validate_input(user_input)
|
||||||
|
if errors is not None:
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user", data_schema=DATA_SCHEMA, errors=errors
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.async_set_unique_id(user_input[CONF_USERNAME].lower())
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
return self.async_create_entry(
|
||||||
|
title=user_input[CONF_USERNAME],
|
||||||
|
data=user_input,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_reauth(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Perform reauth upon an API authentication error."""
|
||||||
|
self._reauth_entry = self.hass.config_entries.async_get_entry(
|
||||||
|
self.context["entry_id"]
|
||||||
|
)
|
||||||
|
return await self.async_step_reauth_confirm()
|
||||||
|
|
||||||
|
async def async_step_reauth_confirm(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Dialog that informs the user that reauth is required."""
|
||||||
|
assert self._reauth_entry
|
||||||
|
username = self._reauth_entry.data[CONF_USERNAME]
|
||||||
|
if user_input is None:
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="reauth_confirm",
|
||||||
|
data_schema=REAUTH_SCHEMA,
|
||||||
|
description_placeholders={"username": username},
|
||||||
|
)
|
||||||
|
user_input[CONF_USERNAME] = username
|
||||||
|
errors = await validate_input(user_input)
|
||||||
|
if errors is not None:
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="reauth_confirm",
|
||||||
|
data_schema=REAUTH_SCHEMA,
|
||||||
|
errors=errors,
|
||||||
|
description_placeholders={"username": username},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.hass.config_entries.async_update_entry(self._reauth_entry, data=user_input)
|
||||||
|
await self.hass.config_entries.async_reload(self._reauth_entry.entry_id)
|
||||||
|
return self.async_abort(reason="reauth_successful")
|
||||||
|
|
||||||
|
async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult:
|
||||||
|
"""Import config from configuration.yaml."""
|
||||||
|
await self.async_set_unique_id(import_config[CONF_USERNAME].lower())
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
return await self.async_step_user(import_config)
|
17
homeassistant/components/brunt/const.py
Normal file
17
homeassistant/components/brunt/const.py
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
"""Constants for Brunt."""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
DOMAIN = "brunt"
|
||||||
|
ATTR_REQUEST_POSITION = "request_position"
|
||||||
|
NOTIFICATION_ID = "brunt_notification"
|
||||||
|
NOTIFICATION_TITLE = "Brunt Cover Setup"
|
||||||
|
ATTRIBUTION = "Based on an unofficial Brunt SDK."
|
||||||
|
PLATFORMS = ["cover"]
|
||||||
|
DATA_BAPI = "bapi"
|
||||||
|
DATA_COOR = "coordinator"
|
||||||
|
|
||||||
|
CLOSED_POSITION = 0
|
||||||
|
OPEN_POSITION = 100
|
||||||
|
|
||||||
|
REGULAR_INTERVAL = timedelta(seconds=20)
|
||||||
|
FAST_INTERVAL = timedelta(seconds=5)
|
|
@ -1,84 +1,134 @@
|
||||||
"""Support for Brunt Blind Engine covers."""
|
"""Support for Brunt Blind Engine covers."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import MutableMapping
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from brunt import BruntAPI
|
from aiohttp.client_exceptions import ClientResponseError
|
||||||
import voluptuous as vol
|
from brunt import BruntClientAsync, Thing
|
||||||
|
|
||||||
from homeassistant.components.cover import (
|
from homeassistant.components.cover import (
|
||||||
ATTR_POSITION,
|
ATTR_POSITION,
|
||||||
DEVICE_CLASS_WINDOW,
|
DEVICE_CLASS_SHADE,
|
||||||
PLATFORM_SCHEMA,
|
|
||||||
SUPPORT_CLOSE,
|
SUPPORT_CLOSE,
|
||||||
SUPPORT_OPEN,
|
SUPPORT_OPEN,
|
||||||
SUPPORT_SET_POSITION,
|
SUPPORT_SET_POSITION,
|
||||||
CoverEntity,
|
CoverEntity,
|
||||||
)
|
)
|
||||||
from homeassistant.const import ATTR_ATTRIBUTION, CONF_PASSWORD, CONF_USERNAME
|
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
from homeassistant.helpers.entity import DeviceInfo
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
|
from homeassistant.helpers.update_coordinator import (
|
||||||
|
CoordinatorEntity,
|
||||||
|
DataUpdateCoordinator,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
ATTR_REQUEST_POSITION,
|
||||||
|
ATTRIBUTION,
|
||||||
|
CLOSED_POSITION,
|
||||||
|
DATA_BAPI,
|
||||||
|
DATA_COOR,
|
||||||
|
DOMAIN,
|
||||||
|
FAST_INTERVAL,
|
||||||
|
OPEN_POSITION,
|
||||||
|
REGULAR_INTERVAL,
|
||||||
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
COVER_FEATURES = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION
|
COVER_FEATURES = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION
|
||||||
|
|
||||||
ATTR_REQUEST_POSITION = "request_position"
|
|
||||||
NOTIFICATION_ID = "brunt_notification"
|
|
||||||
NOTIFICATION_TITLE = "Brunt Cover Setup"
|
|
||||||
ATTRIBUTION = "Based on an unofficial Brunt SDK."
|
|
||||||
|
|
||||||
CLOSED_POSITION = 0
|
async def async_setup_platform(
|
||||||
OPEN_POSITION = 100
|
hass: HomeAssistant,
|
||||||
|
config: ConfigType,
|
||||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
add_entities: AddEntitiesCallback,
|
||||||
{vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string}
|
discovery_info: DiscoveryInfoType | None = None,
|
||||||
)
|
) -> None:
|
||||||
|
"""Component setup, run import config flow for each entry in config."""
|
||||||
|
_LOGGER.warning(
|
||||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
"Loading brunt via platform config is deprecated; The configuration has been migrated to a config entry and can be safely removed from configuration.yaml"
|
||||||
"""Set up the brunt platform."""
|
)
|
||||||
|
hass.async_create_task(
|
||||||
username = config[CONF_USERNAME]
|
hass.config_entries.flow.async_init(
|
||||||
password = config[CONF_PASSWORD]
|
DOMAIN, context={"source": SOURCE_IMPORT}, data=config
|
||||||
|
|
||||||
bapi = BruntAPI(username=username, password=password)
|
|
||||||
try:
|
|
||||||
if not (things := bapi.getThings()["things"]):
|
|
||||||
_LOGGER.error("No things present in account")
|
|
||||||
else:
|
|
||||||
add_entities(
|
|
||||||
[
|
|
||||||
BruntDevice(bapi, thing["NAME"], thing["thingUri"])
|
|
||||||
for thing in things
|
|
||||||
],
|
|
||||||
True,
|
|
||||||
)
|
|
||||||
except (TypeError, KeyError, NameError, ValueError) as ex:
|
|
||||||
_LOGGER.error("%s", ex)
|
|
||||||
hass.components.persistent_notification.create(
|
|
||||||
"Error: {ex}<br />You will need to restart hass after fixing.",
|
|
||||||
title=NOTIFICATION_TITLE,
|
|
||||||
notification_id=NOTIFICATION_ID,
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BruntDevice(CoverEntity):
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up the brunt platform."""
|
||||||
|
bapi: BruntClientAsync = hass.data[DOMAIN][entry.entry_id][DATA_BAPI]
|
||||||
|
coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][DATA_COOR]
|
||||||
|
|
||||||
|
async_add_entities(
|
||||||
|
BruntDevice(coordinator, serial, thing, bapi, entry.entry_id)
|
||||||
|
for serial, thing in coordinator.data.items()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BruntDevice(CoordinatorEntity, CoverEntity):
|
||||||
"""
|
"""
|
||||||
Representation of a Brunt cover device.
|
Representation of a Brunt cover device.
|
||||||
|
|
||||||
Contains the common logic for all Brunt devices.
|
Contains the common logic for all Brunt devices.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_attr_device_class = DEVICE_CLASS_WINDOW
|
def __init__(
|
||||||
_attr_supported_features = COVER_FEATURES
|
self,
|
||||||
|
coordinator: DataUpdateCoordinator,
|
||||||
def __init__(self, bapi, name, thing_uri):
|
serial: str,
|
||||||
|
thing: Thing,
|
||||||
|
bapi: BruntClientAsync,
|
||||||
|
entry_id: str,
|
||||||
|
) -> None:
|
||||||
"""Init the Brunt device."""
|
"""Init the Brunt device."""
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self._attr_unique_id = serial
|
||||||
self._bapi = bapi
|
self._bapi = bapi
|
||||||
self._attr_name = name
|
self._thing = thing
|
||||||
self._thing_uri = thing_uri
|
self._entry_id = entry_id
|
||||||
|
|
||||||
self._state = {}
|
self._remove_update_listener = None
|
||||||
|
|
||||||
|
self._attr_name = self._thing.NAME
|
||||||
|
self._attr_device_class = DEVICE_CLASS_SHADE
|
||||||
|
self._attr_supported_features = COVER_FEATURES
|
||||||
|
self._attr_attribution = ATTRIBUTION
|
||||||
|
self._attr_device_info = DeviceInfo(
|
||||||
|
identifiers={(DOMAIN, self._attr_unique_id)},
|
||||||
|
name=self._attr_name,
|
||||||
|
via_device=(DOMAIN, self._entry_id),
|
||||||
|
manufacturer="Brunt",
|
||||||
|
sw_version=self._thing.FW_VERSION,
|
||||||
|
model=self._thing.MODEL,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_added_to_hass(self) -> None:
|
||||||
|
"""When entity is added to hass."""
|
||||||
|
await super().async_added_to_hass()
|
||||||
|
self.async_on_remove(
|
||||||
|
self.coordinator.async_add_listener(self._brunt_update_listener)
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_cover_position(self) -> int | None:
|
||||||
|
"""
|
||||||
|
Return current position of cover.
|
||||||
|
|
||||||
|
None is unknown, 0 is closed, 100 is fully open.
|
||||||
|
"""
|
||||||
|
pos = self.coordinator.data[self.unique_id].currentPosition
|
||||||
|
return int(pos) if pos is not None else None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def request_cover_position(self) -> int | None:
|
def request_cover_position(self) -> int | None:
|
||||||
|
@ -89,8 +139,8 @@ class BruntDevice(CoverEntity):
|
||||||
to Brunt, at times there is a diff of 1 to current
|
to Brunt, at times there is a diff of 1 to current
|
||||||
None is unknown, 0 is closed, 100 is fully open.
|
None is unknown, 0 is closed, 100 is fully open.
|
||||||
"""
|
"""
|
||||||
pos = self._state.get("requestPosition")
|
pos = self.coordinator.data[self.unique_id].requestPosition
|
||||||
return int(pos) if pos else None
|
return int(pos) if pos is not None else None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def move_state(self) -> int | None:
|
def move_state(self) -> int | None:
|
||||||
|
@ -99,37 +149,64 @@ class BruntDevice(CoverEntity):
|
||||||
|
|
||||||
None is unknown, 0 when stopped, 1 when opening, 2 when closing
|
None is unknown, 0 when stopped, 1 when opening, 2 when closing
|
||||||
"""
|
"""
|
||||||
mov = self._state.get("moveState")
|
mov = self.coordinator.data[self.unique_id].moveState
|
||||||
return int(mov) if mov else None
|
return int(mov) if mov is not None else None
|
||||||
|
|
||||||
def update(self):
|
@property
|
||||||
"""Poll the current state of the device."""
|
def is_opening(self) -> bool:
|
||||||
try:
|
"""Return if the cover is opening or not."""
|
||||||
self._state = self._bapi.getState(thingUri=self._thing_uri).get("thing")
|
return self.move_state == 1
|
||||||
self._attr_available = True
|
|
||||||
except (TypeError, KeyError, NameError, ValueError) as ex:
|
@property
|
||||||
_LOGGER.error("%s", ex)
|
def is_closing(self) -> bool:
|
||||||
self._attr_available = False
|
"""Return if the cover is closing or not."""
|
||||||
self._attr_is_opening = self.move_state == 1
|
return self.move_state == 2
|
||||||
self._attr_is_closing = self.move_state == 2
|
|
||||||
pos = self._state.get("currentPosition")
|
@property
|
||||||
self._attr_current_cover_position = int(pos) if pos else None
|
def extra_state_attributes(self) -> MutableMapping[str, Any]:
|
||||||
self._attr_is_closed = self.current_cover_position == CLOSED_POSITION
|
"""Return the detailed device state attributes."""
|
||||||
self._attr_extra_state_attributes = {
|
return {
|
||||||
ATTR_ATTRIBUTION: ATTRIBUTION,
|
|
||||||
ATTR_REQUEST_POSITION: self.request_cover_position,
|
ATTR_REQUEST_POSITION: self.request_cover_position,
|
||||||
}
|
}
|
||||||
|
|
||||||
def open_cover(self, **kwargs):
|
@property
|
||||||
|
def is_closed(self) -> bool:
|
||||||
|
"""Return true if cover is closed, else False."""
|
||||||
|
return self.current_cover_position == CLOSED_POSITION
|
||||||
|
|
||||||
|
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||||
"""Set the cover to the open position."""
|
"""Set the cover to the open position."""
|
||||||
self._bapi.changeRequestPosition(OPEN_POSITION, thingUri=self._thing_uri)
|
await self._async_update_cover(OPEN_POSITION)
|
||||||
|
|
||||||
def close_cover(self, **kwargs):
|
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||||
"""Set the cover to the closed position."""
|
"""Set the cover to the closed position."""
|
||||||
self._bapi.changeRequestPosition(CLOSED_POSITION, thingUri=self._thing_uri)
|
await self._async_update_cover(CLOSED_POSITION)
|
||||||
|
|
||||||
def set_cover_position(self, **kwargs):
|
async def async_set_cover_position(self, **kwargs: Any) -> None:
|
||||||
"""Set the cover to a specific position."""
|
"""Set the cover to a specific position."""
|
||||||
self._bapi.changeRequestPosition(
|
await self._async_update_cover(int(kwargs[ATTR_POSITION]))
|
||||||
kwargs[ATTR_POSITION], thingUri=self._thing_uri
|
|
||||||
)
|
async def _async_update_cover(self, position: int) -> None:
|
||||||
|
"""Set the cover to the new position and wait for the update to be reflected."""
|
||||||
|
try:
|
||||||
|
await self._bapi.async_change_request_position(
|
||||||
|
position, thingUri=self._thing.thingUri
|
||||||
|
)
|
||||||
|
except ClientResponseError as exc:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
f"Unable to reposition {self._thing.NAME}"
|
||||||
|
) from exc
|
||||||
|
self.coordinator.update_interval = FAST_INTERVAL
|
||||||
|
await self.coordinator.async_request_refresh()
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _brunt_update_listener(self) -> None:
|
||||||
|
"""Update the update interval after each refresh."""
|
||||||
|
if (
|
||||||
|
self.request_cover_position
|
||||||
|
== self._bapi.last_requested_positions[self._thing.thingUri]
|
||||||
|
and self.move_state == 0
|
||||||
|
):
|
||||||
|
self.coordinator.update_interval = REGULAR_INTERVAL
|
||||||
|
else:
|
||||||
|
self.coordinator.update_interval = FAST_INTERVAL
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
{
|
{
|
||||||
"domain": "brunt",
|
"domain": "brunt",
|
||||||
"name": "Brunt Blind Engine",
|
"name": "Brunt Blind Engine",
|
||||||
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/brunt",
|
"documentation": "https://www.home-assistant.io/integrations/brunt",
|
||||||
"requirements": ["brunt==0.1.3"],
|
"requirements": ["brunt==1.0.0"],
|
||||||
"codeowners": ["@eavanvalkenburg"],
|
"codeowners": ["@eavanvalkenburg"],
|
||||||
"iot_class": "cloud_polling"
|
"iot_class": "cloud_polling"
|
||||||
}
|
}
|
||||||
|
|
29
homeassistant/components/brunt/strings.json
Normal file
29
homeassistant/components/brunt/strings.json
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"title": "Setup your Brunt integration",
|
||||||
|
"data": {
|
||||||
|
"username": "[%key:common::config_flow::data::username%]",
|
||||||
|
"password": "[%key:common::config_flow::data::password%]"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"reauth_confirm": {
|
||||||
|
"title": "[%key:common::config_flow::title::reauth%]",
|
||||||
|
"description": "Please reenter the password for: {username}",
|
||||||
|
"data": {
|
||||||
|
"password": "[%key:common::config_flow::data::password%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||||
|
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||||
|
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||||
|
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -43,6 +43,7 @@ FLOWS = [
|
||||||
"braviatv",
|
"braviatv",
|
||||||
"broadlink",
|
"broadlink",
|
||||||
"brother",
|
"brother",
|
||||||
|
"brunt",
|
||||||
"bsblan",
|
"bsblan",
|
||||||
"buienradar",
|
"buienradar",
|
||||||
"canary",
|
"canary",
|
||||||
|
|
|
@ -440,7 +440,7 @@ brother==1.1.0
|
||||||
brottsplatskartan==0.0.1
|
brottsplatskartan==0.0.1
|
||||||
|
|
||||||
# homeassistant.components.brunt
|
# homeassistant.components.brunt
|
||||||
brunt==0.1.3
|
brunt==1.0.0
|
||||||
|
|
||||||
# homeassistant.components.bsblan
|
# homeassistant.components.bsblan
|
||||||
bsblan==0.4.0
|
bsblan==0.4.0
|
||||||
|
|
|
@ -277,6 +277,9 @@ broadlink==0.18.0
|
||||||
# homeassistant.components.brother
|
# homeassistant.components.brother
|
||||||
brother==1.1.0
|
brother==1.1.0
|
||||||
|
|
||||||
|
# homeassistant.components.brunt
|
||||||
|
brunt==1.0.0
|
||||||
|
|
||||||
# homeassistant.components.bsblan
|
# homeassistant.components.bsblan
|
||||||
bsblan==0.4.0
|
bsblan==0.4.0
|
||||||
|
|
||||||
|
|
1
tests/components/brunt/__init__.py
Normal file
1
tests/components/brunt/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
"""Brunt tests."""
|
180
tests/components/brunt/test_config_flow.py
Normal file
180
tests/components/brunt/test_config_flow.py
Normal file
|
@ -0,0 +1,180 @@
|
||||||
|
"""Test the Brunt config flow."""
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from aiohttp import ClientResponseError
|
||||||
|
from aiohttp.client_exceptions import ServerDisconnectedError
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant import config_entries, data_entry_flow
|
||||||
|
from homeassistant.components.brunt.const import DOMAIN
|
||||||
|
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
CONFIG = {CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_form(hass):
|
||||||
|
"""Test we get the form."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}, data=None
|
||||||
|
)
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["errors"] is None
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.brunt.config_flow.BruntClientAsync.async_login",
|
||||||
|
return_value=None,
|
||||||
|
), patch(
|
||||||
|
"homeassistant.components.brunt.async_setup_entry",
|
||||||
|
return_value=True,
|
||||||
|
) as mock_setup_entry:
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
CONFIG,
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||||
|
assert result2["title"] == "test-username"
|
||||||
|
assert result2["data"] == CONFIG
|
||||||
|
assert len(mock_setup_entry.mock_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_import(hass):
|
||||||
|
"""Test we get the form."""
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.brunt.config_flow.BruntClientAsync.async_login",
|
||||||
|
return_value=None,
|
||||||
|
), patch(
|
||||||
|
"homeassistant.components.brunt.async_setup_entry",
|
||||||
|
return_value=True,
|
||||||
|
) as mock_setup_entry:
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=CONFIG
|
||||||
|
)
|
||||||
|
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||||
|
assert result["title"] == "test-username"
|
||||||
|
assert result["data"] == CONFIG
|
||||||
|
assert len(mock_setup_entry.mock_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_import_duplicate_login(hass):
|
||||||
|
"""Test uniqueness of username."""
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
data=CONFIG,
|
||||||
|
title="test-username",
|
||||||
|
unique_id="test-username",
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.brunt.config_flow.BruntClientAsync.async_login",
|
||||||
|
return_value=None,
|
||||||
|
), patch(
|
||||||
|
"homeassistant.components.brunt.async_setup_entry",
|
||||||
|
return_value=True,
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=CONFIG
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||||
|
assert result["reason"] == "already_configured"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_form_duplicate_login(hass):
|
||||||
|
"""Test uniqueness of username."""
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
data=CONFIG,
|
||||||
|
title="test-username",
|
||||||
|
unique_id="test-username",
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.brunt.config_flow.BruntClientAsync.async_login",
|
||||||
|
return_value=None,
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}, data=CONFIG
|
||||||
|
)
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||||
|
assert result["reason"] == "already_configured"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"side_effect, error_message",
|
||||||
|
[
|
||||||
|
(ServerDisconnectedError, "cannot_connect"),
|
||||||
|
(ClientResponseError(Mock(), None, status=403), "invalid_auth"),
|
||||||
|
(ClientResponseError(Mock(), None, status=401), "unknown"),
|
||||||
|
(Exception, "unknown"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_form_error(hass, side_effect, error_message):
|
||||||
|
"""Test we handle cannot connect."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.brunt.config_flow.BruntClientAsync.async_login",
|
||||||
|
side_effect=side_effect,
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}, data=CONFIG
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["errors"] == {"base": error_message}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"side_effect, result_type, password, step_id, reason",
|
||||||
|
[
|
||||||
|
(None, data_entry_flow.RESULT_TYPE_ABORT, "test", None, "reauth_successful"),
|
||||||
|
(
|
||||||
|
Exception,
|
||||||
|
data_entry_flow.RESULT_TYPE_FORM,
|
||||||
|
CONFIG[CONF_PASSWORD],
|
||||||
|
"reauth_confirm",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_reauth(hass, side_effect, result_type, password, step_id, reason):
|
||||||
|
"""Test uniqueness of username."""
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
data=CONFIG,
|
||||||
|
title="test-username",
|
||||||
|
unique_id="test-username",
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={
|
||||||
|
"source": config_entries.SOURCE_REAUTH,
|
||||||
|
"unique_id": entry.unique_id,
|
||||||
|
"entry_id": entry.entry_id,
|
||||||
|
},
|
||||||
|
data=None,
|
||||||
|
)
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "reauth_confirm"
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.brunt.config_flow.BruntClientAsync.async_login",
|
||||||
|
return_value=None,
|
||||||
|
side_effect=side_effect,
|
||||||
|
):
|
||||||
|
result3 = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
user_input={"password": "test"},
|
||||||
|
)
|
||||||
|
assert result3["type"] == result_type
|
||||||
|
assert entry.data["password"] == password
|
||||||
|
assert result3.get("step_id", None) == step_id
|
||||||
|
assert result3.get("reason", None) == reason
|
Loading…
Add table
Add a link
Reference in a new issue