forked from home-assistant/core
Compare commits
58 Commits
2021.3.0b7
...
2021.3.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8844ff24a | ||
|
|
b80c2d426c | ||
|
|
b352c5840f | ||
|
|
9f6007b4e2 | ||
|
|
58573dc74d | ||
|
|
a51ad137a1 | ||
|
|
9601cb7445 | ||
|
|
96b266b2e8 | ||
|
|
3c1aac1034 | ||
|
|
0f115f6937 | ||
|
|
f1fc6c4b25 | ||
|
|
37f486941a | ||
|
|
69f63129aa | ||
|
|
56efae3cb5 | ||
|
|
a2e00324a8 | ||
|
|
e63f766c20 | ||
|
|
e7717694a3 | ||
|
|
ba2b62305b | ||
|
|
b25f846136 | ||
|
|
c1a5a18b53 | ||
|
|
3b05a12e62 | ||
|
|
1145c30c4b | ||
|
|
939da2403f | ||
|
|
ef79d24a8c | ||
|
|
55c1b67de4 | ||
|
|
90e0801c1b | ||
|
|
65859b4107 | ||
|
|
6c45a7d533 | ||
|
|
ddc6cd6da1 | ||
|
|
a4369fc352 | ||
|
|
d11da43551 | ||
|
|
f99ef25f88 | ||
|
|
9fa0de8600 | ||
|
|
91ac4554a2 | ||
|
|
d9542c2efe | ||
|
|
915ee2f4ee | ||
|
|
ff86f64806 | ||
|
|
25ff2e745d | ||
|
|
f53cff49d5 | ||
|
|
36a2521799 | ||
|
|
ee55a04b4b | ||
|
|
6724d86565 | ||
|
|
14dca8e783 | ||
|
|
1817147995 | ||
|
|
0bf3dea40c | ||
|
|
5685b4aa33 | ||
|
|
ef9b9663c5 | ||
|
|
d83ccdc97a | ||
|
|
c0840e22dc | ||
|
|
33c3566106 | ||
|
|
d175ac8e0d | ||
|
|
c2f7a38d09 | ||
|
|
e9785fcd3d | ||
|
|
b711686e10 | ||
|
|
584ad07567 | ||
|
|
a89ba0ed8e | ||
|
|
15c89ebada | ||
|
|
24919e99b8 |
@@ -46,7 +46,7 @@ homeassistant/components/arcam_fmj/* @elupus
|
||||
homeassistant/components/arduino/* @fabaff
|
||||
homeassistant/components/arest/* @fabaff
|
||||
homeassistant/components/arris_tg2492lg/* @vanbalken
|
||||
homeassistant/components/asuswrt/* @kennedyshead
|
||||
homeassistant/components/asuswrt/* @kennedyshead @ollo69
|
||||
homeassistant/components/atag/* @MatsNL
|
||||
homeassistant/components/aten_pe/* @mtdcr
|
||||
homeassistant/components/atome/* @baqs
|
||||
|
||||
@@ -393,7 +393,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
|
||||
),
|
||||
ATTR_FORECAST_TEMP: self._get_temperature_day(day),
|
||||
ATTR_FORECAST_TEMP_LOW: self._get_temperature_low_day(day),
|
||||
ATTR_FORECAST_TIME: dt_util.as_utc(date),
|
||||
ATTR_FORECAST_TIME: dt_util.as_utc(date).isoformat(),
|
||||
ATTR_FORECAST_WIND_SPEED: self._get_wind_speed_day(day),
|
||||
ATTR_FORECAST_WIND_BEARING: self._get_wind_bearing_day(day),
|
||||
}
|
||||
@@ -412,7 +412,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
|
||||
day, hour
|
||||
),
|
||||
ATTR_FORECAST_TEMP: self._get_temperature(day, hour),
|
||||
ATTR_FORECAST_TIME: dt_util.as_utc(forecast_dt),
|
||||
ATTR_FORECAST_TIME: dt_util.as_utc(forecast_dt).isoformat(),
|
||||
ATTR_FORECAST_WIND_SPEED: self._get_wind_speed(day, hour),
|
||||
ATTR_FORECAST_WIND_BEARING: self._get_wind_bearing(day, hour),
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ from .const import (
|
||||
DATA_COORDINATOR,
|
||||
DOMAIN,
|
||||
INTEGRATION_TYPE_GEOGRAPHY_COORDS,
|
||||
INTEGRATION_TYPE_GEOGRAPHY_NAME,
|
||||
INTEGRATION_TYPE_NODE_PRO,
|
||||
LOGGER,
|
||||
)
|
||||
@@ -142,12 +143,21 @@ def _standardize_geography_config_entry(hass, config_entry):
|
||||
if not config_entry.options:
|
||||
# If the config entry doesn't already have any options set, set defaults:
|
||||
entry_updates["options"] = {CONF_SHOW_ON_MAP: True}
|
||||
if CONF_INTEGRATION_TYPE not in config_entry.data:
|
||||
# If the config entry data doesn't contain the integration type, add it:
|
||||
entry_updates["data"] = {
|
||||
**config_entry.data,
|
||||
CONF_INTEGRATION_TYPE: INTEGRATION_TYPE_GEOGRAPHY_COORDS,
|
||||
}
|
||||
if config_entry.data.get(CONF_INTEGRATION_TYPE) not in [
|
||||
INTEGRATION_TYPE_GEOGRAPHY_COORDS,
|
||||
INTEGRATION_TYPE_GEOGRAPHY_NAME,
|
||||
]:
|
||||
# If the config entry data doesn't contain an integration type that we know
|
||||
# about, infer it from the data we have:
|
||||
entry_updates["data"] = {**config_entry.data}
|
||||
if CONF_CITY in config_entry.data:
|
||||
entry_updates["data"][
|
||||
CONF_INTEGRATION_TYPE
|
||||
] = INTEGRATION_TYPE_GEOGRAPHY_NAME
|
||||
else:
|
||||
entry_updates["data"][
|
||||
CONF_INTEGRATION_TYPE
|
||||
] = INTEGRATION_TYPE_GEOGRAPHY_COORDS
|
||||
|
||||
if not entry_updates:
|
||||
return
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "amcrest",
|
||||
"name": "Amcrest",
|
||||
"documentation": "https://www.home-assistant.io/integrations/amcrest",
|
||||
"requirements": ["amcrest==1.7.0"],
|
||||
"requirements": ["amcrest==1.7.1"],
|
||||
"dependencies": ["ffmpeg"],
|
||||
"codeowners": ["@pnbruckner"]
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ class AsusWrtFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
conf_protocol = user_input[CONF_PROTOCOL]
|
||||
if conf_protocol == PROTOCOL_TELNET:
|
||||
await api.connection.disconnect()
|
||||
api.connection.disconnect()
|
||||
return RESULT_SUCCESS
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/asuswrt",
|
||||
"requirements": ["aioasuswrt==1.3.1"],
|
||||
"codeowners": ["@kennedyshead"]
|
||||
"codeowners": ["@kennedyshead", "@ollo69"]
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@ class AsusWrtRouter:
|
||||
"""Close the connection."""
|
||||
if self._api is not None:
|
||||
if self._protocol == PROTOCOL_TELNET:
|
||||
await self._api.connection.disconnect()
|
||||
self._api.connection.disconnect()
|
||||
self._api = None
|
||||
|
||||
for func in self._on_close:
|
||||
|
||||
@@ -102,7 +102,7 @@ class BondEntity(Entity):
|
||||
|
||||
async def _async_update_if_bpup_not_alive(self, *_):
|
||||
"""Fetch via the API if BPUP is not alive."""
|
||||
if self._bpup_subs.alive and self._initialized:
|
||||
if self._bpup_subs.alive and self._initialized and self._available:
|
||||
return
|
||||
|
||||
if self._update_lock.locked():
|
||||
|
||||
@@ -256,7 +256,8 @@ class ClimaCellEntity(CoordinatorEntity):
|
||||
"""Return device registry information."""
|
||||
return {
|
||||
"identifiers": {(DOMAIN, self._config_entry.data[CONF_API_KEY])},
|
||||
"name": self.name,
|
||||
"name": "ClimaCell",
|
||||
"manufacturer": "ClimaCell",
|
||||
"sw_version": "v3",
|
||||
"entry_type": "service",
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ from homeassistant.const import (
|
||||
LENGTH_MILES,
|
||||
PRESSURE_HPA,
|
||||
PRESSURE_INHG,
|
||||
TEMP_CELSIUS,
|
||||
TEMP_FAHRENHEIT,
|
||||
)
|
||||
from homeassistant.helpers.entity import Entity
|
||||
@@ -31,7 +30,6 @@ from homeassistant.helpers.typing import HomeAssistantType
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.distance import convert as distance_convert
|
||||
from homeassistant.util.pressure import convert as pressure_convert
|
||||
from homeassistant.util.temperature import convert as temp_convert
|
||||
|
||||
from . import ClimaCellDataUpdateCoordinator, ClimaCellEntity
|
||||
from .const import (
|
||||
@@ -102,10 +100,6 @@ def _forecast_dict(
|
||||
precipitation = (
|
||||
distance_convert(precipitation / 12, LENGTH_FEET, LENGTH_METERS) * 1000
|
||||
)
|
||||
if temp:
|
||||
temp = temp_convert(temp, TEMP_FAHRENHEIT, TEMP_CELSIUS)
|
||||
if temp_low:
|
||||
temp_low = temp_convert(temp_low, TEMP_FAHRENHEIT, TEMP_CELSIUS)
|
||||
if wind_speed:
|
||||
wind_speed = distance_convert(wind_speed, LENGTH_MILES, LENGTH_KILOMETERS)
|
||||
|
||||
@@ -260,6 +254,7 @@ class ClimaCellWeatherEntity(ClimaCellEntity, WeatherEntity):
|
||||
|
||||
if self.forecast_type == DAILY:
|
||||
use_datetime = False
|
||||
forecast_dt = dt_util.start_of_local_day(forecast_dt)
|
||||
precipitation = self._get_cc_value(
|
||||
forecast, CC_ATTR_PRECIPITATION_DAILY
|
||||
)
|
||||
|
||||
@@ -77,6 +77,7 @@ _NOT_SPEED_INTERVAL = "interval"
|
||||
_NOT_SPEED_IDLE = "idle"
|
||||
_NOT_SPEED_FAVORITE = "favorite"
|
||||
_NOT_SPEED_SLEEP = "sleep"
|
||||
_NOT_SPEED_SILENT = "silent"
|
||||
|
||||
_NOT_SPEEDS_FILTER = {
|
||||
_NOT_SPEED_OFF,
|
||||
@@ -85,6 +86,7 @@ _NOT_SPEEDS_FILTER = {
|
||||
_NOT_SPEED_SMART,
|
||||
_NOT_SPEED_INTERVAL,
|
||||
_NOT_SPEED_IDLE,
|
||||
_NOT_SPEED_SILENT,
|
||||
_NOT_SPEED_SLEEP,
|
||||
_NOT_SPEED_FAVORITE,
|
||||
}
|
||||
@@ -652,7 +654,7 @@ def speed_list_without_preset_modes(speed_list: List):
|
||||
output: ["1", "2", "3", "4", "5", "6", "7"]
|
||||
|
||||
input: ["Auto", "Silent", "Favorite", "Idle", "Medium", "High", "Strong"]
|
||||
output: ["Silent", "Medium", "High", "Strong"]
|
||||
output: ["Medium", "High", "Strong"]
|
||||
"""
|
||||
|
||||
return [speed for speed in speed_list if speed.lower() not in _NOT_SPEEDS_FILTER]
|
||||
@@ -674,7 +676,7 @@ def preset_modes_from_speed_list(speed_list: List):
|
||||
output: ["smart"]
|
||||
|
||||
input: ["Auto", "Silent", "Favorite", "Idle", "Medium", "High", "Strong"]
|
||||
output: ["Auto", "Favorite", "Idle"]
|
||||
output: ["Auto", "Silent", "Favorite", "Idle"]
|
||||
"""
|
||||
|
||||
return [
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Home Assistant Frontend",
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"requirements": [
|
||||
"home-assistant-frontend==20210302.0"
|
||||
"home-assistant-frontend==20210302.6"
|
||||
],
|
||||
"dependencies": [
|
||||
"api",
|
||||
|
||||
@@ -35,6 +35,7 @@ from homeassistant.const import (
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
from homeassistant.core import DOMAIN as HA_DOMAIN, CoreState, callback
|
||||
from homeassistant.exceptions import ConditionError
|
||||
from homeassistant.helpers import condition
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.event import (
|
||||
@@ -439,12 +440,16 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
|
||||
current_state = STATE_ON
|
||||
else:
|
||||
current_state = HVAC_MODE_OFF
|
||||
long_enough = condition.state(
|
||||
self.hass,
|
||||
self.heater_entity_id,
|
||||
current_state,
|
||||
self.min_cycle_duration,
|
||||
)
|
||||
try:
|
||||
long_enough = condition.state(
|
||||
self.hass,
|
||||
self.heater_entity_id,
|
||||
current_state,
|
||||
self.min_cycle_duration,
|
||||
)
|
||||
except ConditionError:
|
||||
long_enough = False
|
||||
|
||||
if not long_enough:
|
||||
return
|
||||
|
||||
|
||||
@@ -616,7 +616,7 @@ class HomeKit:
|
||||
self._async_register_bridge(dev_reg)
|
||||
await self._async_start(bridged_states)
|
||||
_LOGGER.debug("Driver start for %s", self._name)
|
||||
self.hass.add_job(self.driver.start_service)
|
||||
await self.driver.async_start()
|
||||
self.status = STATUS_RUNNING
|
||||
|
||||
@callback
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "HomeKit",
|
||||
"documentation": "https://www.home-assistant.io/integrations/homekit",
|
||||
"requirements": [
|
||||
"HAP-python==3.3.2",
|
||||
"HAP-python==3.4.0",
|
||||
"fnvhash==0.1.0",
|
||||
"PyQRCode==1.2.1",
|
||||
"base36==0.1.1",
|
||||
|
||||
@@ -27,7 +27,7 @@ from homeassistant.const import (
|
||||
STATE_ON,
|
||||
)
|
||||
from homeassistant.core import callback, split_entity_id
|
||||
from homeassistant.helpers.event import call_later
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
|
||||
from .accessories import TYPES, HomeAccessory
|
||||
from .const import (
|
||||
@@ -134,7 +134,7 @@ class Switch(HomeAccessory):
|
||||
self.async_call_service(self._domain, service, params)
|
||||
|
||||
if self.activate_only:
|
||||
call_later(self.hass, 1, self.reset_switch)
|
||||
async_call_later(self.hass, 1, self.reset_switch)
|
||||
|
||||
@callback
|
||||
def async_update_state(self, new_state):
|
||||
|
||||
@@ -80,6 +80,13 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity):
|
||||
|
||||
return features
|
||||
|
||||
@property
|
||||
def speed_count(self):
|
||||
"""Speed count for the fan."""
|
||||
return round(
|
||||
100 / max(1, self.service[CharacteristicsTypes.ROTATION_SPEED].minStep or 0)
|
||||
)
|
||||
|
||||
async def async_set_direction(self, direction):
|
||||
"""Set the direction of the fan."""
|
||||
await self.async_put_characteristics(
|
||||
@@ -110,7 +117,7 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity):
|
||||
if not self.is_on:
|
||||
characteristics[self.on_characteristic] = True
|
||||
|
||||
if self.supported_features & SUPPORT_SET_SPEED:
|
||||
if percentage is not None and self.supported_features & SUPPORT_SET_SPEED:
|
||||
characteristics[CharacteristicsTypes.ROTATION_SPEED] = percentage
|
||||
|
||||
if characteristics:
|
||||
|
||||
@@ -19,7 +19,6 @@ from .const import (
|
||||
CONF_ALLOW_UNREACHABLE,
|
||||
DEFAULT_ALLOW_HUE_GROUPS,
|
||||
DEFAULT_ALLOW_UNREACHABLE,
|
||||
DEFAULT_SCENE_TRANSITION,
|
||||
LOGGER,
|
||||
)
|
||||
from .errors import AuthenticationRequired, CannotConnect
|
||||
@@ -34,9 +33,7 @@ SCENE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_GROUP_NAME): cv.string,
|
||||
vol.Required(ATTR_SCENE_NAME): cv.string,
|
||||
vol.Optional(
|
||||
ATTR_TRANSITION, default=DEFAULT_SCENE_TRANSITION
|
||||
): cv.positive_int,
|
||||
vol.Optional(ATTR_TRANSITION): cv.positive_int,
|
||||
}
|
||||
)
|
||||
# How long should we sleep if the hub is busy
|
||||
@@ -209,7 +206,7 @@ class HueBridge:
|
||||
"""Service to call directly into bridge to set scenes."""
|
||||
group_name = call.data[ATTR_GROUP_NAME]
|
||||
scene_name = call.data[ATTR_SCENE_NAME]
|
||||
transition = call.data.get(ATTR_TRANSITION, DEFAULT_SCENE_TRANSITION)
|
||||
transition = call.data.get(ATTR_TRANSITION)
|
||||
|
||||
group = next(
|
||||
(group for group in self.api.groups.values() if group.name == group_name),
|
||||
|
||||
@@ -14,8 +14,6 @@ DEFAULT_ALLOW_UNREACHABLE = False
|
||||
CONF_ALLOW_HUE_GROUPS = "allow_hue_groups"
|
||||
DEFAULT_ALLOW_HUE_GROUPS = False
|
||||
|
||||
DEFAULT_SCENE_TRANSITION = 4
|
||||
|
||||
GROUP_TYPE_LIGHT_GROUP = "LightGroup"
|
||||
GROUP_TYPE_ROOM = "Room"
|
||||
GROUP_TYPE_LUMINAIRE = "Luminaire"
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"""Support for INSTEON fans via PowerLinc Modem."""
|
||||
import math
|
||||
|
||||
from pyinsteon.constants import FanSpeed
|
||||
|
||||
from homeassistant.components.fan import (
|
||||
DOMAIN as FAN_DOMAIN,
|
||||
SUPPORT_SET_SPEED,
|
||||
@@ -19,7 +17,7 @@ from .const import SIGNAL_ADD_ENTITIES
|
||||
from .insteon_entity import InsteonEntity
|
||||
from .utils import async_add_insteon_entities
|
||||
|
||||
SPEED_RANGE = (1, FanSpeed.HIGH) # off is not included
|
||||
SPEED_RANGE = (0x00, 0xFF) # off is not included
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
@@ -52,6 +50,11 @@ class InsteonFanEntity(InsteonEntity, FanEntity):
|
||||
"""Flag supported features."""
|
||||
return SUPPORT_SET_SPEED
|
||||
|
||||
@property
|
||||
def speed_count(self) -> int:
|
||||
"""Flag supported features."""
|
||||
return 3
|
||||
|
||||
async def async_turn_on(
|
||||
self,
|
||||
speed: str = None,
|
||||
@@ -60,9 +63,7 @@ class InsteonFanEntity(InsteonEntity, FanEntity):
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Turn on the fan."""
|
||||
if percentage is None:
|
||||
percentage = 50
|
||||
await self.async_set_percentage(percentage)
|
||||
await self.async_set_percentage(percentage or 67)
|
||||
|
||||
async def async_turn_off(self, **kwargs) -> None:
|
||||
"""Turn off the fan."""
|
||||
@@ -71,7 +72,7 @@ class InsteonFanEntity(InsteonEntity, FanEntity):
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
"""Set the speed percentage of the fan."""
|
||||
if percentage == 0:
|
||||
await self._insteon_device.async_fan_off()
|
||||
else:
|
||||
on_level = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage))
|
||||
await self._insteon_device.async_fan_on(on_level=on_level)
|
||||
await self.async_turn_off()
|
||||
return
|
||||
on_level = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage))
|
||||
await self._insteon_device.async_on(group=2, on_level=on_level)
|
||||
|
||||
@@ -35,9 +35,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
hub = LitterRobotHub(self.hass, user_input)
|
||||
try:
|
||||
await hub.login()
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_USERNAME], data=user_input
|
||||
)
|
||||
except LitterRobotLoginException:
|
||||
errors["base"] = "invalid_auth"
|
||||
except LitterRobotException:
|
||||
@@ -46,6 +43,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_USERNAME], data=user_input
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
from types import MethodType
|
||||
from typing import Any, Optional
|
||||
|
||||
from pylitterbot import Account, Robot
|
||||
import pylitterbot
|
||||
from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException
|
||||
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
@@ -49,7 +49,7 @@ class LitterRobotHub:
|
||||
async def login(self, load_robots: bool = False):
|
||||
"""Login to Litter-Robot."""
|
||||
self.logged_in = False
|
||||
self.account = Account()
|
||||
self.account = pylitterbot.Account()
|
||||
try:
|
||||
await self.account.connect(
|
||||
username=self._data[CONF_USERNAME],
|
||||
@@ -69,11 +69,11 @@ class LitterRobotHub:
|
||||
class LitterRobotEntity(CoordinatorEntity):
|
||||
"""Generic Litter-Robot entity representing common data and methods."""
|
||||
|
||||
def __init__(self, robot: Robot, entity_type: str, hub: LitterRobotHub):
|
||||
def __init__(self, robot: pylitterbot.Robot, entity_type: str, hub: LitterRobotHub):
|
||||
"""Pass coordinator to CoordinatorEntity."""
|
||||
super().__init__(hub.coordinator)
|
||||
self.robot = robot
|
||||
self.entity_type = entity_type if entity_type else ""
|
||||
self.entity_type = entity_type
|
||||
self.hub = hub
|
||||
|
||||
@property
|
||||
@@ -89,22 +89,21 @@ class LitterRobotEntity(CoordinatorEntity):
|
||||
@property
|
||||
def device_info(self):
|
||||
"""Return the device information for a Litter-Robot."""
|
||||
model = "Litter-Robot 3 Connect"
|
||||
if not self.robot.serial.startswith("LR3C"):
|
||||
model = "Other Litter-Robot Connected Device"
|
||||
return {
|
||||
"identifiers": {(DOMAIN, self.robot.serial)},
|
||||
"name": self.robot.name,
|
||||
"manufacturer": "Litter-Robot",
|
||||
"model": model,
|
||||
"model": self.robot.model,
|
||||
}
|
||||
|
||||
async def perform_action_and_refresh(self, action: MethodType, *args: Any):
|
||||
"""Perform an action and initiates a refresh of the robot data after a few seconds."""
|
||||
|
||||
async def async_call_later_callback(*_) -> None:
|
||||
await self.hub.coordinator.async_request_refresh()
|
||||
|
||||
await action(*args)
|
||||
async_call_later(
|
||||
self.hass, REFRESH_WAIT_TIME, self.hub.coordinator.async_request_refresh
|
||||
)
|
||||
async_call_later(self.hass, REFRESH_WAIT_TIME, async_call_later_callback)
|
||||
|
||||
@staticmethod
|
||||
def parse_time_at_default_timezone(time_str: str) -> Optional[time]:
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"name": "Litter-Robot",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/litterrobot",
|
||||
"requirements": ["pylitterbot==2021.2.5"],
|
||||
"requirements": ["pylitterbot==2021.2.8"],
|
||||
"codeowners": ["@natekspencer"]
|
||||
}
|
||||
|
||||
@@ -1,32 +1,44 @@
|
||||
"""Support for Litter-Robot sensors."""
|
||||
from homeassistant.const import PERCENTAGE
|
||||
from typing import Optional
|
||||
|
||||
from pylitterbot.robot import Robot
|
||||
|
||||
from homeassistant.const import DEVICE_CLASS_TIMESTAMP, PERCENTAGE
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .hub import LitterRobotEntity
|
||||
|
||||
WASTE_DRAWER = "Waste Drawer"
|
||||
from .hub import LitterRobotEntity, LitterRobotHub
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up Litter-Robot sensors using config entry."""
|
||||
hub = hass.data[DOMAIN][config_entry.entry_id]
|
||||
|
||||
entities = []
|
||||
for robot in hub.account.robots:
|
||||
entities.append(LitterRobotSensor(robot, WASTE_DRAWER, hub))
|
||||
|
||||
if entities:
|
||||
async_add_entities(entities, True)
|
||||
def icon_for_gauge_level(gauge_level: Optional[int] = None, offset: int = 0) -> str:
|
||||
"""Return a gauge icon valid identifier."""
|
||||
if gauge_level is None or gauge_level <= 0 + offset:
|
||||
return "mdi:gauge-empty"
|
||||
if gauge_level > 70 + offset:
|
||||
return "mdi:gauge-full"
|
||||
if gauge_level > 30 + offset:
|
||||
return "mdi:gauge"
|
||||
return "mdi:gauge-low"
|
||||
|
||||
|
||||
class LitterRobotSensor(LitterRobotEntity, Entity):
|
||||
"""Litter-Robot sensors."""
|
||||
class LitterRobotPropertySensor(LitterRobotEntity, Entity):
|
||||
"""Litter-Robot property sensors."""
|
||||
|
||||
def __init__(
|
||||
self, robot: Robot, entity_type: str, hub: LitterRobotHub, sensor_attribute: str
|
||||
):
|
||||
"""Pass coordinator to CoordinatorEntity."""
|
||||
super().__init__(robot, entity_type, hub)
|
||||
self.sensor_attribute = sensor_attribute
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state."""
|
||||
return self.robot.waste_drawer_gauge
|
||||
return getattr(self.robot, self.sensor_attribute)
|
||||
|
||||
|
||||
class LitterRobotWasteSensor(LitterRobotPropertySensor, Entity):
|
||||
"""Litter-Robot sensors."""
|
||||
|
||||
@property
|
||||
def unit_of_measurement(self):
|
||||
@@ -36,19 +48,40 @@ class LitterRobotSensor(LitterRobotEntity, Entity):
|
||||
@property
|
||||
def icon(self):
|
||||
"""Return the icon to use in the frontend, if any."""
|
||||
if self.robot.waste_drawer_gauge <= 10:
|
||||
return "mdi:gauge-empty"
|
||||
if self.robot.waste_drawer_gauge < 50:
|
||||
return "mdi:gauge-low"
|
||||
if self.robot.waste_drawer_gauge <= 90:
|
||||
return "mdi:gauge"
|
||||
return "mdi:gauge-full"
|
||||
return icon_for_gauge_level(self.state, 10)
|
||||
|
||||
|
||||
class LitterRobotSleepTimeSensor(LitterRobotPropertySensor, Entity):
|
||||
"""Litter-Robot sleep time sensors."""
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return device specific state attributes."""
|
||||
return {
|
||||
"cycle_count": self.robot.cycle_count,
|
||||
"cycle_capacity": self.robot.cycle_capacity,
|
||||
"cycles_after_drawer_full": self.robot.cycles_after_drawer_full,
|
||||
}
|
||||
def state(self):
|
||||
"""Return the state."""
|
||||
if self.robot.sleep_mode_active:
|
||||
return super().state.isoformat()
|
||||
return None
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the device class, if any."""
|
||||
return DEVICE_CLASS_TIMESTAMP
|
||||
|
||||
|
||||
ROBOT_SENSORS = [
|
||||
(LitterRobotWasteSensor, "Waste Drawer", "waste_drawer_gauge"),
|
||||
(LitterRobotSleepTimeSensor, "Sleep Mode Start Time", "sleep_mode_start_time"),
|
||||
(LitterRobotSleepTimeSensor, "Sleep Mode End Time", "sleep_mode_end_time"),
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up Litter-Robot sensors using config entry."""
|
||||
hub = hass.data[DOMAIN][config_entry.entry_id]
|
||||
|
||||
entities = []
|
||||
for robot in hub.account.robots:
|
||||
for (sensor_class, entity_type, sensor_attribute) in ROBOT_SENSORS:
|
||||
entities.append(sensor_class(robot, entity_type, hub, sensor_attribute))
|
||||
|
||||
if entities:
|
||||
async_add_entities(entities, True)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Support for Litter-Robot switches."""
|
||||
from homeassistant.helpers.entity import ToggleEntity
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .hub import LitterRobotEntity
|
||||
|
||||
|
||||
class LitterRobotNightLightModeSwitch(LitterRobotEntity, ToggleEntity):
|
||||
class LitterRobotNightLightModeSwitch(LitterRobotEntity, SwitchEntity):
|
||||
"""Litter-Robot Night Light Mode Switch."""
|
||||
|
||||
@property
|
||||
@@ -27,7 +27,7 @@ class LitterRobotNightLightModeSwitch(LitterRobotEntity, ToggleEntity):
|
||||
await self.perform_action_and_refresh(self.robot.set_night_light, False)
|
||||
|
||||
|
||||
class LitterRobotPanelLockoutSwitch(LitterRobotEntity, ToggleEntity):
|
||||
class LitterRobotPanelLockoutSwitch(LitterRobotEntity, SwitchEntity):
|
||||
"""Litter-Robot Panel Lockout Switch."""
|
||||
|
||||
@property
|
||||
|
||||
@@ -14,7 +14,6 @@ from homeassistant.components.vacuum import (
|
||||
VacuumEntity,
|
||||
)
|
||||
from homeassistant.const import STATE_OFF
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
from .hub import LitterRobotEntity
|
||||
@@ -54,27 +53,22 @@ class LitterRobotCleaner(LitterRobotEntity, VacuumEntity):
|
||||
def state(self):
|
||||
"""Return the state of the cleaner."""
|
||||
switcher = {
|
||||
Robot.UnitStatus.CCP: STATE_CLEANING,
|
||||
Robot.UnitStatus.EC: STATE_CLEANING,
|
||||
Robot.UnitStatus.CCC: STATE_DOCKED,
|
||||
Robot.UnitStatus.CST: STATE_DOCKED,
|
||||
Robot.UnitStatus.DF1: STATE_DOCKED,
|
||||
Robot.UnitStatus.DF2: STATE_DOCKED,
|
||||
Robot.UnitStatus.RDY: STATE_DOCKED,
|
||||
Robot.UnitStatus.CLEAN_CYCLE: STATE_CLEANING,
|
||||
Robot.UnitStatus.EMPTY_CYCLE: STATE_CLEANING,
|
||||
Robot.UnitStatus.CLEAN_CYCLE_COMPLETE: STATE_DOCKED,
|
||||
Robot.UnitStatus.CAT_SENSOR_TIMING: STATE_DOCKED,
|
||||
Robot.UnitStatus.DRAWER_FULL_1: STATE_DOCKED,
|
||||
Robot.UnitStatus.DRAWER_FULL_2: STATE_DOCKED,
|
||||
Robot.UnitStatus.READY: STATE_DOCKED,
|
||||
Robot.UnitStatus.OFF: STATE_OFF,
|
||||
}
|
||||
|
||||
return switcher.get(self.robot.unit_status, STATE_ERROR)
|
||||
|
||||
@property
|
||||
def error(self):
|
||||
"""Return the error associated with the current state, if any."""
|
||||
return self.robot.unit_status.value
|
||||
|
||||
@property
|
||||
def status(self):
|
||||
"""Return the status of the cleaner."""
|
||||
return f"{self.robot.unit_status.value}{' (Sleeping)' if self.robot.is_sleeping else ''}"
|
||||
return f"{self.robot.unit_status.label}{' (Sleeping)' if self.robot.is_sleeping else ''}"
|
||||
|
||||
async def async_turn_on(self, **kwargs):
|
||||
"""Turn the cleaner on, starting a clean cycle."""
|
||||
@@ -119,22 +113,11 @@ class LitterRobotCleaner(LitterRobotEntity, VacuumEntity):
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return device specific state attributes."""
|
||||
[sleep_mode_start_time, sleep_mode_end_time] = [None, None]
|
||||
|
||||
if self.robot.sleep_mode_active:
|
||||
sleep_mode_start_time = dt_util.as_local(
|
||||
self.robot.sleep_mode_start_time
|
||||
).strftime("%H:%M:00")
|
||||
sleep_mode_end_time = dt_util.as_local(
|
||||
self.robot.sleep_mode_end_time
|
||||
).strftime("%H:%M:00")
|
||||
|
||||
return {
|
||||
"clean_cycle_wait_time_minutes": self.robot.clean_cycle_wait_time_minutes,
|
||||
"is_sleeping": self.robot.is_sleeping,
|
||||
"sleep_mode_start_time": sleep_mode_start_time,
|
||||
"sleep_mode_end_time": sleep_mode_end_time,
|
||||
"sleep_mode_active": self.robot.sleep_mode_active,
|
||||
"power_status": self.robot.power_status,
|
||||
"unit_status_code": self.robot.unit_status.name,
|
||||
"unit_status_code": self.robot.unit_status.value,
|
||||
"last_seen": self.robot.last_seen,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Connect to a MySensors gateway via pymysensors API."""
|
||||
import asyncio
|
||||
from functools import partial
|
||||
import logging
|
||||
from typing import Callable, Dict, List, Optional, Tuple, Type, Union
|
||||
|
||||
@@ -7,11 +8,15 @@ from mysensors import BaseAsyncGateway
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN
|
||||
from homeassistant.components.mqtt import valid_publish_topic, valid_subscribe_topic
|
||||
from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_OPTIMISTIC
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.discovery import async_load_platform
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
|
||||
|
||||
from .const import (
|
||||
@@ -28,21 +33,24 @@ from .const import (
|
||||
CONF_TOPIC_OUT_PREFIX,
|
||||
CONF_VERSION,
|
||||
DOMAIN,
|
||||
MYSENSORS_DISCOVERY,
|
||||
MYSENSORS_GATEWAYS,
|
||||
MYSENSORS_ON_UNLOAD,
|
||||
SUPPORTED_PLATFORMS_WITH_ENTRY_SUPPORT,
|
||||
DevId,
|
||||
GatewayId,
|
||||
SensorType,
|
||||
)
|
||||
from .device import MySensorsDevice, MySensorsEntity, get_mysensors_devices
|
||||
from .gateway import finish_setup, get_mysensors_gateway, gw_stop, setup_gateway
|
||||
from .helpers import on_unload
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_DEBUG = "debug"
|
||||
CONF_NODE_NAME = "name"
|
||||
|
||||
DATA_HASS_CONFIG = "hass_config"
|
||||
|
||||
DEFAULT_BAUD_RATE = 115200
|
||||
DEFAULT_TCP_PORT = 5003
|
||||
DEFAULT_VERSION = "1.4"
|
||||
@@ -134,6 +142,8 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
|
||||
async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool:
|
||||
"""Set up the MySensors component."""
|
||||
hass.data[DOMAIN] = {DATA_HASS_CONFIG: config}
|
||||
|
||||
if DOMAIN not in config or bool(hass.config_entries.async_entries(DOMAIN)):
|
||||
return True
|
||||
|
||||
@@ -181,14 +191,34 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool
|
||||
_LOGGER.error("Gateway setup failed for %s", entry.data)
|
||||
return False
|
||||
|
||||
if DOMAIN not in hass.data:
|
||||
hass.data[DOMAIN] = {}
|
||||
|
||||
if MYSENSORS_GATEWAYS not in hass.data[DOMAIN]:
|
||||
hass.data[DOMAIN][MYSENSORS_GATEWAYS] = {}
|
||||
hass.data[DOMAIN][MYSENSORS_GATEWAYS][entry.entry_id] = gateway
|
||||
|
||||
async def finish():
|
||||
# Connect notify discovery as that integration doesn't support entry forwarding.
|
||||
# Allow loading device tracker platform via discovery
|
||||
# until refactor to config entry is done.
|
||||
|
||||
for platform in (DEVICE_TRACKER_DOMAIN, NOTIFY_DOMAIN):
|
||||
load_discovery_platform = partial(
|
||||
async_load_platform,
|
||||
hass,
|
||||
platform,
|
||||
DOMAIN,
|
||||
hass_config=hass.data[DOMAIN][DATA_HASS_CONFIG],
|
||||
)
|
||||
|
||||
await on_unload(
|
||||
hass,
|
||||
entry.entry_id,
|
||||
async_dispatcher_connect(
|
||||
hass,
|
||||
MYSENSORS_DISCOVERY.format(entry.entry_id, platform),
|
||||
load_discovery_platform,
|
||||
),
|
||||
)
|
||||
|
||||
async def finish() -> None:
|
||||
await asyncio.gather(
|
||||
*[
|
||||
hass.config_entries.async_forward_entry_setup(entry, platform)
|
||||
@@ -223,39 +253,24 @@ async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> boo
|
||||
for fnct in hass.data[DOMAIN][key]:
|
||||
fnct()
|
||||
|
||||
hass.data[DOMAIN].pop(key)
|
||||
|
||||
del hass.data[DOMAIN][MYSENSORS_GATEWAYS][entry.entry_id]
|
||||
|
||||
await gw_stop(hass, entry, gateway)
|
||||
return True
|
||||
|
||||
|
||||
async def on_unload(
|
||||
hass: HomeAssistantType, entry: Union[ConfigEntry, GatewayId], fnct: Callable
|
||||
) -> None:
|
||||
"""Register a callback to be called when entry is unloaded.
|
||||
|
||||
This function is used by platforms to cleanup after themselves
|
||||
"""
|
||||
if isinstance(entry, GatewayId):
|
||||
uniqueid = entry
|
||||
else:
|
||||
uniqueid = entry.entry_id
|
||||
key = MYSENSORS_ON_UNLOAD.format(uniqueid)
|
||||
if key not in hass.data[DOMAIN]:
|
||||
hass.data[DOMAIN][key] = []
|
||||
hass.data[DOMAIN][key].append(fnct)
|
||||
|
||||
|
||||
@callback
|
||||
def setup_mysensors_platform(
|
||||
hass,
|
||||
hass: HomeAssistant,
|
||||
domain: str, # hass platform name
|
||||
discovery_info: Optional[Dict[str, List[DevId]]],
|
||||
discovery_info: Dict[str, List[DevId]],
|
||||
device_class: Union[Type[MySensorsDevice], Dict[SensorType, Type[MySensorsEntity]]],
|
||||
device_args: Optional[
|
||||
Tuple
|
||||
] = None, # extra arguments that will be given to the entity constructor
|
||||
async_add_entities: Callable = None,
|
||||
async_add_entities: Optional[Callable] = None,
|
||||
) -> Optional[List[MySensorsDevice]]:
|
||||
"""Set up a MySensors platform.
|
||||
|
||||
@@ -264,11 +279,6 @@ def setup_mysensors_platform(
|
||||
The function is also given a class.
|
||||
A new instance of the class is created for every device id, and the device id is given to the constructor of the class
|
||||
"""
|
||||
# Only act if called via MySensors by discovery event.
|
||||
# Otherwise gateway is not set up.
|
||||
if not discovery_info:
|
||||
_LOGGER.debug("Skipping setup due to no discovery info")
|
||||
return None
|
||||
if device_args is None:
|
||||
device_args = ()
|
||||
new_devices: List[MySensorsDevice] = []
|
||||
|
||||
@@ -29,7 +29,6 @@ CONF_GATEWAY_TYPE_ALL: List[str] = [
|
||||
|
||||
|
||||
DOMAIN: str = "mysensors"
|
||||
MYSENSORS_GATEWAY_READY: str = "mysensors_gateway_ready_{}"
|
||||
MYSENSORS_GATEWAY_START_TASK: str = "mysensors_gateway_start_task_{}"
|
||||
MYSENSORS_GATEWAYS: str = "mysensors_gateways"
|
||||
PLATFORM: str = "platform"
|
||||
|
||||
@@ -12,6 +12,9 @@ async def async_setup_scanner(
|
||||
hass: HomeAssistantType, config, async_see, discovery_info=None
|
||||
):
|
||||
"""Set up the MySensors device scanner."""
|
||||
if not discovery_info:
|
||||
return False
|
||||
|
||||
new_devices = mysensors.setup_mysensors_platform(
|
||||
hass,
|
||||
DOMAIN,
|
||||
|
||||
@@ -26,17 +26,21 @@ from .const import (
|
||||
CONF_TOPIC_OUT_PREFIX,
|
||||
CONF_VERSION,
|
||||
DOMAIN,
|
||||
MYSENSORS_GATEWAY_READY,
|
||||
MYSENSORS_GATEWAY_START_TASK,
|
||||
MYSENSORS_GATEWAYS,
|
||||
GatewayId,
|
||||
)
|
||||
from .handler import HANDLERS
|
||||
from .helpers import discover_mysensors_platform, validate_child, validate_node
|
||||
from .helpers import (
|
||||
discover_mysensors_platform,
|
||||
on_unload,
|
||||
validate_child,
|
||||
validate_node,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
GATEWAY_READY_TIMEOUT = 15.0
|
||||
GATEWAY_READY_TIMEOUT = 20.0
|
||||
MQTT_COMPONENT = "mqtt"
|
||||
|
||||
|
||||
@@ -64,24 +68,16 @@ async def try_connect(hass: HomeAssistantType, user_input: Dict[str, str]) -> bo
|
||||
if user_input[CONF_DEVICE] == MQTT_COMPONENT:
|
||||
return True # dont validate mqtt. mqtt gateways dont send ready messages :(
|
||||
try:
|
||||
gateway_ready = asyncio.Future()
|
||||
gateway_ready = asyncio.Event()
|
||||
|
||||
def gateway_ready_callback(msg):
|
||||
msg_type = msg.gateway.const.MessageType(msg.type)
|
||||
_LOGGER.debug("Received MySensors msg type %s: %s", msg_type.name, msg)
|
||||
if msg_type.name != "internal":
|
||||
return
|
||||
internal = msg.gateway.const.Internal(msg.sub_type)
|
||||
if internal.name != "I_GATEWAY_READY":
|
||||
return
|
||||
_LOGGER.debug("Received gateway ready")
|
||||
gateway_ready.set_result(True)
|
||||
def on_conn_made(_: BaseAsyncGateway) -> None:
|
||||
gateway_ready.set()
|
||||
|
||||
gateway: Optional[BaseAsyncGateway] = await _get_gateway(
|
||||
hass,
|
||||
device=user_input[CONF_DEVICE],
|
||||
version=user_input[CONF_VERSION],
|
||||
event_callback=gateway_ready_callback,
|
||||
event_callback=lambda _: None,
|
||||
persistence_file=None,
|
||||
baud_rate=user_input.get(CONF_BAUD_RATE),
|
||||
tcp_port=user_input.get(CONF_TCP_PORT),
|
||||
@@ -92,12 +88,13 @@ async def try_connect(hass: HomeAssistantType, user_input: Dict[str, str]) -> bo
|
||||
)
|
||||
if gateway is None:
|
||||
return False
|
||||
gateway.on_conn_made = on_conn_made
|
||||
|
||||
connect_task = None
|
||||
try:
|
||||
connect_task = asyncio.create_task(gateway.start())
|
||||
with async_timeout.timeout(20):
|
||||
await gateway_ready
|
||||
with async_timeout.timeout(GATEWAY_READY_TIMEOUT):
|
||||
await gateway_ready.wait()
|
||||
return True
|
||||
except asyncio.TimeoutError:
|
||||
_LOGGER.info("Try gateway connect failed with timeout")
|
||||
@@ -268,8 +265,8 @@ async def _discover_persistent_devices(
|
||||
|
||||
async def gw_stop(hass, entry: ConfigEntry, gateway: BaseAsyncGateway):
|
||||
"""Stop the gateway."""
|
||||
connect_task = hass.data[DOMAIN].get(
|
||||
MYSENSORS_GATEWAY_START_TASK.format(entry.entry_id)
|
||||
connect_task = hass.data[DOMAIN].pop(
|
||||
MYSENSORS_GATEWAY_START_TASK.format(entry.entry_id), None
|
||||
)
|
||||
if connect_task is not None and not connect_task.done():
|
||||
connect_task.cancel()
|
||||
@@ -280,6 +277,12 @@ async def _gw_start(
|
||||
hass: HomeAssistantType, entry: ConfigEntry, gateway: BaseAsyncGateway
|
||||
):
|
||||
"""Start the gateway."""
|
||||
gateway_ready = asyncio.Event()
|
||||
|
||||
def gateway_connected(_: BaseAsyncGateway):
|
||||
gateway_ready.set()
|
||||
|
||||
gateway.on_conn_made = gateway_connected
|
||||
# Don't use hass.async_create_task to avoid holding up setup indefinitely.
|
||||
hass.data[DOMAIN][
|
||||
MYSENSORS_GATEWAY_START_TASK.format(entry.entry_id)
|
||||
@@ -290,25 +293,24 @@ async def _gw_start(
|
||||
async def stop_this_gw(_: Event):
|
||||
await gw_stop(hass, entry, gateway)
|
||||
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_this_gw)
|
||||
await on_unload(
|
||||
hass,
|
||||
entry.entry_id,
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_this_gw),
|
||||
)
|
||||
|
||||
if entry.data[CONF_DEVICE] == MQTT_COMPONENT:
|
||||
# Gatways connected via mqtt doesn't send gateway ready message.
|
||||
return
|
||||
gateway_ready = asyncio.Future()
|
||||
gateway_ready_key = MYSENSORS_GATEWAY_READY.format(entry.entry_id)
|
||||
hass.data[DOMAIN][gateway_ready_key] = gateway_ready
|
||||
|
||||
try:
|
||||
with async_timeout.timeout(GATEWAY_READY_TIMEOUT):
|
||||
await gateway_ready
|
||||
await gateway_ready.wait()
|
||||
except asyncio.TimeoutError:
|
||||
_LOGGER.warning(
|
||||
"Gateway %s not ready after %s secs so continuing with setup",
|
||||
"Gateway %s not connected after %s secs so continuing with setup",
|
||||
entry.data[CONF_DEVICE],
|
||||
GATEWAY_READY_TIMEOUT,
|
||||
)
|
||||
finally:
|
||||
hass.data[DOMAIN].pop(gateway_ready_key, None)
|
||||
|
||||
|
||||
def _gw_callback_factory(
|
||||
|
||||
@@ -8,14 +8,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
from homeassistant.util import decorator
|
||||
|
||||
from .const import (
|
||||
CHILD_CALLBACK,
|
||||
DOMAIN,
|
||||
MYSENSORS_GATEWAY_READY,
|
||||
NODE_CALLBACK,
|
||||
DevId,
|
||||
GatewayId,
|
||||
)
|
||||
from .const import CHILD_CALLBACK, NODE_CALLBACK, DevId, GatewayId
|
||||
from .device import get_mysensors_devices
|
||||
from .helpers import discover_mysensors_platform, validate_set_msg
|
||||
|
||||
@@ -75,20 +68,6 @@ async def handle_sketch_version(
|
||||
_handle_node_update(hass, gateway_id, msg)
|
||||
|
||||
|
||||
@HANDLERS.register("I_GATEWAY_READY")
|
||||
async def handle_gateway_ready(
|
||||
hass: HomeAssistantType, gateway_id: GatewayId, msg: Message
|
||||
) -> None:
|
||||
"""Handle an internal gateway ready message.
|
||||
|
||||
Set asyncio future result if gateway is ready.
|
||||
"""
|
||||
gateway_ready = hass.data[DOMAIN].get(MYSENSORS_GATEWAY_READY.format(gateway_id))
|
||||
if gateway_ready is None or gateway_ready.cancelled():
|
||||
return
|
||||
gateway_ready.set_result(True)
|
||||
|
||||
|
||||
@callback
|
||||
def _handle_child_update(
|
||||
hass: HomeAssistantType, gateway_id: GatewayId, validated: Dict[str, List[DevId]]
|
||||
|
||||
@@ -2,16 +2,18 @@
|
||||
from collections import defaultdict
|
||||
from enum import IntEnum
|
||||
import logging
|
||||
from typing import DefaultDict, Dict, List, Optional, Set
|
||||
from typing import Callable, DefaultDict, Dict, List, Optional, Set, Union
|
||||
|
||||
from mysensors import BaseAsyncGateway, Message
|
||||
from mysensors.sensor import ChildSensor
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
from homeassistant.util.decorator import Registry
|
||||
|
||||
from .const import (
|
||||
@@ -20,6 +22,7 @@ from .const import (
|
||||
DOMAIN,
|
||||
FLAT_PLATFORM_TYPES,
|
||||
MYSENSORS_DISCOVERY,
|
||||
MYSENSORS_ON_UNLOAD,
|
||||
TYPE_TO_PLATFORMS,
|
||||
DevId,
|
||||
GatewayId,
|
||||
@@ -31,9 +34,26 @@ _LOGGER = logging.getLogger(__name__)
|
||||
SCHEMAS = Registry()
|
||||
|
||||
|
||||
async def on_unload(
|
||||
hass: HomeAssistantType, entry: Union[ConfigEntry, GatewayId], fnct: Callable
|
||||
) -> None:
|
||||
"""Register a callback to be called when entry is unloaded.
|
||||
|
||||
This function is used by platforms to cleanup after themselves.
|
||||
"""
|
||||
if isinstance(entry, GatewayId):
|
||||
uniqueid = entry
|
||||
else:
|
||||
uniqueid = entry.entry_id
|
||||
key = MYSENSORS_ON_UNLOAD.format(uniqueid)
|
||||
if key not in hass.data[DOMAIN]:
|
||||
hass.data[DOMAIN][key] = []
|
||||
hass.data[DOMAIN][key].append(fnct)
|
||||
|
||||
|
||||
@callback
|
||||
def discover_mysensors_platform(
|
||||
hass, gateway_id: GatewayId, platform: str, new_devices: List[DevId]
|
||||
hass: HomeAssistant, gateway_id: GatewayId, platform: str, new_devices: List[DevId]
|
||||
) -> None:
|
||||
"""Discover a MySensors platform."""
|
||||
_LOGGER.debug("Discovering platform %s with devIds: %s", platform, new_devices)
|
||||
|
||||
@@ -2,15 +2,8 @@
|
||||
"domain": "mysensors",
|
||||
"name": "MySensors",
|
||||
"documentation": "https://www.home-assistant.io/integrations/mysensors",
|
||||
"requirements": [
|
||||
"pymysensors==0.20.1"
|
||||
],
|
||||
"after_dependencies": [
|
||||
"mqtt"
|
||||
],
|
||||
"codeowners": [
|
||||
"@MartinHjelmare",
|
||||
"@functionpointer"
|
||||
],
|
||||
"requirements": ["pymysensors==0.21.0"],
|
||||
"after_dependencies": ["mqtt"],
|
||||
"codeowners": ["@MartinHjelmare", "@functionpointer"],
|
||||
"config_flow": true
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ from homeassistant.components.notify import ATTR_TARGET, DOMAIN, BaseNotificatio
|
||||
|
||||
async def async_get_service(hass, config, discovery_info=None):
|
||||
"""Get the MySensors notification service."""
|
||||
if not discovery_info:
|
||||
return None
|
||||
|
||||
new_devices = mysensors.setup_mysensors_platform(
|
||||
hass, DOMAIN, discovery_info, MySensorsNotificationDevice
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components.camera import SUPPORT_STREAM, Camera
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import PlatformNotReady
|
||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
|
||||
@@ -49,15 +50,17 @@ async def async_setup_entry(hass, entry, async_add_entities):
|
||||
|
||||
data_handler = hass.data[DOMAIN][entry.entry_id][DATA_HANDLER]
|
||||
|
||||
await data_handler.register_data_class(
|
||||
CAMERA_DATA_CLASS_NAME, CAMERA_DATA_CLASS_NAME, None
|
||||
)
|
||||
|
||||
if CAMERA_DATA_CLASS_NAME not in data_handler.data:
|
||||
raise PlatformNotReady
|
||||
|
||||
async def get_entities():
|
||||
"""Retrieve Netatmo entities."""
|
||||
await data_handler.register_data_class(
|
||||
CAMERA_DATA_CLASS_NAME, CAMERA_DATA_CLASS_NAME, None
|
||||
)
|
||||
|
||||
data = data_handler.data
|
||||
|
||||
if not data.get(CAMERA_DATA_CLASS_NAME):
|
||||
if not data_handler.data.get(CAMERA_DATA_CLASS_NAME):
|
||||
return []
|
||||
|
||||
data_class = data_handler.data[CAMERA_DATA_CLASS_NAME]
|
||||
@@ -94,24 +97,25 @@ async def async_setup_entry(hass, entry, async_add_entities):
|
||||
|
||||
async_add_entities(await get_entities(), True)
|
||||
|
||||
await data_handler.unregister_data_class(CAMERA_DATA_CLASS_NAME, None)
|
||||
|
||||
platform = entity_platform.current_platform.get()
|
||||
|
||||
if data_handler.data[CAMERA_DATA_CLASS_NAME] is not None:
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_SET_PERSONS_HOME,
|
||||
{vol.Required(ATTR_PERSONS): vol.All(cv.ensure_list, [cv.string])},
|
||||
"_service_set_persons_home",
|
||||
)
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_SET_PERSON_AWAY,
|
||||
{vol.Optional(ATTR_PERSON): cv.string},
|
||||
"_service_set_person_away",
|
||||
)
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_SET_CAMERA_LIGHT,
|
||||
{vol.Required(ATTR_CAMERA_LIGHT_MODE): vol.In(CAMERA_LIGHT_MODES)},
|
||||
"_service_set_camera_light",
|
||||
)
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_SET_PERSONS_HOME,
|
||||
{vol.Required(ATTR_PERSONS): vol.All(cv.ensure_list, [cv.string])},
|
||||
"_service_set_persons_home",
|
||||
)
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_SET_PERSON_AWAY,
|
||||
{vol.Optional(ATTR_PERSON): cv.string},
|
||||
"_service_set_person_away",
|
||||
)
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_SET_CAMERA_LIGHT,
|
||||
{vol.Required(ATTR_CAMERA_LIGHT_MODE): vol.In(CAMERA_LIGHT_MODES)},
|
||||
"_service_set_camera_light",
|
||||
)
|
||||
|
||||
|
||||
class NetatmoCamera(NetatmoBase, Camera):
|
||||
|
||||
@@ -25,6 +25,7 @@ from homeassistant.const import (
|
||||
TEMP_CELSIUS,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import PlatformNotReady
|
||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
||||
from homeassistant.helpers.device_registry import async_get_registry
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
@@ -81,6 +82,7 @@ NETATMO_MAP_PRESET = {
|
||||
STATE_NETATMO_AWAY: PRESET_AWAY,
|
||||
STATE_NETATMO_OFF: STATE_NETATMO_OFF,
|
||||
STATE_NETATMO_MANUAL: STATE_NETATMO_MANUAL,
|
||||
STATE_NETATMO_HOME: PRESET_SCHEDULE,
|
||||
}
|
||||
|
||||
HVAC_MAP_NETATMO = {
|
||||
@@ -111,8 +113,8 @@ async def async_setup_entry(hass, entry, async_add_entities):
|
||||
)
|
||||
home_data = data_handler.data.get(HOMEDATA_DATA_CLASS_NAME)
|
||||
|
||||
if not home_data:
|
||||
return
|
||||
if HOMEDATA_DATA_CLASS_NAME not in data_handler.data:
|
||||
raise PlatformNotReady
|
||||
|
||||
async def get_entities():
|
||||
"""Retrieve Netatmo entities."""
|
||||
@@ -151,6 +153,8 @@ async def async_setup_entry(hass, entry, async_add_entities):
|
||||
|
||||
async_add_entities(await get_entities(), True)
|
||||
|
||||
await data_handler.unregister_data_class(HOMEDATA_DATA_CLASS_NAME, None)
|
||||
|
||||
platform = entity_platform.current_platform.get()
|
||||
|
||||
if home_data is not None:
|
||||
|
||||
@@ -129,7 +129,11 @@ class NetatmoDataHandler:
|
||||
if update_callback:
|
||||
update_callback()
|
||||
|
||||
except (pyatmo.NoDevice, pyatmo.ApiError) as err:
|
||||
except pyatmo.NoDevice as err:
|
||||
_LOGGER.debug(err)
|
||||
self.data[data_class_entry] = None
|
||||
|
||||
except pyatmo.ApiError as err:
|
||||
_LOGGER.debug(err)
|
||||
|
||||
async def register_data_class(
|
||||
|
||||
@@ -31,18 +31,15 @@ async def async_setup_entry(hass, entry, async_add_entities):
|
||||
|
||||
data_handler = hass.data[DOMAIN][entry.entry_id][DATA_HANDLER]
|
||||
|
||||
if CAMERA_DATA_CLASS_NAME not in data_handler.data:
|
||||
raise PlatformNotReady
|
||||
|
||||
async def get_entities():
|
||||
"""Retrieve Netatmo entities."""
|
||||
await data_handler.register_data_class(
|
||||
CAMERA_DATA_CLASS_NAME, CAMERA_DATA_CLASS_NAME, None
|
||||
)
|
||||
|
||||
entities = []
|
||||
all_cameras = []
|
||||
|
||||
if CAMERA_DATA_CLASS_NAME not in data_handler.data:
|
||||
raise PlatformNotReady
|
||||
|
||||
try:
|
||||
for home in data_handler.data[CAMERA_DATA_CLASS_NAME].cameras.values():
|
||||
for camera in home.values():
|
||||
|
||||
@@ -20,6 +20,7 @@ from homeassistant.const import (
|
||||
TEMP_CELSIUS,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import PlatformNotReady
|
||||
from homeassistant.helpers.device_registry import async_entries_for_config_entry
|
||||
from homeassistant.helpers.dispatcher import (
|
||||
async_dispatcher_connect,
|
||||
@@ -129,14 +130,25 @@ async def async_setup_entry(hass, entry, async_add_entities):
|
||||
"""Set up the Netatmo weather and homecoach platform."""
|
||||
data_handler = hass.data[DOMAIN][entry.entry_id][DATA_HANDLER]
|
||||
|
||||
await data_handler.register_data_class(
|
||||
WEATHERSTATION_DATA_CLASS_NAME, WEATHERSTATION_DATA_CLASS_NAME, None
|
||||
)
|
||||
await data_handler.register_data_class(
|
||||
HOMECOACH_DATA_CLASS_NAME, HOMECOACH_DATA_CLASS_NAME, None
|
||||
)
|
||||
|
||||
async def find_entities(data_class_name):
|
||||
"""Find all entities."""
|
||||
await data_handler.register_data_class(data_class_name, data_class_name, None)
|
||||
if data_class_name not in data_handler.data:
|
||||
raise PlatformNotReady
|
||||
|
||||
all_module_infos = {}
|
||||
data = data_handler.data
|
||||
|
||||
if not data.get(data_class_name):
|
||||
if data_class_name not in data:
|
||||
return []
|
||||
|
||||
if data[data_class_name] is None:
|
||||
return []
|
||||
|
||||
data_class = data[data_class_name]
|
||||
@@ -174,6 +186,8 @@ async def async_setup_entry(hass, entry, async_add_entities):
|
||||
NetatmoSensor(data_handler, data_class_name, module, condition)
|
||||
)
|
||||
|
||||
await data_handler.unregister_data_class(data_class_name, None)
|
||||
|
||||
return entities
|
||||
|
||||
for data_class_name in [
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "opentherm_gw",
|
||||
"name": "OpenTherm Gateway",
|
||||
"documentation": "https://www.home-assistant.io/integrations/opentherm_gw",
|
||||
"requirements": ["pyotgw==1.0b1"],
|
||||
"requirements": ["pyotgw==1.1b1"],
|
||||
"codeowners": ["@mvn23"],
|
||||
"config_flow": true
|
||||
}
|
||||
|
||||
@@ -139,7 +139,9 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
|
||||
|
||||
def _convert_forecast(self, entry):
|
||||
forecast = {
|
||||
ATTR_FORECAST_TIME: dt.utc_from_timestamp(entry.reference_time("unix")),
|
||||
ATTR_FORECAST_TIME: dt.utc_from_timestamp(
|
||||
entry.reference_time("unix")
|
||||
).isoformat(),
|
||||
ATTR_FORECAST_PRECIPITATION: self._calc_precipitation(
|
||||
entry.rain, entry.snow
|
||||
),
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
"python-openzwave-mqtt[mqtt-client]==1.4.0"
|
||||
],
|
||||
"after_dependencies": [
|
||||
"mqtt",
|
||||
"zwave"
|
||||
"mqtt"
|
||||
],
|
||||
"codeowners": [
|
||||
"@cgarwood",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Philips TV",
|
||||
"documentation": "https://www.home-assistant.io/integrations/philips_js",
|
||||
"requirements": [
|
||||
"ha-philipsjs==2.3.0"
|
||||
"ha-philipsjs==2.3.1"
|
||||
],
|
||||
"codeowners": [
|
||||
"@elupus"
|
||||
|
||||
@@ -203,25 +203,28 @@ async def async_setup(hass, config):
|
||||
# TCP port when host configured, otherwise serial port
|
||||
port = config[DOMAIN][CONF_PORT]
|
||||
|
||||
# TCP KEEPALIVE will be enabled if value > 0
|
||||
keepalive_idle_timer = config[DOMAIN][CONF_KEEPALIVE_IDLE]
|
||||
if keepalive_idle_timer < 0:
|
||||
_LOGGER.error(
|
||||
"A bogus TCP Keepalive IDLE timer was provided (%d secs), "
|
||||
"default value will be used. "
|
||||
"Recommended values: 60-3600 (seconds)",
|
||||
keepalive_idle_timer,
|
||||
)
|
||||
keepalive_idle_timer = DEFAULT_TCP_KEEPALIVE_IDLE_TIMER
|
||||
elif keepalive_idle_timer == 0:
|
||||
keepalive_idle_timer = None
|
||||
elif keepalive_idle_timer <= 30:
|
||||
_LOGGER.warning(
|
||||
"A very short TCP Keepalive IDLE timer was provided (%d secs), "
|
||||
"and may produce unexpected disconnections from RFlink device."
|
||||
" Recommended values: 60-3600 (seconds)",
|
||||
keepalive_idle_timer,
|
||||
)
|
||||
keepalive_idle_timer = None
|
||||
# TCP KeepAlive only if this is TCP based connection (not serial)
|
||||
if host is not None:
|
||||
# TCP KEEPALIVE will be enabled if value > 0
|
||||
keepalive_idle_timer = config[DOMAIN][CONF_KEEPALIVE_IDLE]
|
||||
if keepalive_idle_timer < 0:
|
||||
_LOGGER.error(
|
||||
"A bogus TCP Keepalive IDLE timer was provided (%d secs), "
|
||||
"it will be disabled. "
|
||||
"Recommended values: 60-3600 (seconds)",
|
||||
keepalive_idle_timer,
|
||||
)
|
||||
keepalive_idle_timer = None
|
||||
elif keepalive_idle_timer == 0:
|
||||
keepalive_idle_timer = None
|
||||
elif keepalive_idle_timer <= 30:
|
||||
_LOGGER.warning(
|
||||
"A very short TCP Keepalive IDLE timer was provided (%d secs) "
|
||||
"and may produce unexpected disconnections from RFlink device."
|
||||
" Recommended values: 60-3600 (seconds)",
|
||||
keepalive_idle_timer,
|
||||
)
|
||||
|
||||
@callback
|
||||
def reconnect(exc=None):
|
||||
|
||||
@@ -177,9 +177,9 @@ def get_device_wrapper(hass: HomeAssistant, device_id: str):
|
||||
return None
|
||||
|
||||
for config_entry in hass.data[DOMAIN][DATA_CONFIG_ENTRY]:
|
||||
wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry][COAP]
|
||||
wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry].get(COAP)
|
||||
|
||||
if wrapper.device_id == device_id:
|
||||
if wrapper and wrapper.device_id == device_id:
|
||||
return wrapper
|
||||
|
||||
return None
|
||||
|
||||
@@ -646,9 +646,12 @@ class SonosEntity(MediaPlayerEntity):
|
||||
update_position = new_status != self._status
|
||||
self._status = new_status
|
||||
|
||||
track_uri = variables["current_track_uri"] if variables else None
|
||||
|
||||
music_source = self.soco.music_source_from_uri(track_uri)
|
||||
if variables:
|
||||
track_uri = variables["current_track_uri"]
|
||||
music_source = self.soco.music_source_from_uri(track_uri)
|
||||
else:
|
||||
# This causes a network round-trip so we avoid it when possible
|
||||
music_source = self.soco.music_source
|
||||
|
||||
if music_source == MUSIC_SRC_TV:
|
||||
self.update_media_linein(SOURCE_TV)
|
||||
|
||||
@@ -12,7 +12,7 @@ from homeassistant.const import (
|
||||
STATE_ON,
|
||||
STATE_UNAVAILABLE,
|
||||
)
|
||||
from homeassistant.core import CALLBACK_TYPE, callback
|
||||
from homeassistant.core import State, callback
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
@@ -37,7 +37,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||
async def async_setup_platform(
|
||||
hass: HomeAssistantType,
|
||||
config: ConfigType,
|
||||
async_add_entities: Callable[[Sequence[Entity], bool], None],
|
||||
async_add_entities: Callable[[Sequence[Entity]], None],
|
||||
discovery_info: Optional[DiscoveryInfoType] = None,
|
||||
) -> None:
|
||||
"""Initialize Light Switch platform."""
|
||||
@@ -53,8 +53,7 @@ async def async_setup_platform(
|
||||
config[CONF_ENTITY_ID],
|
||||
unique_id,
|
||||
)
|
||||
],
|
||||
True,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -66,9 +65,7 @@ class LightSwitch(LightEntity):
|
||||
self._name = name
|
||||
self._switch_entity_id = switch_entity_id
|
||||
self._unique_id = unique_id
|
||||
self._is_on = False
|
||||
self._available = False
|
||||
self._async_unsub_state_changed: Optional[CALLBACK_TYPE] = None
|
||||
self._switch_state: Optional[State] = None
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@@ -78,12 +75,16 @@ class LightSwitch(LightEntity):
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if light switch is on."""
|
||||
return self._is_on
|
||||
assert self._switch_state is not None
|
||||
return self._switch_state.state == STATE_ON
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return true if light switch is on."""
|
||||
return self._available
|
||||
return (
|
||||
self._switch_state is not None
|
||||
and self._switch_state.state != STATE_UNAVAILABLE
|
||||
)
|
||||
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
@@ -117,33 +118,20 @@ class LightSwitch(LightEntity):
|
||||
context=self._context,
|
||||
)
|
||||
|
||||
async def async_update(self):
|
||||
"""Query the switch in this light switch and determine the state."""
|
||||
switch_state = self.hass.states.get(self._switch_entity_id)
|
||||
|
||||
if switch_state is None:
|
||||
self._available = False
|
||||
return
|
||||
|
||||
self._is_on = switch_state.state == STATE_ON
|
||||
self._available = switch_state.state != STATE_UNAVAILABLE
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
assert self.hass is not None
|
||||
self._switch_state = self.hass.states.get(self._switch_entity_id)
|
||||
|
||||
@callback
|
||||
def async_state_changed_listener(*_: Any) -> None:
|
||||
"""Handle child updates."""
|
||||
self.async_schedule_update_ha_state(True)
|
||||
assert self.hass is not None
|
||||
self._switch_state = self.hass.states.get(self._switch_entity_id)
|
||||
self.async_write_ha_state()
|
||||
|
||||
assert self.hass is not None
|
||||
self._async_unsub_state_changed = async_track_state_change_event(
|
||||
self.hass, [self._switch_entity_id], async_state_changed_listener
|
||||
self.async_on_remove(
|
||||
async_track_state_change_event(
|
||||
self.hass, [self._switch_entity_id], async_state_changed_listener
|
||||
)
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self):
|
||||
"""Handle removal from Home Assistant."""
|
||||
if self._async_unsub_state_changed is not None:
|
||||
self._async_unsub_state_changed()
|
||||
self._async_unsub_state_changed = None
|
||||
self._available = False
|
||||
|
||||
@@ -523,7 +523,6 @@ class TemplateFan(TemplateEntity, FanEntity):
|
||||
speed = str(speed)
|
||||
|
||||
if speed in self._speed_list:
|
||||
self._state = STATE_OFF if speed == SPEED_OFF else STATE_ON
|
||||
self._speed = speed
|
||||
self._percentage = self.speed_to_percentage(speed)
|
||||
self._preset_mode = speed if speed in self.preset_modes else None
|
||||
@@ -552,7 +551,6 @@ class TemplateFan(TemplateEntity, FanEntity):
|
||||
return
|
||||
|
||||
if 0 <= percentage <= 100:
|
||||
self._state = STATE_OFF if percentage == 0 else STATE_ON
|
||||
self._percentage = percentage
|
||||
if self._speed_list:
|
||||
self._speed = self.percentage_to_speed(percentage)
|
||||
@@ -569,7 +567,6 @@ class TemplateFan(TemplateEntity, FanEntity):
|
||||
preset_mode = str(preset_mode)
|
||||
|
||||
if preset_mode in self.preset_modes:
|
||||
self._state = STATE_ON
|
||||
self._speed = preset_mode
|
||||
self._percentage = None
|
||||
self._preset_mode = preset_mode
|
||||
|
||||
@@ -13,10 +13,9 @@ from homeassistant.exceptions import (
|
||||
TemplateError,
|
||||
Unauthorized,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv, entity
|
||||
from homeassistant.helpers import config_validation as cv, entity, template
|
||||
from homeassistant.helpers.event import TrackTemplate, async_track_template_result
|
||||
from homeassistant.helpers.service import async_get_all_descriptions
|
||||
from homeassistant.helpers.template import Template
|
||||
from homeassistant.loader import IntegrationNotFound, async_get_integration
|
||||
|
||||
from . import const, decorators, messages
|
||||
@@ -132,6 +131,11 @@ async def handle_call_service(hass, connection, msg):
|
||||
if msg["domain"] == HASS_DOMAIN and msg["service"] in ["restart", "stop"]:
|
||||
blocking = False
|
||||
|
||||
# We do not support templates.
|
||||
target = msg.get("target")
|
||||
if template.is_complex(target):
|
||||
raise vol.Invalid("Templates are not supported here")
|
||||
|
||||
try:
|
||||
context = connection.context(msg)
|
||||
await hass.services.async_call(
|
||||
@@ -140,7 +144,7 @@ async def handle_call_service(hass, connection, msg):
|
||||
msg.get("service_data"),
|
||||
blocking,
|
||||
context,
|
||||
target=msg.get("target"),
|
||||
target=target,
|
||||
)
|
||||
connection.send_message(
|
||||
messages.result_message(msg["id"], {"context": context})
|
||||
@@ -256,14 +260,14 @@ def handle_ping(hass, connection, msg):
|
||||
async def handle_render_template(hass, connection, msg):
|
||||
"""Handle render_template command."""
|
||||
template_str = msg["template"]
|
||||
template = Template(template_str, hass)
|
||||
template_obj = template.Template(template_str, hass)
|
||||
variables = msg.get("variables")
|
||||
timeout = msg.get("timeout")
|
||||
info = None
|
||||
|
||||
if timeout:
|
||||
try:
|
||||
timed_out = await template.async_render_will_timeout(timeout)
|
||||
timed_out = await template_obj.async_render_will_timeout(timeout)
|
||||
except TemplateError as ex:
|
||||
connection.send_error(msg["id"], const.ERR_TEMPLATE_ERROR, str(ex))
|
||||
return
|
||||
@@ -294,7 +298,7 @@ async def handle_render_template(hass, connection, msg):
|
||||
try:
|
||||
info = async_track_template_result(
|
||||
hass,
|
||||
[TrackTemplate(template, variables)],
|
||||
[TrackTemplate(template_obj, variables)],
|
||||
_template_listener,
|
||||
raise_on_template_error=True,
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Support for WeMo device discovery."""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import pywemo
|
||||
@@ -15,14 +16,9 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.util.async_ import gather_with_concurrency
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
# Max number of devices to initialize at once. This limit is in place to
|
||||
# avoid tying up too many executor threads with WeMo device setup.
|
||||
MAX_CONCURRENCY = 3
|
||||
|
||||
# Mapping from Wemo model_name to domain.
|
||||
WEMO_MODEL_DISPATCH = {
|
||||
"Bridge": LIGHT_DOMAIN,
|
||||
@@ -118,12 +114,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||
static_conf = config.get(CONF_STATIC, [])
|
||||
if static_conf:
|
||||
_LOGGER.debug("Adding statically configured WeMo devices...")
|
||||
for device in await gather_with_concurrency(
|
||||
MAX_CONCURRENCY,
|
||||
for device in await asyncio.gather(
|
||||
*[
|
||||
hass.async_add_executor_job(validate_static_config, host, port)
|
||||
for host, port in static_conf
|
||||
],
|
||||
]
|
||||
):
|
||||
if device:
|
||||
wemo_dispatcher.async_add_unique_device(hass, device)
|
||||
@@ -192,44 +187,15 @@ class WemoDiscovery:
|
||||
self._wemo_dispatcher = wemo_dispatcher
|
||||
self._stop = None
|
||||
self._scan_delay = 0
|
||||
self._upnp_entries = set()
|
||||
|
||||
async def async_add_from_upnp_entry(self, entry: pywemo.ssdp.UPNPEntry) -> None:
|
||||
"""Create a WeMoDevice from an UPNPEntry and add it to the dispatcher.
|
||||
|
||||
Uses the self._upnp_entries set to avoid interrogating the same device
|
||||
multiple times.
|
||||
"""
|
||||
if entry in self._upnp_entries:
|
||||
return
|
||||
try:
|
||||
device = await self._hass.async_add_executor_job(
|
||||
pywemo.discovery.device_from_uuid_and_location,
|
||||
entry.udn,
|
||||
entry.location,
|
||||
)
|
||||
except pywemo.PyWeMoException as err:
|
||||
_LOGGER.error("Unable to setup WeMo %r (%s)", entry, err)
|
||||
else:
|
||||
self._wemo_dispatcher.async_add_unique_device(self._hass, device)
|
||||
self._upnp_entries.add(entry)
|
||||
|
||||
async def async_discover_and_schedule(self, *_) -> None:
|
||||
"""Periodically scan the network looking for WeMo devices."""
|
||||
_LOGGER.debug("Scanning network for WeMo devices...")
|
||||
try:
|
||||
# pywemo.ssdp.scan is a light-weight UDP UPnP scan for WeMo devices.
|
||||
entries = await self._hass.async_add_executor_job(pywemo.ssdp.scan)
|
||||
|
||||
# async_add_from_upnp_entry causes multiple HTTP requests to be sent
|
||||
# to the WeMo device for the initial setup of the WeMoDevice
|
||||
# instance. This may take some time to complete. The per-device
|
||||
# setup work is done in parallel to speed up initial setup for the
|
||||
# component.
|
||||
await gather_with_concurrency(
|
||||
MAX_CONCURRENCY,
|
||||
*[self.async_add_from_upnp_entry(entry) for entry in entries],
|
||||
)
|
||||
for device in await self._hass.async_add_executor_job(
|
||||
pywemo.discover_devices
|
||||
):
|
||||
self._wemo_dispatcher.async_add_unique_device(self._hass, device)
|
||||
finally:
|
||||
# Run discovery more frequently after hass has just started.
|
||||
self._scan_delay = min(
|
||||
|
||||
@@ -23,7 +23,7 @@ from .gateway import ConnectXiaomiGateway
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
GATEWAY_PLATFORMS = ["alarm_control_panel", "sensor", "light"]
|
||||
GATEWAY_PLATFORMS = ["alarm_control_panel", "sensor", "switch", "light"]
|
||||
SWITCH_PLATFORMS = ["switch"]
|
||||
VACUUM_PLATFORMS = ["vacuum"]
|
||||
|
||||
|
||||
@@ -21,9 +21,8 @@ MODELS_SWITCH = [
|
||||
"chuangmi.plug.v2",
|
||||
"chuangmi.plug.hmi205",
|
||||
"chuangmi.plug.hmi206",
|
||||
"lumi.acpartner.v3",
|
||||
]
|
||||
MODELS_VACUUM = ["roborock.vacuum"]
|
||||
MODELS_VACUUM = ["roborock.vacuum", "rockrobo.vacuum"]
|
||||
|
||||
MODELS_ALL_DEVICES = MODELS_SWITCH + MODELS_VACUUM
|
||||
MODELS_ALL = MODELS_ALL_DEVICES + MODELS_GATEWAY
|
||||
|
||||
@@ -22,7 +22,6 @@ from homeassistant.const import (
|
||||
DEVICE_CLASS_ILLUMINANCE,
|
||||
DEVICE_CLASS_PRESSURE,
|
||||
DEVICE_CLASS_TEMPERATURE,
|
||||
LIGHT_LUX,
|
||||
PERCENTAGE,
|
||||
PRESSURE_HPA,
|
||||
TEMP_CELSIUS,
|
||||
@@ -38,6 +37,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_NAME = "Xiaomi Miio Sensor"
|
||||
DATA_KEY = "sensor.xiaomi_miio"
|
||||
UNIT_LUMEN = "lm"
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
@@ -302,7 +302,7 @@ class XiaomiGatewayIlluminanceSensor(Entity):
|
||||
@property
|
||||
def unit_of_measurement(self):
|
||||
"""Return the unit of measurement of this entity."""
|
||||
return LIGHT_LUX
|
||||
return UNIT_LUMEN
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
|
||||
@@ -22,6 +22,7 @@ import homeassistant.helpers.config_validation as cv
|
||||
from .const import (
|
||||
CONF_DEVICE,
|
||||
CONF_FLOW_TYPE,
|
||||
CONF_GATEWAY,
|
||||
CONF_MODEL,
|
||||
DOMAIN,
|
||||
SERVICE_SET_POWER_MODE,
|
||||
@@ -129,16 +130,19 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up the switch from a config entry."""
|
||||
entities = []
|
||||
|
||||
if config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE:
|
||||
host = config_entry.data[CONF_HOST]
|
||||
token = config_entry.data[CONF_TOKEN]
|
||||
name = config_entry.title
|
||||
model = config_entry.data[CONF_MODEL]
|
||||
unique_id = config_entry.unique_id
|
||||
|
||||
if config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE or (
|
||||
config_entry.data[CONF_FLOW_TYPE] == CONF_GATEWAY
|
||||
and model == "lumi.acpartner.v3"
|
||||
):
|
||||
if DATA_KEY not in hass.data:
|
||||
hass.data[DATA_KEY] = {}
|
||||
|
||||
host = config_entry.data[CONF_HOST]
|
||||
token = config_entry.data[CONF_TOKEN]
|
||||
name = config_entry.title
|
||||
model = config_entry.data[CONF_MODEL]
|
||||
unique_id = config_entry.unique_id
|
||||
|
||||
_LOGGER.debug("Initializing with host %s (token %s...)", host, token[:5])
|
||||
|
||||
if model in ["chuangmi.plug.v1", "chuangmi.plug.v3", "chuangmi.plug.hmi208"]:
|
||||
|
||||
@@ -48,7 +48,8 @@ from .const import (
|
||||
ZWAVE_JS_EVENT,
|
||||
)
|
||||
from .discovery import async_discover_values
|
||||
from .helpers import get_device_id, get_old_value_id, get_unique_id
|
||||
from .helpers import get_device_id
|
||||
from .migrate import async_migrate_discovered_value
|
||||
from .services import ZWaveServices
|
||||
|
||||
CONNECT_TIMEOUT = 10
|
||||
@@ -98,31 +99,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
dev_reg = await device_registry.async_get_registry(hass)
|
||||
ent_reg = entity_registry.async_get(hass)
|
||||
|
||||
@callback
|
||||
def migrate_entity(platform: str, old_unique_id: str, new_unique_id: str) -> None:
|
||||
"""Check if entity with old unique ID exists, and if so migrate it to new ID."""
|
||||
if entity_id := ent_reg.async_get_entity_id(platform, DOMAIN, old_unique_id):
|
||||
LOGGER.debug(
|
||||
"Migrating entity %s from old unique ID '%s' to new unique ID '%s'",
|
||||
entity_id,
|
||||
old_unique_id,
|
||||
new_unique_id,
|
||||
)
|
||||
try:
|
||||
ent_reg.async_update_entity(
|
||||
entity_id,
|
||||
new_unique_id=new_unique_id,
|
||||
)
|
||||
except ValueError:
|
||||
LOGGER.debug(
|
||||
(
|
||||
"Entity %s can't be migrated because the unique ID is taken. "
|
||||
"Cleaning it up since it is likely no longer valid."
|
||||
),
|
||||
entity_id,
|
||||
)
|
||||
ent_reg.async_remove(entity_id)
|
||||
|
||||
@callback
|
||||
def async_on_node_ready(node: ZwaveNode) -> None:
|
||||
"""Handle node ready event."""
|
||||
@@ -136,49 +112,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
LOGGER.debug("Discovered entity: %s", disc_info)
|
||||
|
||||
# This migration logic was added in 2021.3 to handle a breaking change to
|
||||
# the value_id format. Some time in the future, this code block
|
||||
# (as well as get_old_value_id helper and migrate_entity closure) can be
|
||||
# removed.
|
||||
value_ids = [
|
||||
# 2021.2.* format
|
||||
get_old_value_id(disc_info.primary_value),
|
||||
# 2021.3.0b0 format
|
||||
disc_info.primary_value.value_id,
|
||||
]
|
||||
|
||||
new_unique_id = get_unique_id(
|
||||
client.driver.controller.home_id,
|
||||
disc_info.primary_value.value_id,
|
||||
)
|
||||
|
||||
for value_id in value_ids:
|
||||
old_unique_id = get_unique_id(
|
||||
client.driver.controller.home_id,
|
||||
f"{disc_info.primary_value.node.node_id}.{value_id}",
|
||||
)
|
||||
# Most entities have the same ID format, but notification binary sensors
|
||||
# have a state key in their ID so we need to handle them differently
|
||||
if (
|
||||
disc_info.platform == "binary_sensor"
|
||||
and disc_info.platform_hint == "notification"
|
||||
):
|
||||
for state_key in disc_info.primary_value.metadata.states:
|
||||
# ignore idle key (0)
|
||||
if state_key == "0":
|
||||
continue
|
||||
|
||||
migrate_entity(
|
||||
disc_info.platform,
|
||||
f"{old_unique_id}.{state_key}",
|
||||
f"{new_unique_id}.{state_key}",
|
||||
)
|
||||
|
||||
# Once we've iterated through all state keys, we can move on to the
|
||||
# next item
|
||||
continue
|
||||
|
||||
migrate_entity(disc_info.platform, old_unique_id, new_unique_id)
|
||||
|
||||
# the value_id format. Some time in the future, this call (as well as the
|
||||
# helper functions) can be removed.
|
||||
async_migrate_discovered_value(ent_reg, client, disc_info)
|
||||
async_dispatcher_send(
|
||||
hass, f"{DOMAIN}_{entry.entry_id}_add_{disc_info.platform}", disc_info
|
||||
)
|
||||
@@ -483,11 +419,15 @@ async def async_ensure_addon_running(hass: HomeAssistant, entry: ConfigEntry) ->
|
||||
network_key: str = entry.data[CONF_NETWORK_KEY]
|
||||
|
||||
if not addon_is_installed:
|
||||
addon_manager.async_schedule_install_addon(usb_path, network_key)
|
||||
addon_manager.async_schedule_install_setup_addon(
|
||||
usb_path, network_key, catch_error=True
|
||||
)
|
||||
raise ConfigEntryNotReady
|
||||
|
||||
if not addon_is_running:
|
||||
addon_manager.async_schedule_setup_addon(usb_path, network_key)
|
||||
addon_manager.async_schedule_setup_addon(
|
||||
usb_path, network_key, catch_error=True
|
||||
)
|
||||
raise ConfigEntryNotReady
|
||||
|
||||
|
||||
@@ -497,4 +437,4 @@ def async_ensure_addon_updated(hass: HomeAssistant) -> None:
|
||||
addon_manager: AddonManager = get_addon_manager(hass)
|
||||
if addon_manager.task_in_progress():
|
||||
raise ConfigEntryNotReady
|
||||
addon_manager.async_schedule_update_addon()
|
||||
addon_manager.async_schedule_update_addon(catch_error=True)
|
||||
|
||||
@@ -67,8 +67,8 @@ class AddonManager:
|
||||
"""Set up the add-on manager."""
|
||||
self._hass = hass
|
||||
self._install_task: Optional[asyncio.Task] = None
|
||||
self._start_task: Optional[asyncio.Task] = None
|
||||
self._update_task: Optional[asyncio.Task] = None
|
||||
self._setup_task: Optional[asyncio.Task] = None
|
||||
|
||||
def task_in_progress(self) -> bool:
|
||||
"""Return True if any of the add-on tasks are in progress."""
|
||||
@@ -76,7 +76,7 @@ class AddonManager:
|
||||
task and not task.done()
|
||||
for task in (
|
||||
self._install_task,
|
||||
self._setup_task,
|
||||
self._start_task,
|
||||
self._update_task,
|
||||
)
|
||||
)
|
||||
@@ -125,8 +125,21 @@ class AddonManager:
|
||||
await async_install_addon(self._hass, ADDON_SLUG)
|
||||
|
||||
@callback
|
||||
def async_schedule_install_addon(
|
||||
self, usb_path: str, network_key: str
|
||||
def async_schedule_install_addon(self, catch_error: bool = False) -> asyncio.Task:
|
||||
"""Schedule a task that installs the Z-Wave JS add-on.
|
||||
|
||||
Only schedule a new install task if the there's no running task.
|
||||
"""
|
||||
if not self._install_task or self._install_task.done():
|
||||
LOGGER.info("Z-Wave JS add-on is not installed. Installing add-on")
|
||||
self._install_task = self._async_schedule_addon_operation(
|
||||
self.async_install_addon, catch_error=catch_error
|
||||
)
|
||||
return self._install_task
|
||||
|
||||
@callback
|
||||
def async_schedule_install_setup_addon(
|
||||
self, usb_path: str, network_key: str, catch_error: bool = False
|
||||
) -> asyncio.Task:
|
||||
"""Schedule a task that installs and sets up the Z-Wave JS add-on.
|
||||
|
||||
@@ -136,7 +149,9 @@ class AddonManager:
|
||||
LOGGER.info("Z-Wave JS add-on is not installed. Installing add-on")
|
||||
self._install_task = self._async_schedule_addon_operation(
|
||||
self.async_install_addon,
|
||||
partial(self.async_setup_addon, usb_path, network_key),
|
||||
partial(self.async_configure_addon, usb_path, network_key),
|
||||
self.async_start_addon,
|
||||
catch_error=catch_error,
|
||||
)
|
||||
return self._install_task
|
||||
|
||||
@@ -158,10 +173,11 @@ class AddonManager:
|
||||
if not update_available:
|
||||
return
|
||||
|
||||
await self.async_create_snapshot()
|
||||
await async_update_addon(self._hass, ADDON_SLUG)
|
||||
|
||||
@callback
|
||||
def async_schedule_update_addon(self) -> asyncio.Task:
|
||||
def async_schedule_update_addon(self, catch_error: bool = False) -> asyncio.Task:
|
||||
"""Schedule a task that updates and sets up the Z-Wave JS add-on.
|
||||
|
||||
Only schedule a new update task if the there's no running task.
|
||||
@@ -169,7 +185,8 @@ class AddonManager:
|
||||
if not self._update_task or self._update_task.done():
|
||||
LOGGER.info("Trying to update the Z-Wave JS add-on")
|
||||
self._update_task = self._async_schedule_addon_operation(
|
||||
self.async_create_snapshot, self.async_update_addon
|
||||
self.async_update_addon,
|
||||
catch_error=catch_error,
|
||||
)
|
||||
return self._update_task
|
||||
|
||||
@@ -178,12 +195,25 @@ class AddonManager:
|
||||
"""Start the Z-Wave JS add-on."""
|
||||
await async_start_addon(self._hass, ADDON_SLUG)
|
||||
|
||||
@callback
|
||||
def async_schedule_start_addon(self, catch_error: bool = False) -> asyncio.Task:
|
||||
"""Schedule a task that starts the Z-Wave JS add-on.
|
||||
|
||||
Only schedule a new start task if the there's no running task.
|
||||
"""
|
||||
if not self._start_task or self._start_task.done():
|
||||
LOGGER.info("Z-Wave JS add-on is not running. Starting add-on")
|
||||
self._start_task = self._async_schedule_addon_operation(
|
||||
self.async_start_addon, catch_error=catch_error
|
||||
)
|
||||
return self._start_task
|
||||
|
||||
@api_error("Failed to stop the Z-Wave JS add-on")
|
||||
async def async_stop_addon(self) -> None:
|
||||
"""Stop the Z-Wave JS add-on."""
|
||||
await async_stop_addon(self._hass, ADDON_SLUG)
|
||||
|
||||
async def async_setup_addon(self, usb_path: str, network_key: str) -> None:
|
||||
async def async_configure_addon(self, usb_path: str, network_key: str) -> None:
|
||||
"""Configure and start Z-Wave JS add-on."""
|
||||
addon_options = await self.async_get_addon_options()
|
||||
|
||||
@@ -195,22 +225,22 @@ class AddonManager:
|
||||
if new_addon_options != addon_options:
|
||||
await self.async_set_addon_options(new_addon_options)
|
||||
|
||||
await self.async_start_addon()
|
||||
|
||||
@callback
|
||||
def async_schedule_setup_addon(
|
||||
self, usb_path: str, network_key: str
|
||||
self, usb_path: str, network_key: str, catch_error: bool = False
|
||||
) -> asyncio.Task:
|
||||
"""Schedule a task that configures and starts the Z-Wave JS add-on.
|
||||
|
||||
Only schedule a new setup task if the there's no running task.
|
||||
"""
|
||||
if not self._setup_task or self._setup_task.done():
|
||||
if not self._start_task or self._start_task.done():
|
||||
LOGGER.info("Z-Wave JS add-on is not running. Starting add-on")
|
||||
self._setup_task = self._async_schedule_addon_operation(
|
||||
partial(self.async_setup_addon, usb_path, network_key)
|
||||
self._start_task = self._async_schedule_addon_operation(
|
||||
partial(self.async_configure_addon, usb_path, network_key),
|
||||
self.async_start_addon,
|
||||
catch_error=catch_error,
|
||||
)
|
||||
return self._setup_task
|
||||
return self._start_task
|
||||
|
||||
@api_error("Failed to create a snapshot of the Z-Wave JS add-on.")
|
||||
async def async_create_snapshot(self) -> None:
|
||||
@@ -227,7 +257,9 @@ class AddonManager:
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_schedule_addon_operation(self, *funcs: Callable) -> asyncio.Task:
|
||||
def _async_schedule_addon_operation(
|
||||
self, *funcs: Callable, catch_error: bool = False
|
||||
) -> asyncio.Task:
|
||||
"""Schedule an add-on task."""
|
||||
|
||||
async def addon_operation() -> None:
|
||||
@@ -236,6 +268,8 @@ class AddonManager:
|
||||
try:
|
||||
await func()
|
||||
except AddonError as err:
|
||||
if not catch_error:
|
||||
raise
|
||||
LOGGER.error(err)
|
||||
break
|
||||
|
||||
|
||||
@@ -118,25 +118,17 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity):
|
||||
super().__init__(config_entry, client, info)
|
||||
self._hvac_modes: Dict[str, Optional[int]] = {}
|
||||
self._hvac_presets: Dict[str, Optional[int]] = {}
|
||||
self._unit_value: ZwaveValue = None
|
||||
self._unit_value: Optional[ZwaveValue] = None
|
||||
|
||||
self._current_mode = self.get_zwave_value(
|
||||
THERMOSTAT_MODE_PROPERTY, command_class=CommandClass.THERMOSTAT_MODE
|
||||
)
|
||||
self._setpoint_values: Dict[ThermostatSetpointType, ZwaveValue] = {}
|
||||
for enum in ThermostatSetpointType:
|
||||
# Some devices don't include a property key so we need to check for value
|
||||
# ID's, both with and without the property key
|
||||
self._setpoint_values[enum] = self.get_zwave_value(
|
||||
THERMOSTAT_SETPOINT_PROPERTY,
|
||||
command_class=CommandClass.THERMOSTAT_SETPOINT,
|
||||
value_property_key=enum.value.key,
|
||||
value_property_key_name=enum.value.name,
|
||||
add_to_watched_value_ids=True,
|
||||
) or self.get_zwave_value(
|
||||
THERMOSTAT_SETPOINT_PROPERTY,
|
||||
command_class=CommandClass.THERMOSTAT_SETPOINT,
|
||||
value_property_key_name=enum.value.name,
|
||||
add_to_watched_value_ids=True,
|
||||
)
|
||||
# Use the first found setpoint value to always determine the temperature unit
|
||||
@@ -170,6 +162,15 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity):
|
||||
add_to_watched_value_ids=True,
|
||||
)
|
||||
self._set_modes_and_presets()
|
||||
self._supported_features = SUPPORT_PRESET_MODE
|
||||
# If any setpoint value exists, we can assume temperature
|
||||
# can be set
|
||||
if any(self._setpoint_values.values()):
|
||||
self._supported_features |= SUPPORT_TARGET_TEMPERATURE
|
||||
if HVAC_MODE_HEAT_COOL in self.hvac_modes:
|
||||
self._supported_features |= SUPPORT_TARGET_TEMPERATURE_RANGE
|
||||
if self._fan_mode:
|
||||
self._supported_features |= SUPPORT_FAN_MODE
|
||||
|
||||
def _setpoint_value(self, setpoint_type: ThermostatSetpointType) -> ZwaveValue:
|
||||
"""Optionally return a ZwaveValue for a setpoint."""
|
||||
@@ -215,7 +216,11 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity):
|
||||
@property
|
||||
def temperature_unit(self) -> str:
|
||||
"""Return the unit of measurement used by the platform."""
|
||||
if "f" in self._unit_value.metadata.unit.lower():
|
||||
if (
|
||||
self._unit_value
|
||||
and self._unit_value.metadata.unit
|
||||
and "f" in self._unit_value.metadata.unit.lower()
|
||||
):
|
||||
return TEMP_FAHRENHEIT
|
||||
return TEMP_CELSIUS
|
||||
|
||||
@@ -263,7 +268,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity):
|
||||
return None
|
||||
try:
|
||||
temp = self._setpoint_value(self._current_mode_setpoint_enums[0])
|
||||
except ValueError:
|
||||
except (IndexError, ValueError):
|
||||
return None
|
||||
return temp.value if temp else None
|
||||
|
||||
@@ -275,7 +280,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity):
|
||||
return None
|
||||
try:
|
||||
temp = self._setpoint_value(self._current_mode_setpoint_enums[1])
|
||||
except ValueError:
|
||||
except (IndexError, ValueError):
|
||||
return None
|
||||
return temp.value if temp else None
|
||||
|
||||
@@ -339,14 +344,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity):
|
||||
@property
|
||||
def supported_features(self) -> int:
|
||||
"""Return the list of supported features."""
|
||||
support = SUPPORT_PRESET_MODE
|
||||
if len(self._current_mode_setpoint_enums) == 1:
|
||||
support |= SUPPORT_TARGET_TEMPERATURE
|
||||
if len(self._current_mode_setpoint_enums) > 1:
|
||||
support |= SUPPORT_TARGET_TEMPERATURE_RANGE
|
||||
if self._fan_mode:
|
||||
support |= SUPPORT_FAN_MODE
|
||||
return support
|
||||
return self._supported_features
|
||||
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Set new target fan mode."""
|
||||
|
||||
@@ -117,7 +117,15 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
await self.async_set_unique_id(
|
||||
version_info.home_id, raise_on_progress=False
|
||||
)
|
||||
self._abort_if_unique_id_configured(user_input)
|
||||
# Make sure we disable any add-on handling
|
||||
# if the controller is reconfigured in a manual step.
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={
|
||||
**user_input,
|
||||
CONF_USE_ADDON: False,
|
||||
CONF_INTEGRATION_CREATED_ADDON: False,
|
||||
}
|
||||
)
|
||||
self.ws_address = user_input[CONF_URL]
|
||||
return self._async_create_entry_from_vars()
|
||||
|
||||
@@ -172,11 +180,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
self, user_input: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Handle logic when on Supervisor host."""
|
||||
# Only one entry with Supervisor add-on support is allowed.
|
||||
for entry in self.hass.config_entries.async_entries(DOMAIN):
|
||||
if entry.data.get(CONF_USE_ADDON):
|
||||
return await self.async_step_manual()
|
||||
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="on_supervisor", data_schema=ON_SUPERVISOR_SCHEMA
|
||||
@@ -289,7 +292,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
assert self.hass
|
||||
addon_manager: AddonManager = get_addon_manager(self.hass)
|
||||
try:
|
||||
await addon_manager.async_start_addon()
|
||||
await addon_manager.async_schedule_start_addon()
|
||||
# Sleep some seconds to let the add-on start properly before connecting.
|
||||
for _ in range(ADDON_SETUP_TIMEOUT_ROUNDS):
|
||||
await asyncio.sleep(ADDON_SETUP_TIMEOUT)
|
||||
@@ -338,7 +341,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
version_info.home_id, raise_on_progress=False
|
||||
)
|
||||
|
||||
self._abort_if_unique_id_configured()
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={
|
||||
CONF_URL: self.ws_address,
|
||||
CONF_USB_PATH: self.usb_path,
|
||||
CONF_NETWORK_KEY: self.network_key,
|
||||
}
|
||||
)
|
||||
return self._async_create_entry_from_vars()
|
||||
|
||||
async def _async_get_addon_info(self) -> dict:
|
||||
@@ -381,7 +390,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Install the Z-Wave JS add-on."""
|
||||
addon_manager: AddonManager = get_addon_manager(self.hass)
|
||||
try:
|
||||
await addon_manager.async_install_addon()
|
||||
await addon_manager.async_schedule_install_addon()
|
||||
finally:
|
||||
# Continue the flow after show progress when the task is done.
|
||||
self.hass.async_create_task(
|
||||
|
||||
@@ -105,7 +105,11 @@ class ZWaveBaseEntity(Entity):
|
||||
"""Generate entity name."""
|
||||
if additional_info is None:
|
||||
additional_info = []
|
||||
name: str = self.info.node.name or self.info.node.device_config.description
|
||||
name: str = (
|
||||
self.info.node.name
|
||||
or self.info.node.device_config.description
|
||||
or f"Node {self.info.node.node_id}"
|
||||
)
|
||||
if include_value_name:
|
||||
value_name = (
|
||||
alternate_value_name
|
||||
@@ -169,7 +173,6 @@ class ZWaveBaseEntity(Entity):
|
||||
command_class: Optional[int] = None,
|
||||
endpoint: Optional[int] = None,
|
||||
value_property_key: Optional[int] = None,
|
||||
value_property_key_name: Optional[str] = None,
|
||||
add_to_watched_value_ids: bool = True,
|
||||
check_all_endpoints: bool = False,
|
||||
) -> Optional[ZwaveValue]:
|
||||
@@ -188,7 +191,6 @@ class ZWaveBaseEntity(Entity):
|
||||
value_property,
|
||||
endpoint=endpoint,
|
||||
property_key=value_property_key,
|
||||
property_key_name=value_property_key_name,
|
||||
)
|
||||
return_value = self.info.node.values.get(value_id)
|
||||
|
||||
@@ -203,7 +205,6 @@ class ZWaveBaseEntity(Entity):
|
||||
value_property,
|
||||
endpoint=endpoint_.index,
|
||||
property_key=value_property_key,
|
||||
property_key_name=value_property_key_name,
|
||||
)
|
||||
return_value = self.info.node.values.get(value_id)
|
||||
if return_value:
|
||||
|
||||
@@ -3,7 +3,6 @@ from typing import List, Tuple, cast
|
||||
|
||||
from zwave_js_server.client import Client as ZwaveClient
|
||||
from zwave_js_server.model.node import Node as ZwaveNode
|
||||
from zwave_js_server.model.value import Value as ZwaveValue
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -13,16 +12,6 @@ from homeassistant.helpers.entity_registry import async_get as async_get_ent_reg
|
||||
from .const import DATA_CLIENT, DOMAIN
|
||||
|
||||
|
||||
@callback
|
||||
def get_old_value_id(value: ZwaveValue) -> str:
|
||||
"""Get old value ID so we can migrate entity unique ID."""
|
||||
command_class = value.command_class
|
||||
endpoint = value.endpoint or "00"
|
||||
property_ = value.property_
|
||||
property_key_name = value.property_key_name or "00"
|
||||
return f"{value.node.node_id}-{command_class}-{endpoint}-{property_}-{property_key_name}"
|
||||
|
||||
|
||||
@callback
|
||||
def get_unique_id(home_id: str, value_id: str) -> str:
|
||||
"""Get unique ID from home ID and value ID."""
|
||||
|
||||
@@ -228,7 +228,6 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
|
||||
"targetColor",
|
||||
CommandClass.SWITCH_COLOR,
|
||||
value_property_key=None,
|
||||
value_property_key_name=None,
|
||||
)
|
||||
if combined_color_val and isinstance(combined_color_val.value, dict):
|
||||
colors_dict = {}
|
||||
@@ -252,7 +251,6 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
|
||||
"targetColor",
|
||||
CommandClass.SWITCH_COLOR,
|
||||
value_property_key=property_key.key,
|
||||
value_property_key_name=property_key.name,
|
||||
)
|
||||
if target_zwave_value is None:
|
||||
# guard for unsupported color
|
||||
@@ -318,31 +316,26 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
|
||||
"currentColor",
|
||||
CommandClass.SWITCH_COLOR,
|
||||
value_property_key=ColorComponent.RED.value.key,
|
||||
value_property_key_name=ColorComponent.RED.value.name,
|
||||
)
|
||||
green_val = self.get_zwave_value(
|
||||
"currentColor",
|
||||
CommandClass.SWITCH_COLOR,
|
||||
value_property_key=ColorComponent.GREEN.value.key,
|
||||
value_property_key_name=ColorComponent.GREEN.value.name,
|
||||
)
|
||||
blue_val = self.get_zwave_value(
|
||||
"currentColor",
|
||||
CommandClass.SWITCH_COLOR,
|
||||
value_property_key=ColorComponent.BLUE.value.key,
|
||||
value_property_key_name=ColorComponent.BLUE.value.name,
|
||||
)
|
||||
ww_val = self.get_zwave_value(
|
||||
"currentColor",
|
||||
CommandClass.SWITCH_COLOR,
|
||||
value_property_key=ColorComponent.WARM_WHITE.value.key,
|
||||
value_property_key_name=ColorComponent.WARM_WHITE.value.name,
|
||||
)
|
||||
cw_val = self.get_zwave_value(
|
||||
"currentColor",
|
||||
CommandClass.SWITCH_COLOR,
|
||||
value_property_key=ColorComponent.COLD_WHITE.value.key,
|
||||
value_property_key_name=ColorComponent.COLD_WHITE.value.name,
|
||||
)
|
||||
# prefer the (new) combined color property
|
||||
# https://github.com/zwave-js/node-zwave-js/pull/1782
|
||||
@@ -350,7 +343,6 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
|
||||
"currentColor",
|
||||
CommandClass.SWITCH_COLOR,
|
||||
value_property_key=None,
|
||||
value_property_key_name=None,
|
||||
)
|
||||
if combined_color_val and isinstance(combined_color_val.value, dict):
|
||||
multi_color = combined_color_val.value
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Z-Wave JS",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/zwave_js",
|
||||
"requirements": ["zwave-js-server-python==0.20.1"],
|
||||
"requirements": ["zwave-js-server-python==0.21.1"],
|
||||
"codeowners": ["@home-assistant/z-wave"],
|
||||
"dependencies": ["http", "websocket_api"]
|
||||
}
|
||||
|
||||
113
homeassistant/components/zwave_js/migrate.py
Normal file
113
homeassistant/components/zwave_js/migrate.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Functions used to migrate unique IDs for Z-Wave JS entities."""
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from zwave_js_server.client import Client as ZwaveClient
|
||||
from zwave_js_server.model.value import Value as ZwaveValue
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.entity_registry import EntityRegistry
|
||||
|
||||
from .const import DOMAIN
|
||||
from .discovery import ZwaveDiscoveryInfo
|
||||
from .helpers import get_unique_id
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@callback
|
||||
def async_migrate_entity(
|
||||
ent_reg: EntityRegistry, platform: str, old_unique_id: str, new_unique_id: str
|
||||
) -> None:
|
||||
"""Check if entity with old unique ID exists, and if so migrate it to new ID."""
|
||||
if entity_id := ent_reg.async_get_entity_id(platform, DOMAIN, old_unique_id):
|
||||
_LOGGER.debug(
|
||||
"Migrating entity %s from old unique ID '%s' to new unique ID '%s'",
|
||||
entity_id,
|
||||
old_unique_id,
|
||||
new_unique_id,
|
||||
)
|
||||
try:
|
||||
ent_reg.async_update_entity(
|
||||
entity_id,
|
||||
new_unique_id=new_unique_id,
|
||||
)
|
||||
except ValueError:
|
||||
_LOGGER.debug(
|
||||
(
|
||||
"Entity %s can't be migrated because the unique ID is taken. "
|
||||
"Cleaning it up since it is likely no longer valid."
|
||||
),
|
||||
entity_id,
|
||||
)
|
||||
ent_reg.async_remove(entity_id)
|
||||
|
||||
|
||||
@callback
|
||||
def async_migrate_discovered_value(
|
||||
ent_reg: EntityRegistry, client: ZwaveClient, disc_info: ZwaveDiscoveryInfo
|
||||
) -> None:
|
||||
"""Migrate unique ID for entity/entities tied to discovered value."""
|
||||
new_unique_id = get_unique_id(
|
||||
client.driver.controller.home_id,
|
||||
disc_info.primary_value.value_id,
|
||||
)
|
||||
|
||||
# 2021.2.*, 2021.3.0b0, and 2021.3.0 formats
|
||||
for value_id in get_old_value_ids(disc_info.primary_value):
|
||||
old_unique_id = get_unique_id(
|
||||
client.driver.controller.home_id,
|
||||
value_id,
|
||||
)
|
||||
# Most entities have the same ID format, but notification binary sensors
|
||||
# have a state key in their ID so we need to handle them differently
|
||||
if (
|
||||
disc_info.platform == "binary_sensor"
|
||||
and disc_info.platform_hint == "notification"
|
||||
):
|
||||
for state_key in disc_info.primary_value.metadata.states:
|
||||
# ignore idle key (0)
|
||||
if state_key == "0":
|
||||
continue
|
||||
|
||||
async_migrate_entity(
|
||||
ent_reg,
|
||||
disc_info.platform,
|
||||
f"{old_unique_id}.{state_key}",
|
||||
f"{new_unique_id}.{state_key}",
|
||||
)
|
||||
|
||||
# Once we've iterated through all state keys, we can move on to the
|
||||
# next item
|
||||
continue
|
||||
|
||||
async_migrate_entity(ent_reg, disc_info.platform, old_unique_id, new_unique_id)
|
||||
|
||||
|
||||
@callback
|
||||
def get_old_value_ids(value: ZwaveValue) -> List[str]:
|
||||
"""Get old value IDs so we can migrate entity unique ID."""
|
||||
value_ids = []
|
||||
|
||||
# Pre 2021.3.0 value ID
|
||||
command_class = value.command_class
|
||||
endpoint = value.endpoint or "00"
|
||||
property_ = value.property_
|
||||
property_key_name = value.property_key_name or "00"
|
||||
value_ids.append(
|
||||
f"{value.node.node_id}.{value.node.node_id}-{command_class}-{endpoint}-"
|
||||
f"{property_}-{property_key_name}"
|
||||
)
|
||||
|
||||
endpoint = "00" if value.endpoint is None else value.endpoint
|
||||
property_key = "00" if value.property_key is None else value.property_key
|
||||
property_key_name = value.property_key_name or "00"
|
||||
|
||||
value_id = (
|
||||
f"{value.node.node_id}-{command_class}-{endpoint}-"
|
||||
f"{property_}-{property_key}-{property_key_name}"
|
||||
)
|
||||
# 2021.3.0b0 and 2021.3.0 value IDs
|
||||
value_ids.extend([f"{value.node.node_id}.{value_id}", value_id])
|
||||
|
||||
return value_ids
|
||||
@@ -70,10 +70,15 @@ set_config_parameter:
|
||||
refresh_value:
|
||||
name: Refresh value(s) of a Z-Wave entity
|
||||
description: Force update value(s) for a Z-Wave entity
|
||||
target:
|
||||
entity:
|
||||
integration: zwave_js
|
||||
fields:
|
||||
entity_id:
|
||||
name: Entity
|
||||
description: Entity whose value(s) should be refreshed
|
||||
required: true
|
||||
example: sensor.family_room_motion
|
||||
selector:
|
||||
entity:
|
||||
integration: zwave_js
|
||||
refresh_all_values:
|
||||
name: Refresh all values?
|
||||
description: Whether to refresh all values (true) or just the primary value (false)
|
||||
|
||||
@@ -11,10 +11,9 @@ import weakref
|
||||
import attr
|
||||
|
||||
from homeassistant import data_entry_flow, loader
|
||||
from homeassistant.const import EVENT_CONFIG_ENTRY_DISABLED_BY_UPDATED
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry
|
||||
from homeassistant.helpers import device_registry, entity_registry
|
||||
from homeassistant.helpers.event import Event
|
||||
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
|
||||
from homeassistant.setup import async_process_deps_reqs, async_setup_component
|
||||
@@ -807,12 +806,21 @@ class ConfigEntries:
|
||||
entry.disabled_by = disabled_by
|
||||
self._async_schedule_save()
|
||||
|
||||
# Unload the config entry, then fire an event
|
||||
dev_reg = device_registry.async_get(self.hass)
|
||||
ent_reg = entity_registry.async_get(self.hass)
|
||||
|
||||
if not entry.disabled_by:
|
||||
# The config entry will no longer be disabled, enable devices and entities
|
||||
device_registry.async_config_entry_disabled_by_changed(dev_reg, entry)
|
||||
entity_registry.async_config_entry_disabled_by_changed(ent_reg, entry)
|
||||
|
||||
# Load or unload the config entry
|
||||
reload_result = await self.async_reload(entry_id)
|
||||
|
||||
self.hass.bus.async_fire(
|
||||
EVENT_CONFIG_ENTRY_DISABLED_BY_UPDATED, {"config_entry_id": entry_id}
|
||||
)
|
||||
if entry.disabled_by:
|
||||
# The config entry has been disabled, disable devices and entities
|
||||
device_registry.async_config_entry_disabled_by_changed(dev_reg, entry)
|
||||
entity_registry.async_config_entry_disabled_by_changed(ent_reg, entry)
|
||||
|
||||
return reload_result
|
||||
|
||||
@@ -1251,8 +1259,16 @@ class EntityRegistryDisabledHandler:
|
||||
|
||||
@callback
|
||||
def _handle_entry_updated_filter(event: Event) -> bool:
|
||||
"""Handle entity registry entry update filter."""
|
||||
if event.data["action"] != "update" or "disabled_by" not in event.data["changes"]:
|
||||
"""Handle entity registry entry update filter.
|
||||
|
||||
Only handle changes to "disabled_by".
|
||||
If "disabled_by" was DISABLED_CONFIG_ENTRY, reload is not needed.
|
||||
"""
|
||||
if (
|
||||
event.data["action"] != "update"
|
||||
or "disabled_by" not in event.data["changes"]
|
||||
or event.data["changes"]["disabled_by"] == entity_registry.DISABLED_CONFIG_ENTRY
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Constants used by Home Assistant components."""
|
||||
MAJOR_VERSION = 2021
|
||||
MINOR_VERSION = 3
|
||||
PATCH_VERSION = "0b7"
|
||||
PATCH_VERSION = "3"
|
||||
__short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||
__version__ = f"{__short_version__}.{PATCH_VERSION}"
|
||||
REQUIRED_PYTHON_VER = (3, 8, 0)
|
||||
@@ -202,7 +202,6 @@ CONF_ZONE = "zone"
|
||||
# #### EVENTS ####
|
||||
EVENT_CALL_SERVICE = "call_service"
|
||||
EVENT_COMPONENT_LOADED = "component_loaded"
|
||||
EVENT_CONFIG_ENTRY_DISABLED_BY_UPDATED = "config_entry_disabled_by_updated"
|
||||
EVENT_CORE_CONFIG_UPDATE = "core_config_updated"
|
||||
EVENT_HOMEASSISTANT_CLOSE = "homeassistant_close"
|
||||
EVENT_HOMEASSISTANT_START = "homeassistant_start"
|
||||
|
||||
@@ -265,10 +265,9 @@ def async_numeric_state(
|
||||
"numeric_state", f"template error: {ex}"
|
||||
) from ex
|
||||
|
||||
# Known states that never match the numeric condition
|
||||
if value in (STATE_UNAVAILABLE, STATE_UNKNOWN):
|
||||
raise ConditionErrorMessage(
|
||||
"numeric_state", f"state of {entity_id} is unavailable"
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
fvalue = float(value)
|
||||
@@ -281,13 +280,15 @@ def async_numeric_state(
|
||||
if below is not None:
|
||||
if isinstance(below, str):
|
||||
below_entity = hass.states.get(below)
|
||||
if not below_entity or below_entity.state in (
|
||||
if not below_entity:
|
||||
raise ConditionErrorMessage(
|
||||
"numeric_state", f"unknown 'below' entity {below}"
|
||||
)
|
||||
if below_entity.state in (
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
):
|
||||
raise ConditionErrorMessage(
|
||||
"numeric_state", f"the 'below' entity {below} is unavailable"
|
||||
)
|
||||
return False
|
||||
try:
|
||||
if fvalue >= float(below_entity.state):
|
||||
return False
|
||||
@@ -302,13 +303,15 @@ def async_numeric_state(
|
||||
if above is not None:
|
||||
if isinstance(above, str):
|
||||
above_entity = hass.states.get(above)
|
||||
if not above_entity or above_entity.state in (
|
||||
if not above_entity:
|
||||
raise ConditionErrorMessage(
|
||||
"numeric_state", f"unknown 'above' entity {above}"
|
||||
)
|
||||
if above_entity.state in (
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
):
|
||||
raise ConditionErrorMessage(
|
||||
"numeric_state", f"the 'above' entity {above} is unavailable"
|
||||
)
|
||||
return False
|
||||
try:
|
||||
if fvalue <= float(above_entity.state):
|
||||
return False
|
||||
|
||||
@@ -6,10 +6,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple, Union,
|
||||
|
||||
import attr
|
||||
|
||||
from homeassistant.const import (
|
||||
EVENT_CONFIG_ENTRY_DISABLED_BY_UPDATED,
|
||||
EVENT_HOMEASSISTANT_STARTED,
|
||||
)
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
|
||||
from homeassistant.core import Event, callback
|
||||
from homeassistant.loader import bind_hass
|
||||
import homeassistant.util.uuid as uuid_util
|
||||
@@ -20,6 +17,8 @@ from .typing import UNDEFINED, HomeAssistantType, UndefinedType
|
||||
# mypy: disallow_any_generics
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
from . import entity_registry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -143,10 +142,6 @@ class DeviceRegistry:
|
||||
self.hass = hass
|
||||
self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
|
||||
self._clear_index()
|
||||
self.hass.bus.async_listen(
|
||||
EVENT_CONFIG_ENTRY_DISABLED_BY_UPDATED,
|
||||
self.async_config_entry_disabled_by_changed,
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_get(self, device_id: str) -> Optional[DeviceEntry]:
|
||||
@@ -618,38 +613,6 @@ class DeviceRegistry:
|
||||
if area_id == device.area_id:
|
||||
self._async_update_device(dev_id, area_id=None)
|
||||
|
||||
@callback
|
||||
def async_config_entry_disabled_by_changed(self, event: Event) -> None:
|
||||
"""Handle a config entry being disabled or enabled.
|
||||
|
||||
Disable devices in the registry that are associated to a config entry when
|
||||
the config entry is disabled.
|
||||
"""
|
||||
config_entry = self.hass.config_entries.async_get_entry(
|
||||
event.data["config_entry_id"]
|
||||
)
|
||||
|
||||
# The config entry may be deleted already if the event handling is late
|
||||
if not config_entry:
|
||||
return
|
||||
|
||||
if not config_entry.disabled_by:
|
||||
devices = async_entries_for_config_entry(
|
||||
self, event.data["config_entry_id"]
|
||||
)
|
||||
for device in devices:
|
||||
if device.disabled_by != DISABLED_CONFIG_ENTRY:
|
||||
continue
|
||||
self.async_update_device(device.id, disabled_by=None)
|
||||
return
|
||||
|
||||
devices = async_entries_for_config_entry(self, event.data["config_entry_id"])
|
||||
for device in devices:
|
||||
if device.disabled:
|
||||
# Entity already disabled, do not overwrite
|
||||
continue
|
||||
self.async_update_device(device.id, disabled_by=DISABLED_CONFIG_ENTRY)
|
||||
|
||||
|
||||
@callback
|
||||
def async_get(hass: HomeAssistantType) -> DeviceRegistry:
|
||||
@@ -691,6 +654,34 @@ def async_entries_for_config_entry(
|
||||
]
|
||||
|
||||
|
||||
@callback
|
||||
def async_config_entry_disabled_by_changed(
|
||||
registry: DeviceRegistry, config_entry: "ConfigEntry"
|
||||
) -> None:
|
||||
"""Handle a config entry being disabled or enabled.
|
||||
|
||||
Disable devices in the registry that are associated with a config entry when
|
||||
the config entry is disabled, enable devices in the registry that are associated
|
||||
with a config entry when the config entry is enabled and the devices are marked
|
||||
DISABLED_CONFIG_ENTRY.
|
||||
"""
|
||||
|
||||
devices = async_entries_for_config_entry(registry, config_entry.entry_id)
|
||||
|
||||
if not config_entry.disabled_by:
|
||||
for device in devices:
|
||||
if device.disabled_by != DISABLED_CONFIG_ENTRY:
|
||||
continue
|
||||
registry.async_update_device(device.id, disabled_by=None)
|
||||
return
|
||||
|
||||
for device in devices:
|
||||
if device.disabled:
|
||||
# Device already disabled, do not overwrite
|
||||
continue
|
||||
registry.async_update_device(device.id, disabled_by=DISABLED_CONFIG_ENTRY)
|
||||
|
||||
|
||||
@callback
|
||||
def async_cleanup(
|
||||
hass: HomeAssistantType,
|
||||
|
||||
@@ -31,7 +31,6 @@ from homeassistant.const import (
|
||||
ATTR_RESTORED,
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
EVENT_CONFIG_ENTRY_DISABLED_BY_UPDATED,
|
||||
EVENT_HOMEASSISTANT_START,
|
||||
STATE_UNAVAILABLE,
|
||||
)
|
||||
@@ -158,10 +157,6 @@ class EntityRegistry:
|
||||
self.hass.bus.async_listen(
|
||||
EVENT_DEVICE_REGISTRY_UPDATED, self.async_device_modified
|
||||
)
|
||||
self.hass.bus.async_listen(
|
||||
EVENT_CONFIG_ENTRY_DISABLED_BY_UPDATED,
|
||||
self.async_config_entry_disabled_by_changed,
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_get_device_class_lookup(self, domain_device_classes: set) -> dict:
|
||||
@@ -363,40 +358,6 @@ class EntityRegistry:
|
||||
for entity in entities:
|
||||
self.async_update_entity(entity.entity_id, disabled_by=DISABLED_DEVICE)
|
||||
|
||||
@callback
|
||||
def async_config_entry_disabled_by_changed(self, event: Event) -> None:
|
||||
"""Handle a config entry being disabled or enabled.
|
||||
|
||||
Disable entities in the registry that are associated to a config entry when
|
||||
the config entry is disabled.
|
||||
"""
|
||||
config_entry = self.hass.config_entries.async_get_entry(
|
||||
event.data["config_entry_id"]
|
||||
)
|
||||
|
||||
# The config entry may be deleted already if the event handling is late
|
||||
if not config_entry:
|
||||
return
|
||||
|
||||
if not config_entry.disabled_by:
|
||||
entities = async_entries_for_config_entry(
|
||||
self, event.data["config_entry_id"]
|
||||
)
|
||||
for entity in entities:
|
||||
if entity.disabled_by != DISABLED_CONFIG_ENTRY:
|
||||
continue
|
||||
self.async_update_entity(entity.entity_id, disabled_by=None)
|
||||
return
|
||||
|
||||
entities = async_entries_for_config_entry(self, event.data["config_entry_id"])
|
||||
for entity in entities:
|
||||
if entity.disabled:
|
||||
# Entity already disabled, do not overwrite
|
||||
continue
|
||||
self.async_update_entity(
|
||||
entity.entity_id, disabled_by=DISABLED_CONFIG_ENTRY
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_update_entity(
|
||||
self,
|
||||
@@ -443,7 +404,8 @@ class EntityRegistry:
|
||||
"""Private facing update properties method."""
|
||||
old = self.entities[entity_id]
|
||||
|
||||
changes = {}
|
||||
new_values = {} # Dict with new key/value pairs
|
||||
old_values = {} # Dict with old key/value pairs
|
||||
|
||||
for attr_name, value in (
|
||||
("name", name),
|
||||
@@ -460,7 +422,8 @@ class EntityRegistry:
|
||||
("original_icon", original_icon),
|
||||
):
|
||||
if value is not UNDEFINED and value != getattr(old, attr_name):
|
||||
changes[attr_name] = value
|
||||
new_values[attr_name] = value
|
||||
old_values[attr_name] = getattr(old, attr_name)
|
||||
|
||||
if new_entity_id is not UNDEFINED and new_entity_id != old.entity_id:
|
||||
if self.async_is_registered(new_entity_id):
|
||||
@@ -473,7 +436,8 @@ class EntityRegistry:
|
||||
raise ValueError("New entity ID should be same domain")
|
||||
|
||||
self.entities.pop(entity_id)
|
||||
entity_id = changes["entity_id"] = new_entity_id
|
||||
entity_id = new_values["entity_id"] = new_entity_id
|
||||
old_values["entity_id"] = old.entity_id
|
||||
|
||||
if new_unique_id is not UNDEFINED:
|
||||
conflict_entity_id = self.async_get_entity_id(
|
||||
@@ -484,18 +448,19 @@ class EntityRegistry:
|
||||
f"Unique id '{new_unique_id}' is already in use by "
|
||||
f"'{conflict_entity_id}'"
|
||||
)
|
||||
changes["unique_id"] = new_unique_id
|
||||
new_values["unique_id"] = new_unique_id
|
||||
old_values["unique_id"] = old.unique_id
|
||||
|
||||
if not changes:
|
||||
if not new_values:
|
||||
return old
|
||||
|
||||
self._remove_index(old)
|
||||
new = attr.evolve(old, **changes)
|
||||
new = attr.evolve(old, **new_values)
|
||||
self._register_entry(new)
|
||||
|
||||
self.async_schedule_save()
|
||||
|
||||
data = {"action": "update", "entity_id": entity_id, "changes": list(changes)}
|
||||
data = {"action": "update", "entity_id": entity_id, "changes": old_values}
|
||||
|
||||
if old.entity_id != entity_id:
|
||||
data["old_entity_id"] = old.entity_id
|
||||
@@ -670,6 +635,36 @@ def async_entries_for_config_entry(
|
||||
]
|
||||
|
||||
|
||||
@callback
|
||||
def async_config_entry_disabled_by_changed(
|
||||
registry: EntityRegistry, config_entry: "ConfigEntry"
|
||||
) -> None:
|
||||
"""Handle a config entry being disabled or enabled.
|
||||
|
||||
Disable entities in the registry that are associated with a config entry when
|
||||
the config entry is disabled, enable entities in the registry that are associated
|
||||
with a config entry when the config entry is enabled and the entities are marked
|
||||
DISABLED_CONFIG_ENTRY.
|
||||
"""
|
||||
|
||||
entities = async_entries_for_config_entry(registry, config_entry.entry_id)
|
||||
|
||||
if not config_entry.disabled_by:
|
||||
for entity in entities:
|
||||
if entity.disabled_by != DISABLED_CONFIG_ENTRY:
|
||||
continue
|
||||
registry.async_update_entity(entity.entity_id, disabled_by=None)
|
||||
return
|
||||
|
||||
for entity in entities:
|
||||
if entity.disabled:
|
||||
# Entity already disabled, do not overwrite
|
||||
continue
|
||||
registry.async_update_entity(
|
||||
entity.entity_id, disabled_by=DISABLED_CONFIG_ENTRY
|
||||
)
|
||||
|
||||
|
||||
async def _async_migrate(entities: Dict[str, Any]) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Migrate the YAML config file to storage helper format."""
|
||||
return {
|
||||
|
||||
@@ -15,7 +15,7 @@ defusedxml==0.6.0
|
||||
distro==1.5.0
|
||||
emoji==1.2.0
|
||||
hass-nabucasa==0.41.0
|
||||
home-assistant-frontend==20210302.0
|
||||
home-assistant-frontend==20210302.6
|
||||
httpx==0.16.1
|
||||
jinja2>=2.11.3
|
||||
netdisco==2.8.2
|
||||
|
||||
@@ -17,7 +17,7 @@ Adafruit-SHT31==1.0.2
|
||||
# Adafruit_BBIO==1.1.1
|
||||
|
||||
# homeassistant.components.homekit
|
||||
HAP-python==3.3.2
|
||||
HAP-python==3.4.0
|
||||
|
||||
# homeassistant.components.mastodon
|
||||
Mastodon.py==1.5.1
|
||||
@@ -245,7 +245,7 @@ alpha_vantage==2.3.1
|
||||
ambiclimate==0.2.1
|
||||
|
||||
# homeassistant.components.amcrest
|
||||
amcrest==1.7.0
|
||||
amcrest==1.7.1
|
||||
|
||||
# homeassistant.components.androidtv
|
||||
androidtv[async]==0.0.57
|
||||
@@ -721,7 +721,7 @@ guppy3==3.1.0
|
||||
ha-ffmpeg==3.0.2
|
||||
|
||||
# homeassistant.components.philips_js
|
||||
ha-philipsjs==2.3.0
|
||||
ha-philipsjs==2.3.1
|
||||
|
||||
# homeassistant.components.habitica
|
||||
habitipy==0.2.0
|
||||
@@ -763,7 +763,7 @@ hole==0.5.1
|
||||
holidays==0.10.5.2
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20210302.0
|
||||
home-assistant-frontend==20210302.6
|
||||
|
||||
# homeassistant.components.zwave
|
||||
homeassistant-pyozw==0.1.10
|
||||
@@ -1504,7 +1504,7 @@ pylibrespot-java==0.1.0
|
||||
pylitejet==0.3.0
|
||||
|
||||
# homeassistant.components.litterrobot
|
||||
pylitterbot==2021.2.5
|
||||
pylitterbot==2021.2.8
|
||||
|
||||
# homeassistant.components.loopenergy
|
||||
pyloopenergy==0.2.1
|
||||
@@ -1555,7 +1555,7 @@ pymusiccast==0.1.6
|
||||
pymyq==3.0.4
|
||||
|
||||
# homeassistant.components.mysensors
|
||||
pymysensors==0.20.1
|
||||
pymysensors==0.21.0
|
||||
|
||||
# homeassistant.components.nanoleaf
|
||||
pynanoleaf==0.0.5
|
||||
@@ -1603,7 +1603,7 @@ pyoppleio==1.0.5
|
||||
pyota==2.0.5
|
||||
|
||||
# homeassistant.components.opentherm_gw
|
||||
pyotgw==1.0b1
|
||||
pyotgw==1.1b1
|
||||
|
||||
# homeassistant.auth.mfa_modules.notify
|
||||
# homeassistant.auth.mfa_modules.totp
|
||||
@@ -2397,4 +2397,4 @@ zigpy==0.32.0
|
||||
zm-py==0.5.2
|
||||
|
||||
# homeassistant.components.zwave_js
|
||||
zwave-js-server-python==0.20.1
|
||||
zwave-js-server-python==0.21.1
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
AEMET-OpenData==0.1.8
|
||||
|
||||
# homeassistant.components.homekit
|
||||
HAP-python==3.3.2
|
||||
HAP-python==3.4.0
|
||||
|
||||
# homeassistant.components.flick_electric
|
||||
PyFlick==0.0.2
|
||||
@@ -382,7 +382,7 @@ guppy3==3.1.0
|
||||
ha-ffmpeg==3.0.2
|
||||
|
||||
# homeassistant.components.philips_js
|
||||
ha-philipsjs==2.3.0
|
||||
ha-philipsjs==2.3.1
|
||||
|
||||
# homeassistant.components.habitica
|
||||
habitipy==0.2.0
|
||||
@@ -412,7 +412,7 @@ hole==0.5.1
|
||||
holidays==0.10.5.2
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20210302.0
|
||||
home-assistant-frontend==20210302.6
|
||||
|
||||
# homeassistant.components.zwave
|
||||
homeassistant-pyozw==0.1.10
|
||||
@@ -794,7 +794,7 @@ pylibrespot-java==0.1.0
|
||||
pylitejet==0.3.0
|
||||
|
||||
# homeassistant.components.litterrobot
|
||||
pylitterbot==2021.2.5
|
||||
pylitterbot==2021.2.8
|
||||
|
||||
# homeassistant.components.lutron_caseta
|
||||
pylutron-caseta==0.9.0
|
||||
@@ -827,7 +827,7 @@ pymonoprice==0.3
|
||||
pymyq==3.0.4
|
||||
|
||||
# homeassistant.components.mysensors
|
||||
pymysensors==0.20.1
|
||||
pymysensors==0.21.0
|
||||
|
||||
# homeassistant.components.nuki
|
||||
pynuki==1.3.8
|
||||
@@ -851,7 +851,7 @@ pyopenuv==1.0.9
|
||||
pyopnsense==0.2.0
|
||||
|
||||
# homeassistant.components.opentherm_gw
|
||||
pyotgw==1.0b1
|
||||
pyotgw==1.1b1
|
||||
|
||||
# homeassistant.auth.mfa_modules.notify
|
||||
# homeassistant.auth.mfa_modules.totp
|
||||
@@ -1234,4 +1234,4 @@ zigpy-znp==0.4.0
|
||||
zigpy==0.32.0
|
||||
|
||||
# homeassistant.components.zwave_js
|
||||
zwave-js-server-python==0.20.1
|
||||
zwave-js-server-python==0.21.1
|
||||
|
||||
@@ -147,6 +147,8 @@ IGNORE_VIOLATIONS = {
|
||||
# Demo
|
||||
("demo", "manual"),
|
||||
("demo", "openalpr_local"),
|
||||
# Migration wizard from zwave to ozw.
|
||||
"ozw",
|
||||
# This should become a helper method that integrations can submit data to
|
||||
("websocket_api", "lovelace"),
|
||||
("websocket_api", "shopping_list"),
|
||||
|
||||
@@ -37,7 +37,9 @@ async def test_aemet_forecast_create_sensors(hass):
|
||||
assert state.state == "-4"
|
||||
|
||||
state = hass.states.get("sensor.aemet_daily_forecast_time")
|
||||
assert state.state == "2021-01-10 00:00:00+00:00"
|
||||
assert (
|
||||
state.state == dt_util.parse_datetime("2021-01-10 00:00:00+00:00").isoformat()
|
||||
)
|
||||
|
||||
state = hass.states.get("sensor.aemet_daily_forecast_wind_bearing")
|
||||
assert state.state == "45.0"
|
||||
|
||||
@@ -51,8 +51,9 @@ async def test_aemet_weather(hass):
|
||||
assert forecast.get(ATTR_FORECAST_PRECIPITATION_PROBABILITY) == 30
|
||||
assert forecast.get(ATTR_FORECAST_TEMP) == 4
|
||||
assert forecast.get(ATTR_FORECAST_TEMP_LOW) == -4
|
||||
assert forecast.get(ATTR_FORECAST_TIME) == dt_util.parse_datetime(
|
||||
"2021-01-10 00:00:00+00:00"
|
||||
assert (
|
||||
forecast.get(ATTR_FORECAST_TIME)
|
||||
== dt_util.parse_datetime("2021-01-10 00:00:00+00:00").isoformat()
|
||||
)
|
||||
assert forecast.get(ATTR_FORECAST_WIND_BEARING) == 45.0
|
||||
assert forecast.get(ATTR_FORECAST_WIND_SPEED) == 20
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Tests for the AsusWrt config flow."""
|
||||
from socket import gaierror
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -46,7 +46,7 @@ def mock_controller_connect():
|
||||
with patch("homeassistant.components.asuswrt.router.AsusWrt") as service_mock:
|
||||
service_mock.return_value.connection.async_connect = AsyncMock()
|
||||
service_mock.return_value.is_connected = True
|
||||
service_mock.return_value.connection.disconnect = AsyncMock()
|
||||
service_mock.return_value.connection.disconnect = Mock()
|
||||
yield service_mock
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Tests for the AsusWrt sensor."""
|
||||
from datetime import timedelta
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from aioasuswrt.asuswrt import Device
|
||||
import pytest
|
||||
@@ -49,7 +49,7 @@ def mock_controller_connect():
|
||||
with patch("homeassistant.components.asuswrt.router.AsusWrt") as service_mock:
|
||||
service_mock.return_value.connection.async_connect = AsyncMock()
|
||||
service_mock.return_value.is_connected = True
|
||||
service_mock.return_value.connection.disconnect = AsyncMock()
|
||||
service_mock.return_value.connection.disconnect = Mock()
|
||||
service_mock.return_value.async_get_connected_devices = AsyncMock(
|
||||
return_value=MOCK_DEVICES
|
||||
)
|
||||
|
||||
169
tests/components/bond/test_entity.py
Normal file
169
tests/components/bond/test_entity.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""Tests for the Bond entities."""
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
from bond_api import BPUPSubscriptions, DeviceType
|
||||
|
||||
from homeassistant import core
|
||||
from homeassistant.components import fan
|
||||
from homeassistant.components.fan import DOMAIN as FAN_DOMAIN
|
||||
from homeassistant.const import STATE_ON, STATE_UNAVAILABLE
|
||||
from homeassistant.util import utcnow
|
||||
|
||||
from .common import patch_bond_device_state, setup_platform
|
||||
|
||||
from tests.common import async_fire_time_changed
|
||||
|
||||
|
||||
def ceiling_fan(name: str):
|
||||
"""Create a ceiling fan with given name."""
|
||||
return {
|
||||
"name": name,
|
||||
"type": DeviceType.CEILING_FAN,
|
||||
"actions": ["SetSpeed", "SetDirection"],
|
||||
}
|
||||
|
||||
|
||||
async def test_bpup_goes_offline_and_recovers_same_entity(hass: core.HomeAssistant):
|
||||
"""Test that push updates fail and we fallback to polling and then bpup recovers.
|
||||
|
||||
The BPUP recovery is triggered by an update for the entity and
|
||||
we do not fallback to polling because state is in sync.
|
||||
"""
|
||||
bpup_subs = BPUPSubscriptions()
|
||||
with patch(
|
||||
"homeassistant.components.bond.BPUPSubscriptions",
|
||||
return_value=bpup_subs,
|
||||
):
|
||||
await setup_platform(
|
||||
hass, FAN_DOMAIN, ceiling_fan("name-1"), bond_device_id="test-device-id"
|
||||
)
|
||||
|
||||
bpup_subs.notify(
|
||||
{
|
||||
"s": 200,
|
||||
"t": "bond/test-device-id/update",
|
||||
"b": {"power": 1, "speed": 3, "direction": 0},
|
||||
}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get("fan.name_1").attributes[fan.ATTR_PERCENTAGE] == 100
|
||||
|
||||
bpup_subs.notify(
|
||||
{
|
||||
"s": 200,
|
||||
"t": "bond/test-device-id/update",
|
||||
"b": {"power": 1, "speed": 1, "direction": 0},
|
||||
}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get("fan.name_1").attributes[fan.ATTR_PERCENTAGE] == 33
|
||||
|
||||
bpup_subs.last_message_time = 0
|
||||
with patch_bond_device_state(side_effect=asyncio.TimeoutError):
|
||||
async_fire_time_changed(hass, utcnow() + timedelta(seconds=230))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get("fan.name_1").state == STATE_UNAVAILABLE
|
||||
|
||||
# Ensure we do not poll to get the state
|
||||
# since bpup has recovered and we know we
|
||||
# are back in sync
|
||||
with patch_bond_device_state(side_effect=Exception):
|
||||
bpup_subs.notify(
|
||||
{
|
||||
"s": 200,
|
||||
"t": "bond/test-device-id/update",
|
||||
"b": {"power": 1, "speed": 2, "direction": 0},
|
||||
}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("fan.name_1")
|
||||
assert state.state == STATE_ON
|
||||
assert state.attributes[fan.ATTR_PERCENTAGE] == 66
|
||||
|
||||
|
||||
async def test_bpup_goes_offline_and_recovers_different_entity(
|
||||
hass: core.HomeAssistant,
|
||||
):
|
||||
"""Test that push updates fail and we fallback to polling and then bpup recovers.
|
||||
|
||||
The BPUP recovery is triggered by an update for a different entity which
|
||||
forces a poll since we need to re-get the state.
|
||||
"""
|
||||
bpup_subs = BPUPSubscriptions()
|
||||
with patch(
|
||||
"homeassistant.components.bond.BPUPSubscriptions",
|
||||
return_value=bpup_subs,
|
||||
):
|
||||
await setup_platform(
|
||||
hass, FAN_DOMAIN, ceiling_fan("name-1"), bond_device_id="test-device-id"
|
||||
)
|
||||
|
||||
bpup_subs.notify(
|
||||
{
|
||||
"s": 200,
|
||||
"t": "bond/test-device-id/update",
|
||||
"b": {"power": 1, "speed": 3, "direction": 0},
|
||||
}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get("fan.name_1").attributes[fan.ATTR_PERCENTAGE] == 100
|
||||
|
||||
bpup_subs.notify(
|
||||
{
|
||||
"s": 200,
|
||||
"t": "bond/test-device-id/update",
|
||||
"b": {"power": 1, "speed": 1, "direction": 0},
|
||||
}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get("fan.name_1").attributes[fan.ATTR_PERCENTAGE] == 33
|
||||
|
||||
bpup_subs.last_message_time = 0
|
||||
with patch_bond_device_state(side_effect=asyncio.TimeoutError):
|
||||
async_fire_time_changed(hass, utcnow() + timedelta(seconds=230))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get("fan.name_1").state == STATE_UNAVAILABLE
|
||||
|
||||
bpup_subs.notify(
|
||||
{
|
||||
"s": 200,
|
||||
"t": "bond/not-this-device-id/update",
|
||||
"b": {"power": 1, "speed": 2, "direction": 0},
|
||||
}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get("fan.name_1").state == STATE_UNAVAILABLE
|
||||
|
||||
with patch_bond_device_state(return_value={"power": 1, "speed": 1}):
|
||||
async_fire_time_changed(hass, utcnow() + timedelta(seconds=430))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("fan.name_1")
|
||||
assert state.state == STATE_ON
|
||||
assert state.attributes[fan.ATTR_PERCENTAGE] == 33
|
||||
|
||||
|
||||
async def test_polling_fails_and_recovers(hass: core.HomeAssistant):
|
||||
"""Test that polling fails and we recover."""
|
||||
await setup_platform(
|
||||
hass, FAN_DOMAIN, ceiling_fan("name-1"), bond_device_id="test-device-id"
|
||||
)
|
||||
|
||||
with patch_bond_device_state(side_effect=asyncio.TimeoutError):
|
||||
async_fire_time_changed(hass, utcnow() + timedelta(seconds=230))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get("fan.name_1").state == STATE_UNAVAILABLE
|
||||
|
||||
with patch_bond_device_state(return_value={"power": 1, "speed": 1}):
|
||||
async_fire_time_changed(hass, utcnow() + timedelta(seconds=230))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("fan.name_1")
|
||||
assert state.state == STATE_ON
|
||||
assert state.attributes[fan.ATTR_PERCENTAGE] == 33
|
||||
@@ -10,12 +10,7 @@ import homeassistant.components.automation as automation
|
||||
from homeassistant.components.homeassistant.triggers import (
|
||||
numeric_state as numeric_state_trigger,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ENTITY_MATCH_ALL,
|
||||
SERVICE_TURN_OFF,
|
||||
STATE_UNAVAILABLE,
|
||||
)
|
||||
from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, SERVICE_TURN_OFF
|
||||
from homeassistant.core import Context
|
||||
from homeassistant.setup import async_setup_component
|
||||
import homeassistant.util.dt as dt_util
|
||||
@@ -347,52 +342,6 @@ async def test_if_fires_on_entity_unavailable_at_startup(hass, calls):
|
||||
assert len(calls) == 0
|
||||
|
||||
|
||||
async def test_if_not_fires_on_entity_unavailable(hass, calls):
|
||||
"""Test the firing with entity changing to unavailable."""
|
||||
# set initial state
|
||||
hass.states.async_set("test.entity", 9)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
"platform": "numeric_state",
|
||||
"entity_id": "test.entity",
|
||||
"above": 10,
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# 11 is above 10
|
||||
hass.states.async_set("test.entity", 11)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
|
||||
# Going to unavailable and back should not fire
|
||||
hass.states.async_set("test.entity", STATE_UNAVAILABLE)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
hass.states.async_set("test.entity", 11)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
|
||||
# Crossing threshold via unavailable should fire
|
||||
hass.states.async_set("test.entity", 9)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
hass.states.async_set("test.entity", STATE_UNAVAILABLE)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
hass.states.async_set("test.entity", 11)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("above", (10, "input_number.value_10"))
|
||||
async def test_if_fires_on_entity_change_below_to_above(hass, calls, above):
|
||||
"""Test the firing with changed entity."""
|
||||
@@ -1522,7 +1471,7 @@ async def test_if_fires_on_change_with_for_template_3(hass, calls, above, below)
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
async def test_if_not_fires_on_error_with_for_template(hass, caplog, calls):
|
||||
async def test_if_not_fires_on_error_with_for_template(hass, calls):
|
||||
"""Test for not firing on error with for template."""
|
||||
hass.states.async_set("test.entity", 0)
|
||||
await hass.async_block_till_done()
|
||||
@@ -1547,17 +1496,11 @@ async def test_if_not_fires_on_error_with_for_template(hass, caplog, calls):
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
caplog.clear()
|
||||
caplog.set_level(logging.WARNING)
|
||||
|
||||
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=3))
|
||||
hass.states.async_set("test.entity", "unavailable")
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
assert len(caplog.record_tuples) == 1
|
||||
assert caplog.record_tuples[0][1] == logging.WARNING
|
||||
|
||||
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=3))
|
||||
hass.states.async_set("test.entity", 101)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@@ -493,7 +493,7 @@ async def test_homekit_start(hass, hk_driver, device_reg):
|
||||
) as mock_setup_msg, patch(
|
||||
"pyhap.accessory_driver.AccessoryDriver.add_accessory"
|
||||
) as hk_driver_add_acc, patch(
|
||||
"pyhap.accessory_driver.AccessoryDriver.start_service"
|
||||
"pyhap.accessory_driver.AccessoryDriver.async_start"
|
||||
) as hk_driver_start:
|
||||
await homekit.async_start()
|
||||
|
||||
@@ -528,7 +528,7 @@ async def test_homekit_start(hass, hk_driver, device_reg):
|
||||
) as mock_setup_msg, patch(
|
||||
"pyhap.accessory_driver.AccessoryDriver.add_accessory"
|
||||
) as hk_driver_add_acc, patch(
|
||||
"pyhap.accessory_driver.AccessoryDriver.start_service"
|
||||
"pyhap.accessory_driver.AccessoryDriver.async_start"
|
||||
) as hk_driver_start:
|
||||
await homekit.async_start()
|
||||
|
||||
@@ -567,7 +567,7 @@ async def test_homekit_start_with_a_broken_accessory(hass, hk_driver, mock_zeroc
|
||||
) as mock_setup_msg, patch(
|
||||
"pyhap.accessory_driver.AccessoryDriver.add_accessory",
|
||||
) as hk_driver_add_acc, patch(
|
||||
"pyhap.accessory_driver.AccessoryDriver.start_service"
|
||||
"pyhap.accessory_driver.AccessoryDriver.async_start"
|
||||
) as hk_driver_start:
|
||||
await homekit.async_start()
|
||||
|
||||
@@ -630,7 +630,7 @@ async def test_homekit_reset_accessories(hass, mock_zeroconf):
|
||||
), patch("pyhap.accessory.Bridge.add_accessory") as mock_add_accessory, patch(
|
||||
"pyhap.accessory_driver.AccessoryDriver.config_changed"
|
||||
) as hk_driver_config_changed, patch(
|
||||
"pyhap.accessory_driver.AccessoryDriver.start_service"
|
||||
"pyhap.accessory_driver.AccessoryDriver.async_start"
|
||||
):
|
||||
await async_init_entry(hass, entry)
|
||||
|
||||
@@ -674,7 +674,7 @@ async def test_homekit_too_many_accessories(hass, hk_driver, caplog, mock_zeroco
|
||||
hass.states.async_set("light.demo2", "on")
|
||||
hass.states.async_set("light.demo3", "on")
|
||||
|
||||
with patch("pyhap.accessory_driver.AccessoryDriver.start_service"), patch(
|
||||
with patch("pyhap.accessory_driver.AccessoryDriver.async_start"), patch(
|
||||
"pyhap.accessory_driver.AccessoryDriver.add_accessory"
|
||||
), patch(f"{PATH_HOMEKIT}.show_setup_message"), patch(
|
||||
f"{PATH_HOMEKIT}.HomeBridge", _mock_bridge
|
||||
@@ -738,7 +738,7 @@ async def test_homekit_finds_linked_batteries(
|
||||
with patch.object(homekit.bridge, "add_accessory"), patch(
|
||||
f"{PATH_HOMEKIT}.show_setup_message"
|
||||
), patch(f"{PATH_HOMEKIT}.get_accessory") as mock_get_acc, patch(
|
||||
"pyhap.accessory_driver.AccessoryDriver.start_service"
|
||||
"pyhap.accessory_driver.AccessoryDriver.async_start"
|
||||
):
|
||||
await homekit.async_start()
|
||||
await hass.async_block_till_done()
|
||||
@@ -810,7 +810,7 @@ async def test_homekit_async_get_integration_fails(
|
||||
with patch.object(homekit.bridge, "add_accessory"), patch(
|
||||
f"{PATH_HOMEKIT}.show_setup_message"
|
||||
), patch(f"{PATH_HOMEKIT}.get_accessory") as mock_get_acc, patch(
|
||||
"pyhap.accessory_driver.AccessoryDriver.start_service"
|
||||
"pyhap.accessory_driver.AccessoryDriver.async_start"
|
||||
):
|
||||
await homekit.async_start()
|
||||
await hass.async_block_till_done()
|
||||
@@ -895,7 +895,7 @@ async def test_homekit_uses_system_zeroconf(hass, hk_driver, mock_zeroconf):
|
||||
assert await async_setup_component(hass, "zeroconf", {"zeroconf": {}})
|
||||
system_zc = await zeroconf.async_get_instance(hass)
|
||||
|
||||
with patch("pyhap.accessory_driver.AccessoryDriver.start_service"), patch(
|
||||
with patch("pyhap.accessory_driver.AccessoryDriver.async_start"), patch(
|
||||
f"{PATH_HOMEKIT}.HomeKit.async_stop"
|
||||
):
|
||||
entry.add_to_hass(hass)
|
||||
@@ -963,7 +963,7 @@ async def test_homekit_ignored_missing_devices(
|
||||
with patch.object(homekit.bridge, "add_accessory"), patch(
|
||||
f"{PATH_HOMEKIT}.show_setup_message"
|
||||
), patch(f"{PATH_HOMEKIT}.get_accessory") as mock_get_acc, patch(
|
||||
"pyhap.accessory_driver.AccessoryDriver.start_service"
|
||||
"pyhap.accessory_driver.AccessoryDriver.async_start"
|
||||
):
|
||||
await homekit.async_start()
|
||||
await hass.async_block_till_done()
|
||||
@@ -1025,7 +1025,7 @@ async def test_homekit_finds_linked_motion_sensors(
|
||||
with patch.object(homekit.bridge, "add_accessory"), patch(
|
||||
f"{PATH_HOMEKIT}.show_setup_message"
|
||||
), patch(f"{PATH_HOMEKIT}.get_accessory") as mock_get_acc, patch(
|
||||
"pyhap.accessory_driver.AccessoryDriver.start_service"
|
||||
"pyhap.accessory_driver.AccessoryDriver.async_start"
|
||||
):
|
||||
await homekit.async_start()
|
||||
await hass.async_block_till_done()
|
||||
@@ -1090,7 +1090,7 @@ async def test_homekit_finds_linked_humidity_sensors(
|
||||
with patch.object(homekit.bridge, "add_accessory"), patch(
|
||||
f"{PATH_HOMEKIT}.show_setup_message"
|
||||
), patch(f"{PATH_HOMEKIT}.get_accessory") as mock_get_acc, patch(
|
||||
"pyhap.accessory_driver.AccessoryDriver.start_service"
|
||||
"pyhap.accessory_driver.AccessoryDriver.async_start"
|
||||
):
|
||||
await homekit.async_start()
|
||||
await hass.async_block_till_done()
|
||||
@@ -1153,7 +1153,7 @@ async def test_reload(hass, mock_zeroconf):
|
||||
), patch(
|
||||
f"{PATH_HOMEKIT}.get_accessory"
|
||||
), patch(
|
||||
"pyhap.accessory_driver.AccessoryDriver.start_service"
|
||||
"pyhap.accessory_driver.AccessoryDriver.async_start"
|
||||
):
|
||||
mock_homekit2.return_value = homekit = Mock()
|
||||
type(homekit).async_start = AsyncMock()
|
||||
@@ -1205,7 +1205,7 @@ async def test_homekit_start_in_accessory_mode(hass, hk_driver, device_reg):
|
||||
with patch(f"{PATH_HOMEKIT}.HomeKit.add_bridge_accessory") as mock_add_acc, patch(
|
||||
"pyhap.accessory_driver.AccessoryDriver.add_accessory"
|
||||
), patch(f"{PATH_HOMEKIT}.show_setup_message") as mock_setup_msg, patch(
|
||||
"pyhap.accessory_driver.AccessoryDriver.start_service"
|
||||
"pyhap.accessory_driver.AccessoryDriver.async_start"
|
||||
) as hk_driver_start:
|
||||
await homekit.async_start()
|
||||
|
||||
|
||||
@@ -11,6 +11,13 @@ import homeassistant.util.dt as dt_util
|
||||
from tests.components.light.conftest import mock_light_profiles # noqa
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_zeroconf():
|
||||
"""Mock zeroconf."""
|
||||
with mock.patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc:
|
||||
yield mock_zc.return_value
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def utcnow(request):
|
||||
"""Freeze time at a known point."""
|
||||
|
||||
@@ -50,6 +50,38 @@ def create_fanv2_service(accessory):
|
||||
swing_mode.value = 0
|
||||
|
||||
|
||||
def create_fanv2_service_with_min_step(accessory):
|
||||
"""Define fan v2 characteristics as per HAP spec."""
|
||||
service = accessory.add_service(ServicesTypes.FAN_V2)
|
||||
|
||||
cur_state = service.add_char(CharacteristicsTypes.ACTIVE)
|
||||
cur_state.value = 0
|
||||
|
||||
direction = service.add_char(CharacteristicsTypes.ROTATION_DIRECTION)
|
||||
direction.value = 0
|
||||
|
||||
speed = service.add_char(CharacteristicsTypes.ROTATION_SPEED)
|
||||
speed.value = 0
|
||||
speed.minStep = 25
|
||||
|
||||
swing_mode = service.add_char(CharacteristicsTypes.SWING_MODE)
|
||||
swing_mode.value = 0
|
||||
|
||||
|
||||
def create_fanv2_service_without_rotation_speed(accessory):
|
||||
"""Define fan v2 characteristics as per HAP spec."""
|
||||
service = accessory.add_service(ServicesTypes.FAN_V2)
|
||||
|
||||
cur_state = service.add_char(CharacteristicsTypes.ACTIVE)
|
||||
cur_state.value = 0
|
||||
|
||||
direction = service.add_char(CharacteristicsTypes.ROTATION_DIRECTION)
|
||||
direction.value = 0
|
||||
|
||||
swing_mode = service.add_char(CharacteristicsTypes.SWING_MODE)
|
||||
swing_mode.value = 0
|
||||
|
||||
|
||||
async def test_fan_read_state(hass, utcnow):
|
||||
"""Test that we can read the state of a HomeKit fan accessory."""
|
||||
helper = await setup_test_component(hass, create_fan_service)
|
||||
@@ -95,6 +127,29 @@ async def test_turn_on(hass, utcnow):
|
||||
assert helper.characteristics[V1_ROTATION_SPEED].value == 33.0
|
||||
|
||||
|
||||
async def test_turn_on_off_without_rotation_speed(hass, utcnow):
|
||||
"""Test that we can turn a fan on."""
|
||||
helper = await setup_test_component(
|
||||
hass, create_fanv2_service_without_rotation_speed
|
||||
)
|
||||
|
||||
await hass.services.async_call(
|
||||
"fan",
|
||||
"turn_on",
|
||||
{"entity_id": "fan.testdevice"},
|
||||
blocking=True,
|
||||
)
|
||||
assert helper.characteristics[V2_ACTIVE].value == 1
|
||||
|
||||
await hass.services.async_call(
|
||||
"fan",
|
||||
"turn_off",
|
||||
{"entity_id": "fan.testdevice"},
|
||||
blocking=True,
|
||||
)
|
||||
assert helper.characteristics[V2_ACTIVE].value == 0
|
||||
|
||||
|
||||
async def test_turn_off(hass, utcnow):
|
||||
"""Test that we can turn a fan off."""
|
||||
helper = await setup_test_component(hass, create_fan_service)
|
||||
@@ -181,6 +236,7 @@ async def test_speed_read(hass, utcnow):
|
||||
state = await helper.poll_and_get_state()
|
||||
assert state.attributes["speed"] == "high"
|
||||
assert state.attributes["percentage"] == 100
|
||||
assert state.attributes["percentage_step"] == 1.0
|
||||
|
||||
helper.characteristics[V1_ROTATION_SPEED].value = 50
|
||||
state = await helper.poll_and_get_state()
|
||||
@@ -277,6 +333,24 @@ async def test_v2_turn_on(hass, utcnow):
|
||||
assert helper.characteristics[V2_ACTIVE].value == 1
|
||||
assert helper.characteristics[V2_ROTATION_SPEED].value == 33.0
|
||||
|
||||
await hass.services.async_call(
|
||||
"fan",
|
||||
"turn_off",
|
||||
{"entity_id": "fan.testdevice"},
|
||||
blocking=True,
|
||||
)
|
||||
assert helper.characteristics[V2_ACTIVE].value == 0
|
||||
assert helper.characteristics[V2_ROTATION_SPEED].value == 33.0
|
||||
|
||||
await hass.services.async_call(
|
||||
"fan",
|
||||
"turn_on",
|
||||
{"entity_id": "fan.testdevice"},
|
||||
blocking=True,
|
||||
)
|
||||
assert helper.characteristics[V2_ACTIVE].value == 1
|
||||
assert helper.characteristics[V2_ROTATION_SPEED].value == 33.0
|
||||
|
||||
|
||||
async def test_v2_turn_off(hass, utcnow):
|
||||
"""Test that we can turn a fan off."""
|
||||
@@ -355,6 +429,29 @@ async def test_v2_set_percentage(hass, utcnow):
|
||||
assert helper.characteristics[V2_ACTIVE].value == 0
|
||||
|
||||
|
||||
async def test_v2_set_percentage_with_min_step(hass, utcnow):
|
||||
"""Test that we set fan speed by percentage."""
|
||||
helper = await setup_test_component(hass, create_fanv2_service_with_min_step)
|
||||
|
||||
helper.characteristics[V2_ACTIVE].value = 1
|
||||
|
||||
await hass.services.async_call(
|
||||
"fan",
|
||||
"set_percentage",
|
||||
{"entity_id": "fan.testdevice", "percentage": 66},
|
||||
blocking=True,
|
||||
)
|
||||
assert helper.characteristics[V2_ROTATION_SPEED].value == 75
|
||||
|
||||
await hass.services.async_call(
|
||||
"fan",
|
||||
"set_percentage",
|
||||
{"entity_id": "fan.testdevice", "percentage": 0},
|
||||
blocking=True,
|
||||
)
|
||||
assert helper.characteristics[V2_ACTIVE].value == 0
|
||||
|
||||
|
||||
async def test_v2_speed_read(hass, utcnow):
|
||||
"""Test that we can read a fans oscillation."""
|
||||
helper = await setup_test_component(hass, create_fanv2_service)
|
||||
|
||||
@@ -189,6 +189,7 @@ async def test_hue_activate_scene(hass, mock_api):
|
||||
|
||||
assert len(mock_api.mock_requests) == 3
|
||||
assert mock_api.mock_requests[2]["json"]["scene"] == "scene_1"
|
||||
assert "transitiontime" not in mock_api.mock_requests[2]["json"]
|
||||
assert mock_api.mock_requests[2]["path"] == "groups/group_1/action"
|
||||
|
||||
|
||||
|
||||
@@ -1,45 +1,59 @@
|
||||
"""Configure pytest for Litter-Robot tests."""
|
||||
from typing import Optional
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pylitterbot
|
||||
from pylitterbot import Robot
|
||||
import pytest
|
||||
|
||||
from homeassistant.components import litterrobot
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .common import CONFIG, ROBOT_DATA
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
def create_mock_robot(hass):
|
||||
def create_mock_robot(unit_status_code: Optional[str] = None):
|
||||
"""Create a mock Litter-Robot device."""
|
||||
robot = Robot(data=ROBOT_DATA)
|
||||
robot.start_cleaning = AsyncMock()
|
||||
robot.set_power_status = AsyncMock()
|
||||
robot.reset_waste_drawer = AsyncMock()
|
||||
robot.set_sleep_mode = AsyncMock()
|
||||
robot.set_night_light = AsyncMock()
|
||||
robot.set_panel_lockout = AsyncMock()
|
||||
return robot
|
||||
if not (
|
||||
unit_status_code
|
||||
and Robot.UnitStatus(unit_status_code) != Robot.UnitStatus.UNKNOWN
|
||||
):
|
||||
unit_status_code = ROBOT_DATA["unitStatus"]
|
||||
|
||||
with patch.dict(ROBOT_DATA, {"unitStatus": unit_status_code}):
|
||||
robot = Robot(data=ROBOT_DATA)
|
||||
robot.start_cleaning = AsyncMock()
|
||||
robot.set_power_status = AsyncMock()
|
||||
robot.reset_waste_drawer = AsyncMock()
|
||||
robot.set_sleep_mode = AsyncMock()
|
||||
robot.set_night_light = AsyncMock()
|
||||
robot.set_panel_lockout = AsyncMock()
|
||||
return robot
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_hub(hass):
|
||||
"""Mock a Litter-Robot hub."""
|
||||
hub = MagicMock(
|
||||
hass=hass,
|
||||
account=MagicMock(),
|
||||
logged_in=True,
|
||||
coordinator=MagicMock(spec=DataUpdateCoordinator),
|
||||
spec=litterrobot.LitterRobotHub,
|
||||
)
|
||||
hub.coordinator.last_update_success = True
|
||||
hub.account.robots = [create_mock_robot(hass)]
|
||||
return hub
|
||||
def create_mock_account(unit_status_code: Optional[str] = None):
|
||||
"""Create a mock Litter-Robot account."""
|
||||
account = MagicMock(spec=pylitterbot.Account)
|
||||
account.connect = AsyncMock()
|
||||
account.refresh_robots = AsyncMock()
|
||||
account.robots = [create_mock_robot(unit_status_code)]
|
||||
return account
|
||||
|
||||
|
||||
async def setup_hub(hass, mock_hub, platform_domain):
|
||||
@pytest.fixture
|
||||
def mock_account():
|
||||
"""Mock a Litter-Robot account."""
|
||||
return create_mock_account()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_account_with_error():
|
||||
"""Mock a Litter-Robot account with error."""
|
||||
return create_mock_account("BR")
|
||||
|
||||
|
||||
async def setup_integration(hass, mock_account, platform_domain=None):
|
||||
"""Load a Litter-Robot platform with the provided hub."""
|
||||
entry = MockConfigEntry(
|
||||
domain=litterrobot.DOMAIN,
|
||||
@@ -47,9 +61,11 @@ async def setup_hub(hass, mock_hub, platform_domain):
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.litterrobot.LitterRobotHub",
|
||||
return_value=mock_hub,
|
||||
with patch("pylitterbot.Account", return_value=mock_account), patch(
|
||||
"homeassistant.components.litterrobot.PLATFORMS",
|
||||
[platform_domain] if platform_domain else [],
|
||||
):
|
||||
await hass.config_entries.async_forward_entry_setup(entry, platform_domain)
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
return entry
|
||||
|
||||
@@ -4,11 +4,14 @@ from unittest.mock import patch
|
||||
from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException
|
||||
|
||||
from homeassistant import config_entries, setup
|
||||
from homeassistant.components import litterrobot
|
||||
|
||||
from .common import CONF_USERNAME, CONFIG, DOMAIN
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
async def test_form(hass):
|
||||
|
||||
async def test_form(hass, mock_account):
|
||||
"""Test we get the form."""
|
||||
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
@@ -17,10 +20,7 @@ async def test_form(hass):
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {}
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.litterrobot.config_flow.LitterRobotHub.login",
|
||||
return_value=True,
|
||||
), patch(
|
||||
with patch("pylitterbot.Account", return_value=mock_account), patch(
|
||||
"homeassistant.components.litterrobot.async_setup", return_value=True
|
||||
) as mock_setup, patch(
|
||||
"homeassistant.components.litterrobot.async_setup_entry",
|
||||
@@ -38,6 +38,23 @@ async def test_form(hass):
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_already_configured(hass):
|
||||
"""Test we handle already configured."""
|
||||
MockConfigEntry(
|
||||
domain=litterrobot.DOMAIN,
|
||||
data=CONFIG[litterrobot.DOMAIN],
|
||||
).add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
data=CONFIG[litterrobot.DOMAIN],
|
||||
)
|
||||
|
||||
assert result["type"] == "abort"
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_form_invalid_auth(hass):
|
||||
"""Test we handle invalid auth."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
@@ -45,7 +62,7 @@ async def test_form_invalid_auth(hass):
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.litterrobot.config_flow.LitterRobotHub.login",
|
||||
"pylitterbot.Account.connect",
|
||||
side_effect=LitterRobotLoginException,
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
@@ -63,7 +80,7 @@ async def test_form_cannot_connect(hass):
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.litterrobot.config_flow.LitterRobotHub.login",
|
||||
"pylitterbot.Account.connect",
|
||||
side_effect=LitterRobotException,
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
@@ -81,7 +98,7 @@ async def test_form_unknown_error(hass):
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.litterrobot.config_flow.LitterRobotHub.login",
|
||||
"pylitterbot.Account.connect",
|
||||
side_effect=Exception,
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
|
||||
@@ -1,20 +1,48 @@
|
||||
"""Test Litter-Robot setup process."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException
|
||||
import pytest
|
||||
|
||||
from homeassistant.components import litterrobot
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.config_entries import (
|
||||
ENTRY_STATE_SETUP_ERROR,
|
||||
ENTRY_STATE_SETUP_RETRY,
|
||||
)
|
||||
|
||||
from .common import CONFIG
|
||||
from .conftest import setup_integration
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_unload_entry(hass):
|
||||
async def test_unload_entry(hass, mock_account):
|
||||
"""Test being able to unload an entry."""
|
||||
entry = await setup_integration(hass, mock_account)
|
||||
|
||||
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.data[litterrobot.DOMAIN] == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"side_effect,expected_state",
|
||||
(
|
||||
(LitterRobotLoginException, ENTRY_STATE_SETUP_ERROR),
|
||||
(LitterRobotException, ENTRY_STATE_SETUP_RETRY),
|
||||
),
|
||||
)
|
||||
async def test_entry_not_setup(hass, side_effect, expected_state):
|
||||
"""Test being able to handle config entry not setup."""
|
||||
entry = MockConfigEntry(
|
||||
domain=litterrobot.DOMAIN,
|
||||
data=CONFIG[litterrobot.DOMAIN],
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
assert await async_setup_component(hass, litterrobot.DOMAIN, {}) is True
|
||||
assert await litterrobot.async_unload_entry(hass, entry)
|
||||
assert hass.data[litterrobot.DOMAIN] == {}
|
||||
with patch(
|
||||
"pylitterbot.Account.connect",
|
||||
side_effect=side_effect,
|
||||
):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
assert entry.state == expected_state
|
||||
|
||||
@@ -1,20 +1,57 @@
|
||||
"""Test the Litter-Robot sensor entity."""
|
||||
from unittest.mock import Mock
|
||||
|
||||
from homeassistant.components.litterrobot.sensor import LitterRobotSleepTimeSensor
|
||||
from homeassistant.components.sensor import DOMAIN as PLATFORM_DOMAIN
|
||||
from homeassistant.const import PERCENTAGE
|
||||
from homeassistant.const import DEVICE_CLASS_TIMESTAMP, PERCENTAGE
|
||||
|
||||
from .conftest import setup_hub
|
||||
from .conftest import create_mock_robot, setup_integration
|
||||
|
||||
ENTITY_ID = "sensor.test_waste_drawer"
|
||||
WASTE_DRAWER_ENTITY_ID = "sensor.test_waste_drawer"
|
||||
|
||||
|
||||
async def test_sensor(hass, mock_hub):
|
||||
"""Tests the sensor entity was set up."""
|
||||
await setup_hub(hass, mock_hub, PLATFORM_DOMAIN)
|
||||
async def test_waste_drawer_sensor(hass, mock_account):
|
||||
"""Tests the waste drawer sensor entity was set up."""
|
||||
await setup_integration(hass, mock_account, PLATFORM_DOMAIN)
|
||||
|
||||
sensor = hass.states.get(ENTITY_ID)
|
||||
sensor = hass.states.get(WASTE_DRAWER_ENTITY_ID)
|
||||
assert sensor
|
||||
assert sensor.state == "50"
|
||||
assert sensor.attributes["cycle_count"] == 15
|
||||
assert sensor.attributes["cycle_capacity"] == 30
|
||||
assert sensor.attributes["cycles_after_drawer_full"] == 0
|
||||
assert sensor.attributes["unit_of_measurement"] == PERCENTAGE
|
||||
|
||||
|
||||
async def test_sleep_time_sensor_with_none_state(hass):
|
||||
"""Tests the sleep mode start time sensor where sleep mode is inactive."""
|
||||
robot = create_mock_robot()
|
||||
robot.sleep_mode_active = False
|
||||
sensor = LitterRobotSleepTimeSensor(
|
||||
robot, "Sleep Mode Start Time", Mock(), "sleep_mode_start_time"
|
||||
)
|
||||
|
||||
assert sensor
|
||||
assert sensor.state is None
|
||||
assert sensor.device_class == DEVICE_CLASS_TIMESTAMP
|
||||
|
||||
|
||||
async def test_gauge_icon():
|
||||
"""Test icon generator for gauge sensor."""
|
||||
from homeassistant.components.litterrobot.sensor import icon_for_gauge_level
|
||||
|
||||
GAUGE_EMPTY = "mdi:gauge-empty"
|
||||
GAUGE_LOW = "mdi:gauge-low"
|
||||
GAUGE = "mdi:gauge"
|
||||
GAUGE_FULL = "mdi:gauge-full"
|
||||
|
||||
assert icon_for_gauge_level(None) == GAUGE_EMPTY
|
||||
assert icon_for_gauge_level(0) == GAUGE_EMPTY
|
||||
assert icon_for_gauge_level(5) == GAUGE_LOW
|
||||
assert icon_for_gauge_level(40) == GAUGE
|
||||
assert icon_for_gauge_level(80) == GAUGE_FULL
|
||||
assert icon_for_gauge_level(100) == GAUGE_FULL
|
||||
|
||||
assert icon_for_gauge_level(None, 10) == GAUGE_EMPTY
|
||||
assert icon_for_gauge_level(0, 10) == GAUGE_EMPTY
|
||||
assert icon_for_gauge_level(5, 10) == GAUGE_EMPTY
|
||||
assert icon_for_gauge_level(40, 10) == GAUGE_LOW
|
||||
assert icon_for_gauge_level(80, 10) == GAUGE
|
||||
assert icon_for_gauge_level(100, 10) == GAUGE_FULL
|
||||
|
||||
@@ -12,7 +12,7 @@ from homeassistant.components.switch import (
|
||||
from homeassistant.const import ATTR_ENTITY_ID, STATE_ON
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from .conftest import setup_hub
|
||||
from .conftest import setup_integration
|
||||
|
||||
from tests.common import async_fire_time_changed
|
||||
|
||||
@@ -20,9 +20,9 @@ NIGHT_LIGHT_MODE_ENTITY_ID = "switch.test_night_light_mode"
|
||||
PANEL_LOCKOUT_ENTITY_ID = "switch.test_panel_lockout"
|
||||
|
||||
|
||||
async def test_switch(hass, mock_hub):
|
||||
async def test_switch(hass, mock_account):
|
||||
"""Tests the switch entity was set up."""
|
||||
await setup_hub(hass, mock_hub, PLATFORM_DOMAIN)
|
||||
await setup_integration(hass, mock_account, PLATFORM_DOMAIN)
|
||||
|
||||
switch = hass.states.get(NIGHT_LIGHT_MODE_ENTITY_ID)
|
||||
assert switch
|
||||
@@ -36,9 +36,9 @@ async def test_switch(hass, mock_hub):
|
||||
(PANEL_LOCKOUT_ENTITY_ID, "set_panel_lockout"),
|
||||
],
|
||||
)
|
||||
async def test_on_off_commands(hass, mock_hub, entity_id, robot_command):
|
||||
async def test_on_off_commands(hass, mock_account, entity_id, robot_command):
|
||||
"""Test sending commands to the switch."""
|
||||
await setup_hub(hass, mock_hub, PLATFORM_DOMAIN)
|
||||
await setup_integration(hass, mock_account, PLATFORM_DOMAIN)
|
||||
|
||||
switch = hass.states.get(entity_id)
|
||||
assert switch
|
||||
@@ -48,12 +48,14 @@ async def test_on_off_commands(hass, mock_hub, entity_id, robot_command):
|
||||
count = 0
|
||||
for service in [SERVICE_TURN_ON, SERVICE_TURN_OFF]:
|
||||
count += 1
|
||||
|
||||
await hass.services.async_call(
|
||||
PLATFORM_DOMAIN,
|
||||
service,
|
||||
data,
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
future = utcnow() + timedelta(seconds=REFRESH_WAIT_TIME)
|
||||
async_fire_time_changed(hass, future)
|
||||
assert getattr(mock_hub.account.robots[0], robot_command).call_count == count
|
||||
assert getattr(mock_account.robots[0], robot_command).call_count == count
|
||||
|
||||
@@ -12,20 +12,21 @@ from homeassistant.components.vacuum import (
|
||||
SERVICE_TURN_OFF,
|
||||
SERVICE_TURN_ON,
|
||||
STATE_DOCKED,
|
||||
STATE_ERROR,
|
||||
)
|
||||
from homeassistant.const import ATTR_COMMAND, ATTR_ENTITY_ID
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from .conftest import setup_hub
|
||||
from .conftest import setup_integration
|
||||
|
||||
from tests.common import async_fire_time_changed
|
||||
|
||||
ENTITY_ID = "vacuum.test_litter_box"
|
||||
|
||||
|
||||
async def test_vacuum(hass, mock_hub):
|
||||
async def test_vacuum(hass, mock_account):
|
||||
"""Tests the vacuum entity was set up."""
|
||||
await setup_hub(hass, mock_hub, PLATFORM_DOMAIN)
|
||||
await setup_integration(hass, mock_account, PLATFORM_DOMAIN)
|
||||
|
||||
vacuum = hass.states.get(ENTITY_ID)
|
||||
assert vacuum
|
||||
@@ -33,6 +34,15 @@ async def test_vacuum(hass, mock_hub):
|
||||
assert vacuum.attributes["is_sleeping"] is False
|
||||
|
||||
|
||||
async def test_vacuum_with_error(hass, mock_account_with_error):
|
||||
"""Tests a vacuum entity with an error."""
|
||||
await setup_integration(hass, mock_account_with_error, PLATFORM_DOMAIN)
|
||||
|
||||
vacuum = hass.states.get(ENTITY_ID)
|
||||
assert vacuum
|
||||
assert vacuum.state == STATE_ERROR
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"service,command,extra",
|
||||
[
|
||||
@@ -52,14 +62,22 @@ async def test_vacuum(hass, mock_hub):
|
||||
ATTR_PARAMS: {"enabled": True, "sleep_time": "22:30"},
|
||||
},
|
||||
),
|
||||
(
|
||||
SERVICE_SEND_COMMAND,
|
||||
"set_sleep_mode",
|
||||
{
|
||||
ATTR_COMMAND: "set_sleep_mode",
|
||||
ATTR_PARAMS: {"enabled": True, "sleep_time": None},
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_commands(hass, mock_hub, service, command, extra):
|
||||
async def test_commands(hass, mock_account, service, command, extra):
|
||||
"""Test sending commands to the vacuum."""
|
||||
await setup_hub(hass, mock_hub, PLATFORM_DOMAIN)
|
||||
await setup_integration(hass, mock_account, PLATFORM_DOMAIN)
|
||||
|
||||
vacuum = hass.states.get(ENTITY_ID)
|
||||
assert vacuum is not None
|
||||
assert vacuum
|
||||
assert vacuum.state == STATE_DOCKED
|
||||
|
||||
data = {ATTR_ENTITY_ID: ENTITY_ID}
|
||||
@@ -74,4 +92,4 @@ async def test_commands(hass, mock_hub, service, command, extra):
|
||||
)
|
||||
future = utcnow() + timedelta(seconds=REFRESH_WAIT_TIME)
|
||||
async_fire_time_changed(hass, future)
|
||||
getattr(mock_hub.account.robots[0], command).assert_called_once()
|
||||
getattr(mock_account.robots[0], command).assert_called_once()
|
||||
|
||||
@@ -246,6 +246,10 @@ async def test_templates_with_entities(hass, calls):
|
||||
await hass.async_block_till_done()
|
||||
_verify(hass, STATE_ON, None, 0, True, DIRECTION_FORWARD, None)
|
||||
|
||||
hass.states.async_set(_STATE_INPUT_BOOLEAN, False)
|
||||
await hass.async_block_till_done()
|
||||
_verify(hass, STATE_OFF, None, 0, True, DIRECTION_FORWARD, None)
|
||||
|
||||
|
||||
async def test_templates_with_entities_and_invalid_percentage(hass, calls):
|
||||
"""Test templates with values from other entities."""
|
||||
@@ -274,7 +278,7 @@ async def test_templates_with_entities_and_invalid_percentage(hass, calls):
|
||||
await hass.async_start()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
_verify(hass, STATE_OFF, SPEED_OFF, 0, None, None, None)
|
||||
_verify(hass, STATE_ON, SPEED_OFF, 0, None, None, None)
|
||||
|
||||
hass.states.async_set("sensor.percentage", "33")
|
||||
await hass.async_block_till_done()
|
||||
@@ -299,7 +303,7 @@ async def test_templates_with_entities_and_invalid_percentage(hass, calls):
|
||||
hass.states.async_set("sensor.percentage", "0")
|
||||
await hass.async_block_till_done()
|
||||
|
||||
_verify(hass, STATE_OFF, SPEED_OFF, 0, None, None, None)
|
||||
_verify(hass, STATE_ON, SPEED_OFF, 0, None, None, None)
|
||||
|
||||
|
||||
async def test_templates_with_entities_and_preset_modes(hass, calls):
|
||||
|
||||
@@ -21,13 +21,7 @@ from tests.common import MockEntity, MockEntityPlatform, async_mock_service
|
||||
|
||||
async def test_call_service(hass, websocket_client):
|
||||
"""Test call service command."""
|
||||
calls = []
|
||||
|
||||
@callback
|
||||
def service_call(call):
|
||||
calls.append(call)
|
||||
|
||||
hass.services.async_register("domain_test", "test_service", service_call)
|
||||
calls = async_mock_service(hass, "domain_test", "test_service")
|
||||
|
||||
await websocket_client.send_json(
|
||||
{
|
||||
@@ -54,13 +48,7 @@ async def test_call_service(hass, websocket_client):
|
||||
|
||||
async def test_call_service_target(hass, websocket_client):
|
||||
"""Test call service command with target."""
|
||||
calls = []
|
||||
|
||||
@callback
|
||||
def service_call(call):
|
||||
calls.append(call)
|
||||
|
||||
hass.services.async_register("domain_test", "test_service", service_call)
|
||||
calls = async_mock_service(hass, "domain_test", "test_service")
|
||||
|
||||
await websocket_client.send_json(
|
||||
{
|
||||
@@ -93,6 +81,28 @@ async def test_call_service_target(hass, websocket_client):
|
||||
}
|
||||
|
||||
|
||||
async def test_call_service_target_template(hass, websocket_client):
|
||||
"""Test call service command with target does not allow template."""
|
||||
await websocket_client.send_json(
|
||||
{
|
||||
"id": 5,
|
||||
"type": "call_service",
|
||||
"domain": "domain_test",
|
||||
"service": "test_service",
|
||||
"service_data": {"hello": "world"},
|
||||
"target": {
|
||||
"entity_id": "{{ 1 }}",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
msg = await websocket_client.receive_json()
|
||||
assert msg["id"] == 5
|
||||
assert msg["type"] == const.TYPE_RESULT
|
||||
assert not msg["success"]
|
||||
assert msg["error"]["code"] == const.ERR_INVALID_FORMAT
|
||||
|
||||
|
||||
async def test_call_service_not_found(hass, websocket_client):
|
||||
"""Test call service command."""
|
||||
await websocket_client.send_json(
|
||||
@@ -232,7 +242,6 @@ async def test_call_service_error(hass, websocket_client):
|
||||
)
|
||||
|
||||
msg = await websocket_client.receive_json()
|
||||
print(msg)
|
||||
assert msg["id"] == 5
|
||||
assert msg["type"] == const.TYPE_RESULT
|
||||
assert msg["success"] is False
|
||||
@@ -249,7 +258,6 @@ async def test_call_service_error(hass, websocket_client):
|
||||
)
|
||||
|
||||
msg = await websocket_client.receive_json()
|
||||
print(msg)
|
||||
assert msg["id"] == 6
|
||||
assert msg["type"] == const.TYPE_RESULT
|
||||
assert msg["success"] is False
|
||||
|
||||
@@ -100,41 +100,28 @@ async def test_static_config_with_invalid_host(hass):
|
||||
async def test_discovery(hass, pywemo_registry):
|
||||
"""Verify that discovery dispatches devices to the platform for setup."""
|
||||
|
||||
def create_device(uuid, location):
|
||||
def create_device(counter):
|
||||
"""Create a unique mock Motion detector device for each counter value."""
|
||||
device = create_autospec(pywemo.Motion, instance=True)
|
||||
device.host = location
|
||||
device.port = MOCK_PORT
|
||||
device.name = f"{MOCK_NAME}_{uuid}"
|
||||
device.serialnumber = f"{MOCK_SERIAL_NUMBER}_{uuid}"
|
||||
device.host = f"{MOCK_HOST}_{counter}"
|
||||
device.port = MOCK_PORT + counter
|
||||
device.name = f"{MOCK_NAME}_{counter}"
|
||||
device.serialnumber = f"{MOCK_SERIAL_NUMBER}_{counter}"
|
||||
device.model_name = "Motion"
|
||||
device.get_state.return_value = 0 # Default to Off
|
||||
return device
|
||||
|
||||
def create_upnp_entry(counter):
|
||||
return pywemo.ssdp.UPNPEntry.from_response(
|
||||
"\r\n".join(
|
||||
[
|
||||
"",
|
||||
f"LOCATION: http://192.168.1.100:{counter}/setup.xml",
|
||||
f"USN: uuid:Socket-1_0-SERIAL{counter}::upnp:rootdevice",
|
||||
"",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
upnp_entries = [create_upnp_entry(0), create_upnp_entry(1)]
|
||||
pywemo_devices = [create_device(0), create_device(1)]
|
||||
# Setup the component and start discovery.
|
||||
with patch(
|
||||
"pywemo.discovery.device_from_uuid_and_location", side_effect=create_device
|
||||
), patch("pywemo.ssdp.scan", return_value=upnp_entries) as mock_scan:
|
||||
"pywemo.discover_devices", return_value=pywemo_devices
|
||||
) as mock_discovery:
|
||||
assert await async_setup_component(
|
||||
hass, DOMAIN, {DOMAIN: {CONF_DISCOVERY: True}}
|
||||
)
|
||||
await pywemo_registry.semaphore.acquire() # Returns after platform setup.
|
||||
mock_scan.assert_called()
|
||||
# Add two of the same entries to test deduplication.
|
||||
upnp_entries.extend([create_upnp_entry(2), create_upnp_entry(2)])
|
||||
mock_discovery.assert_called()
|
||||
pywemo_devices.append(create_device(2))
|
||||
|
||||
# Test that discovery runs periodically and the async_dispatcher_send code works.
|
||||
async_fire_time_changed(
|
||||
|
||||
@@ -16,6 +16,7 @@ PROPERTY_DOOR_STATUS_BINARY_SENSOR = (
|
||||
CLIMATE_RADIO_THERMOSTAT_ENTITY = "climate.z_wave_thermostat"
|
||||
CLIMATE_DANFOSS_LC13_ENTITY = "climate.living_connect_z_thermostat"
|
||||
CLIMATE_FLOOR_THERMOSTAT_ENTITY = "climate.floor_thermostat"
|
||||
CLIMATE_MAIN_HEAT_ACTIONNER = "climate.main_heat_actionner"
|
||||
BULB_6_MULTI_COLOR_LIGHT_ENTITY = "light.bulb_6_multi_color"
|
||||
EATON_RF9640_ENTITY = "light.allloaddimmer"
|
||||
AEON_SMART_SWITCH_LIGHT_ENTITY = "light.smart_switch_6"
|
||||
|
||||
@@ -240,6 +240,12 @@ def nortek_thermostat_state_fixture():
|
||||
return json.loads(load_fixture("zwave_js/nortek_thermostat_state.json"))
|
||||
|
||||
|
||||
@pytest.fixture(name="srt321_hrt4_zw_state", scope="session")
|
||||
def srt321_hrt4_zw_state_fixture():
|
||||
"""Load the climate HRT4-ZW / SRT321 / SRT322 thermostat node state fixture data."""
|
||||
return json.loads(load_fixture("zwave_js/srt321_hrt4_zw_state.json"))
|
||||
|
||||
|
||||
@pytest.fixture(name="chain_actuator_zws12_state", scope="session")
|
||||
def window_cover_state_fixture():
|
||||
"""Load the window cover node state fixture data."""
|
||||
@@ -282,6 +288,18 @@ def aeotec_radiator_thermostat_state_fixture():
|
||||
return json.loads(load_fixture("zwave_js/aeotec_radiator_thermostat_state.json"))
|
||||
|
||||
|
||||
@pytest.fixture(name="inovelli_lzw36_state", scope="session")
|
||||
def inovelli_lzw36_state_fixture():
|
||||
"""Load the Inovelli LZW36 node state fixture data."""
|
||||
return json.loads(load_fixture("zwave_js/inovelli_lzw36_state.json"))
|
||||
|
||||
|
||||
@pytest.fixture(name="null_name_check_state", scope="session")
|
||||
def null_name_check_state_fixture():
|
||||
"""Load the null name check node state fixture data."""
|
||||
return json.loads(load_fixture("zwave_js/null_name_check_state.json"))
|
||||
|
||||
|
||||
@pytest.fixture(name="client")
|
||||
def mock_client_fixture(controller_state, version_state):
|
||||
"""Mock a client."""
|
||||
@@ -417,6 +435,14 @@ def nortek_thermostat_fixture(client, nortek_thermostat_state):
|
||||
return node
|
||||
|
||||
|
||||
@pytest.fixture(name="srt321_hrt4_zw")
|
||||
def srt321_hrt4_zw_fixture(client, srt321_hrt4_zw_state):
|
||||
"""Mock a HRT4-ZW / SRT321 / SRT322 thermostat node."""
|
||||
node = Node(client, copy.deepcopy(srt321_hrt4_zw_state))
|
||||
client.driver.controller.nodes[node.node_id] = node
|
||||
return node
|
||||
|
||||
|
||||
@pytest.fixture(name="aeotec_radiator_thermostat")
|
||||
def aeotec_radiator_thermostat_fixture(client, aeotec_radiator_thermostat_state):
|
||||
"""Mock a Aeotec thermostat node."""
|
||||
@@ -470,6 +496,14 @@ def in_wall_smart_fan_control_fixture(client, in_wall_smart_fan_control_state):
|
||||
return node
|
||||
|
||||
|
||||
@pytest.fixture(name="null_name_check")
|
||||
def null_name_check_fixture(client, null_name_check_state):
|
||||
"""Mock a node with no name."""
|
||||
node = Node(client, copy.deepcopy(null_name_check_state))
|
||||
client.driver.controller.nodes[node.node_id] = node
|
||||
return node
|
||||
|
||||
|
||||
@pytest.fixture(name="multiple_devices")
|
||||
def multiple_devices_fixture(
|
||||
client, climate_radio_thermostat_ct100_plus_state, lock_schlage_be469_state
|
||||
|
||||
@@ -70,7 +70,7 @@ async def test_websocket_api(hass, integration, multisensor_6, hass_ws_client):
|
||||
result = msg["result"]
|
||||
|
||||
assert len(result) == 61
|
||||
key = "52-112-0-2-00-00"
|
||||
key = "52-112-0-2"
|
||||
assert result[key]["property"] == 2
|
||||
assert result[key]["metadata"]["type"] == "number"
|
||||
assert result[key]["configuration_value_type"] == "enumerated"
|
||||
|
||||
@@ -24,13 +24,22 @@ from homeassistant.components.climate.const import (
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
SERVICE_SET_PRESET_MODE,
|
||||
SERVICE_SET_TEMPERATURE,
|
||||
SUPPORT_FAN_MODE,
|
||||
SUPPORT_PRESET_MODE,
|
||||
SUPPORT_TARGET_TEMPERATURE,
|
||||
SUPPORT_TARGET_TEMPERATURE_RANGE,
|
||||
)
|
||||
from homeassistant.components.zwave_js.climate import ATTR_FAN_STATE
|
||||
from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
ATTR_TEMPERATURE,
|
||||
)
|
||||
|
||||
from .common import (
|
||||
CLIMATE_DANFOSS_LC13_ENTITY,
|
||||
CLIMATE_FLOOR_THERMOSTAT_ENTITY,
|
||||
CLIMATE_MAIN_HEAT_ACTIONNER,
|
||||
CLIMATE_RADIO_THERMOSTAT_ENTITY,
|
||||
)
|
||||
|
||||
@@ -57,6 +66,13 @@ async def test_thermostat_v2(
|
||||
assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE
|
||||
assert state.attributes[ATTR_FAN_MODE] == "Auto low"
|
||||
assert state.attributes[ATTR_FAN_STATE] == "Idle / off"
|
||||
assert (
|
||||
state.attributes[ATTR_SUPPORTED_FEATURES]
|
||||
== SUPPORT_PRESET_MODE
|
||||
| SUPPORT_TARGET_TEMPERATURE
|
||||
| SUPPORT_TARGET_TEMPERATURE_RANGE
|
||||
| SUPPORT_FAN_MODE
|
||||
)
|
||||
|
||||
# Test setting preset mode
|
||||
await hass.services.async_call(
|
||||
@@ -404,9 +420,13 @@ async def test_setpoint_thermostat(hass, client, climate_danfoss_lc_13, integrat
|
||||
|
||||
assert state
|
||||
assert state.state == HVAC_MODE_HEAT
|
||||
assert state.attributes[ATTR_TEMPERATURE] == 25
|
||||
assert state.attributes[ATTR_TEMPERATURE] == 14
|
||||
assert state.attributes[ATTR_HVAC_MODES] == [HVAC_MODE_HEAT]
|
||||
assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE
|
||||
assert (
|
||||
state.attributes[ATTR_SUPPORTED_FEATURES]
|
||||
== SUPPORT_PRESET_MODE | SUPPORT_TARGET_TEMPERATURE
|
||||
)
|
||||
|
||||
client.async_send_command_no_wait.reset_mock()
|
||||
|
||||
@@ -431,6 +451,7 @@ async def test_setpoint_thermostat(hass, client, climate_danfoss_lc_13, integrat
|
||||
"commandClassName": "Thermostat Setpoint",
|
||||
"property": "setpoint",
|
||||
"propertyName": "setpoint",
|
||||
"propertyKey": 1,
|
||||
"propertyKeyName": "Heating",
|
||||
"ccVersion": 2,
|
||||
"metadata": {
|
||||
@@ -440,7 +461,7 @@ async def test_setpoint_thermostat(hass, client, climate_danfoss_lc_13, integrat
|
||||
"unit": "\u00b0C",
|
||||
"ccSpecific": {"setpointType": 1},
|
||||
},
|
||||
"value": 25,
|
||||
"value": 14,
|
||||
}
|
||||
assert args["value"] == 21.5
|
||||
|
||||
@@ -458,6 +479,7 @@ async def test_setpoint_thermostat(hass, client, climate_danfoss_lc_13, integrat
|
||||
"commandClass": 67,
|
||||
"endpoint": 0,
|
||||
"property": "setpoint",
|
||||
"propertyKey": 1,
|
||||
"propertyKeyName": "Heating",
|
||||
"propertyName": "setpoint",
|
||||
"newValue": 23,
|
||||
@@ -488,3 +510,24 @@ async def test_thermostat_heatit(hass, client, climate_heatit_z_trm3, integratio
|
||||
assert state.attributes[ATTR_TEMPERATURE] == 22.5
|
||||
assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE
|
||||
assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE
|
||||
assert (
|
||||
state.attributes[ATTR_SUPPORTED_FEATURES]
|
||||
== SUPPORT_PRESET_MODE | SUPPORT_TARGET_TEMPERATURE
|
||||
)
|
||||
|
||||
|
||||
async def test_thermostat_srt321_hrt4_zw(hass, client, srt321_hrt4_zw, integration):
|
||||
"""Test a climate entity from a HRT4-ZW / SRT321 thermostat device.
|
||||
|
||||
This device currently has no setpoint values.
|
||||
"""
|
||||
state = hass.states.get(CLIMATE_MAIN_HEAT_ACTIONNER)
|
||||
|
||||
assert state
|
||||
assert state.state == HVAC_MODE_OFF
|
||||
assert state.attributes[ATTR_HVAC_MODES] == [
|
||||
HVAC_MODE_OFF,
|
||||
HVAC_MODE_HEAT,
|
||||
]
|
||||
assert state.attributes[ATTR_CURRENT_TEMPERATURE] is None
|
||||
assert state.attributes[ATTR_SUPPORTED_FEATURES] == SUPPORT_PRESET_MODE
|
||||
|
||||
@@ -184,7 +184,16 @@ async def test_manual_errors(
|
||||
|
||||
async def test_manual_already_configured(hass):
|
||||
"""Test that only one unique instance is allowed."""
|
||||
entry = MockConfigEntry(domain=DOMAIN, data={}, title=TITLE, unique_id=1234)
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
"url": "ws://localhost:3000",
|
||||
"use_addon": True,
|
||||
"integration_created_addon": True,
|
||||
},
|
||||
title=TITLE,
|
||||
unique_id=1234,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||
@@ -198,12 +207,15 @@ async def test_manual_already_configured(hass):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
"url": "ws://localhost:3000",
|
||||
"url": "ws://1.1.1.1:3001",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] == "abort"
|
||||
assert result["reason"] == "already_configured"
|
||||
assert entry.data["url"] == "ws://1.1.1.1:3001"
|
||||
assert entry.data["use_addon"] is False
|
||||
assert entry.data["integration_created_addon"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}])
|
||||
@@ -507,49 +519,6 @@ async def test_not_addon(hass, supervisor):
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_addon_already_configured(hass, supervisor):
|
||||
"""Test add-on already configured leads to manual step."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN, data={"use_addon": True}, title=TITLE, unique_id=5678
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "manual"
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.zwave_js.async_setup", return_value=True
|
||||
) as mock_setup, patch(
|
||||
"homeassistant.components.zwave_js.async_setup_entry",
|
||||
return_value=True,
|
||||
) as mock_setup_entry:
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
"url": "ws://localhost:3000",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["title"] == TITLE
|
||||
assert result["data"] == {
|
||||
"url": "ws://localhost:3000",
|
||||
"usb_path": None,
|
||||
"network_key": None,
|
||||
"use_addon": False,
|
||||
"integration_created_addon": False,
|
||||
}
|
||||
assert len(mock_setup.mock_calls) == 1
|
||||
assert len(mock_setup_entry.mock_calls) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}])
|
||||
async def test_addon_running(
|
||||
hass,
|
||||
@@ -661,9 +630,18 @@ async def test_addon_running_already_configured(
|
||||
hass, supervisor, addon_running, addon_options, get_addon_discovery_info
|
||||
):
|
||||
"""Test that only one unique instance is allowed when add-on is running."""
|
||||
addon_options["device"] = "/test"
|
||||
addon_options["network_key"] = "abc123"
|
||||
entry = MockConfigEntry(domain=DOMAIN, data={}, title=TITLE, unique_id=1234)
|
||||
addon_options["device"] = "/test_new"
|
||||
addon_options["network_key"] = "def456"
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
"url": "ws://localhost:3000",
|
||||
"usb_path": "/test",
|
||||
"network_key": "abc123",
|
||||
},
|
||||
title=TITLE,
|
||||
unique_id=1234,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||
@@ -680,6 +658,9 @@ async def test_addon_running_already_configured(
|
||||
|
||||
assert result["type"] == "abort"
|
||||
assert result["reason"] == "already_configured"
|
||||
assert entry.data["url"] == "ws://host1:3001"
|
||||
assert entry.data["usb_path"] == "/test_new"
|
||||
assert entry.data["network_key"] == "def456"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}])
|
||||
@@ -885,7 +866,16 @@ async def test_addon_installed_already_configured(
|
||||
get_addon_discovery_info,
|
||||
):
|
||||
"""Test that only one unique instance is allowed when add-on is installed."""
|
||||
entry = MockConfigEntry(domain=DOMAIN, data={}, title=TITLE, unique_id=1234)
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
"url": "ws://localhost:3000",
|
||||
"usb_path": "/test",
|
||||
"network_key": "abc123",
|
||||
},
|
||||
title=TITLE,
|
||||
unique_id=1234,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||
@@ -904,7 +894,7 @@ async def test_addon_installed_already_configured(
|
||||
assert result["step_id"] == "configure_addon"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"usb_path": "/test", "network_key": "abc123"}
|
||||
result["flow_id"], {"usb_path": "/test_new", "network_key": "def456"}
|
||||
)
|
||||
|
||||
assert result["type"] == "progress"
|
||||
@@ -915,6 +905,9 @@ async def test_addon_installed_already_configured(
|
||||
|
||||
assert result["type"] == "abort"
|
||||
assert result["reason"] == "already_configured"
|
||||
assert entry.data["url"] == "ws://host1:3001"
|
||||
assert entry.data["usb_path"] == "/test_new"
|
||||
assert entry.data["network_key"] == "def456"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}])
|
||||
|
||||
@@ -160,7 +160,7 @@ async def test_unique_id_migration_dupes(
|
||||
|
||||
# Check that new RegistryEntry is using new unique ID format
|
||||
entity_entry = ent_reg.async_get(AIR_TEMPERATURE_SENSOR)
|
||||
new_unique_id = f"{client.driver.controller.home_id}.52-49-0-Air temperature-00-00"
|
||||
new_unique_id = f"{client.driver.controller.home_id}.52-49-0-Air temperature"
|
||||
assert entity_entry.unique_id == new_unique_id
|
||||
|
||||
assert ent_reg.async_get(f"{AIR_TEMPERATURE_SENSOR}_1") is None
|
||||
@@ -195,7 +195,7 @@ async def test_unique_id_migration_v1(hass, multisensor_6_state, client, integra
|
||||
|
||||
# Check that new RegistryEntry is using new unique ID format
|
||||
entity_entry = ent_reg.async_get(AIR_TEMPERATURE_SENSOR)
|
||||
new_unique_id = f"{client.driver.controller.home_id}.52-49-0-Air temperature-00-00"
|
||||
new_unique_id = f"{client.driver.controller.home_id}.52-49-0-Air temperature"
|
||||
assert entity_entry.unique_id == new_unique_id
|
||||
|
||||
|
||||
@@ -228,7 +228,147 @@ async def test_unique_id_migration_v2(hass, multisensor_6_state, client, integra
|
||||
|
||||
# Check that new RegistryEntry is using new unique ID format
|
||||
entity_entry = ent_reg.async_get(ILLUMINANCE_SENSOR)
|
||||
new_unique_id = f"{client.driver.controller.home_id}.52-49-0-Illuminance-00-00"
|
||||
new_unique_id = f"{client.driver.controller.home_id}.52-49-0-Illuminance"
|
||||
assert entity_entry.unique_id == new_unique_id
|
||||
|
||||
|
||||
async def test_unique_id_migration_v3(hass, multisensor_6_state, client, integration):
|
||||
"""Test unique ID is migrated from old format to new (version 3)."""
|
||||
ent_reg = entity_registry.async_get(hass)
|
||||
# Migrate version 2
|
||||
ILLUMINANCE_SENSOR = "sensor.multisensor_6_illuminance"
|
||||
entity_name = ILLUMINANCE_SENSOR.split(".")[1]
|
||||
|
||||
# Create entity RegistryEntry using old unique ID format
|
||||
old_unique_id = f"{client.driver.controller.home_id}.52-49-0-Illuminance-00-00"
|
||||
entity_entry = ent_reg.async_get_or_create(
|
||||
"sensor",
|
||||
DOMAIN,
|
||||
old_unique_id,
|
||||
suggested_object_id=entity_name,
|
||||
config_entry=integration,
|
||||
original_name=entity_name,
|
||||
)
|
||||
assert entity_entry.entity_id == ILLUMINANCE_SENSOR
|
||||
assert entity_entry.unique_id == old_unique_id
|
||||
|
||||
# Add a ready node, unique ID should be migrated
|
||||
node = Node(client, multisensor_6_state)
|
||||
event = {"node": node}
|
||||
|
||||
client.driver.controller.emit("node added", event)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Check that new RegistryEntry is using new unique ID format
|
||||
entity_entry = ent_reg.async_get(ILLUMINANCE_SENSOR)
|
||||
new_unique_id = f"{client.driver.controller.home_id}.52-49-0-Illuminance"
|
||||
assert entity_entry.unique_id == new_unique_id
|
||||
|
||||
|
||||
async def test_unique_id_migration_property_key_v1(
|
||||
hass, hank_binary_switch_state, client, integration
|
||||
):
|
||||
"""Test unique ID with property key is migrated from old format to new (version 1)."""
|
||||
ent_reg = entity_registry.async_get(hass)
|
||||
|
||||
SENSOR_NAME = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed"
|
||||
entity_name = SENSOR_NAME.split(".")[1]
|
||||
|
||||
# Create entity RegistryEntry using old unique ID format
|
||||
old_unique_id = f"{client.driver.controller.home_id}.32.32-50-00-value-W_Consumed"
|
||||
entity_entry = ent_reg.async_get_or_create(
|
||||
"sensor",
|
||||
DOMAIN,
|
||||
old_unique_id,
|
||||
suggested_object_id=entity_name,
|
||||
config_entry=integration,
|
||||
original_name=entity_name,
|
||||
)
|
||||
assert entity_entry.entity_id == SENSOR_NAME
|
||||
assert entity_entry.unique_id == old_unique_id
|
||||
|
||||
# Add a ready node, unique ID should be migrated
|
||||
node = Node(client, hank_binary_switch_state)
|
||||
event = {"node": node}
|
||||
|
||||
client.driver.controller.emit("node added", event)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Check that new RegistryEntry is using new unique ID format
|
||||
entity_entry = ent_reg.async_get(SENSOR_NAME)
|
||||
new_unique_id = f"{client.driver.controller.home_id}.32-50-0-value-66049"
|
||||
assert entity_entry.unique_id == new_unique_id
|
||||
|
||||
|
||||
async def test_unique_id_migration_property_key_v2(
|
||||
hass, hank_binary_switch_state, client, integration
|
||||
):
|
||||
"""Test unique ID with property key is migrated from old format to new (version 2)."""
|
||||
ent_reg = entity_registry.async_get(hass)
|
||||
|
||||
SENSOR_NAME = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed"
|
||||
entity_name = SENSOR_NAME.split(".")[1]
|
||||
|
||||
# Create entity RegistryEntry using old unique ID format
|
||||
old_unique_id = (
|
||||
f"{client.driver.controller.home_id}.32.32-50-0-value-66049-W_Consumed"
|
||||
)
|
||||
entity_entry = ent_reg.async_get_or_create(
|
||||
"sensor",
|
||||
DOMAIN,
|
||||
old_unique_id,
|
||||
suggested_object_id=entity_name,
|
||||
config_entry=integration,
|
||||
original_name=entity_name,
|
||||
)
|
||||
assert entity_entry.entity_id == SENSOR_NAME
|
||||
assert entity_entry.unique_id == old_unique_id
|
||||
|
||||
# Add a ready node, unique ID should be migrated
|
||||
node = Node(client, hank_binary_switch_state)
|
||||
event = {"node": node}
|
||||
|
||||
client.driver.controller.emit("node added", event)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Check that new RegistryEntry is using new unique ID format
|
||||
entity_entry = ent_reg.async_get(SENSOR_NAME)
|
||||
new_unique_id = f"{client.driver.controller.home_id}.32-50-0-value-66049"
|
||||
assert entity_entry.unique_id == new_unique_id
|
||||
|
||||
|
||||
async def test_unique_id_migration_property_key_v3(
|
||||
hass, hank_binary_switch_state, client, integration
|
||||
):
|
||||
"""Test unique ID with property key is migrated from old format to new (version 3)."""
|
||||
ent_reg = entity_registry.async_get(hass)
|
||||
|
||||
SENSOR_NAME = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed"
|
||||
entity_name = SENSOR_NAME.split(".")[1]
|
||||
|
||||
# Create entity RegistryEntry using old unique ID format
|
||||
old_unique_id = f"{client.driver.controller.home_id}.32-50-0-value-66049-W_Consumed"
|
||||
entity_entry = ent_reg.async_get_or_create(
|
||||
"sensor",
|
||||
DOMAIN,
|
||||
old_unique_id,
|
||||
suggested_object_id=entity_name,
|
||||
config_entry=integration,
|
||||
original_name=entity_name,
|
||||
)
|
||||
assert entity_entry.entity_id == SENSOR_NAME
|
||||
assert entity_entry.unique_id == old_unique_id
|
||||
|
||||
# Add a ready node, unique ID should be migrated
|
||||
node = Node(client, hank_binary_switch_state)
|
||||
event = {"node": node}
|
||||
|
||||
client.driver.controller.emit("node added", event)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Check that new RegistryEntry is using new unique ID format
|
||||
entity_entry = ent_reg.async_get(SENSOR_NAME)
|
||||
new_unique_id = f"{client.driver.controller.home_id}.32-50-0-value-66049"
|
||||
assert entity_entry.unique_id == new_unique_id
|
||||
|
||||
|
||||
@@ -262,7 +402,7 @@ async def test_unique_id_migration_notification_binary_sensor(
|
||||
|
||||
# Check that new RegistryEntry is using new unique ID format
|
||||
entity_entry = ent_reg.async_get(NOTIFICATION_MOTION_BINARY_SENSOR)
|
||||
new_unique_id = f"{client.driver.controller.home_id}.52-113-0-Home Security-Motion sensor status-Motion sensor status.8"
|
||||
new_unique_id = f"{client.driver.controller.home_id}.52-113-0-Home Security-Motion sensor status.8"
|
||||
assert entity_entry.unique_id == new_unique_id
|
||||
|
||||
|
||||
@@ -319,6 +459,12 @@ async def test_existing_node_ready(
|
||||
)
|
||||
|
||||
|
||||
async def test_null_name(hass, client, null_name_check, integration):
|
||||
"""Test that node without a name gets a generic node name."""
|
||||
node = null_name_check
|
||||
assert hass.states.get(f"switch.node_{node.node_id}")
|
||||
|
||||
|
||||
async def test_existing_node_not_ready(hass, client, multisensor_6, device_registry):
|
||||
"""Test we handle a non ready node that exists during integration setup."""
|
||||
node = multisensor_6
|
||||
@@ -445,11 +591,13 @@ async def test_addon_info_failure(
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"addon_version, update_available, update_calls, update_addon_side_effect",
|
||||
"addon_version, update_available, update_calls, snapshot_calls, "
|
||||
"update_addon_side_effect, create_shapshot_side_effect",
|
||||
[
|
||||
("1.0", True, 1, None),
|
||||
("1.0", False, 0, None),
|
||||
("1.0", True, 1, HassioAPIError("Boom")),
|
||||
("1.0", True, 1, 1, None, None),
|
||||
("1.0", False, 0, 0, None, None),
|
||||
("1.0", True, 1, 1, HassioAPIError("Boom"), None),
|
||||
("1.0", True, 0, 1, None, HassioAPIError("Boom")),
|
||||
],
|
||||
)
|
||||
async def test_update_addon(
|
||||
@@ -464,11 +612,14 @@ async def test_update_addon(
|
||||
addon_version,
|
||||
update_available,
|
||||
update_calls,
|
||||
snapshot_calls,
|
||||
update_addon_side_effect,
|
||||
create_shapshot_side_effect,
|
||||
):
|
||||
"""Test update the Z-Wave JS add-on during entry setup."""
|
||||
addon_info.return_value["version"] = addon_version
|
||||
addon_info.return_value["update_available"] = update_available
|
||||
create_shapshot.side_effect = create_shapshot_side_effect
|
||||
update_addon.side_effect = update_addon_side_effect
|
||||
client.connect.side_effect = InvalidServerVersion("Invalid version")
|
||||
device = "/test"
|
||||
@@ -490,12 +641,7 @@ async def test_update_addon(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.state == ENTRY_STATE_SETUP_RETRY
|
||||
assert create_shapshot.call_count == 1
|
||||
assert create_shapshot.call_args == call(
|
||||
hass,
|
||||
{"name": f"addon_core_zwave_js_{addon_version}", "addons": ["core_zwave_js"]},
|
||||
partial=True,
|
||||
)
|
||||
assert create_shapshot.call_count == snapshot_calls
|
||||
assert update_addon.call_count == update_calls
|
||||
|
||||
|
||||
|
||||
@@ -4,11 +4,25 @@
|
||||
"status": 1,
|
||||
"ready": true,
|
||||
"deviceClass": {
|
||||
"basic": {"key": 4, "label":"Routing Slave"},
|
||||
"generic": {"key": 8, "label":"Thermostat"},
|
||||
"specific": {"key": 4, "label":"Setpoint Thermostat"},
|
||||
"mandatorySupportedCCs": [],
|
||||
"mandatoryControlCCs": []
|
||||
"basic": {
|
||||
"key": 4,
|
||||
"label": "Routing Slave"
|
||||
},
|
||||
"generic": {
|
||||
"key": 8,
|
||||
"label": "Thermostat"
|
||||
},
|
||||
"specific": {
|
||||
"key": 4,
|
||||
"label": "Setpoint Thermostat"
|
||||
},
|
||||
"mandatorySupportedCCs": [
|
||||
114,
|
||||
143,
|
||||
67,
|
||||
134
|
||||
],
|
||||
"mandatoryControlledCCs": []
|
||||
},
|
||||
"isListening": false,
|
||||
"isFrequentListening": false,
|
||||
@@ -22,6 +36,7 @@
|
||||
"productType": 5,
|
||||
"firmwareVersion": "1.1",
|
||||
"deviceConfig": {
|
||||
"filename": "/usr/src/app/node_modules/@zwave-js/config/config/devices/0x0002/lc-13.json",
|
||||
"manufacturerId": 2,
|
||||
"manufacturer": "Danfoss",
|
||||
"label": "LC-13",
|
||||
@@ -66,19 +81,76 @@
|
||||
14
|
||||
],
|
||||
"interviewAttempts": 1,
|
||||
"interviewStage": 7,
|
||||
"commandClasses": [
|
||||
{
|
||||
"id": 67,
|
||||
"name": "Thermostat Setpoint",
|
||||
"version": 2,
|
||||
"isSecure": false
|
||||
},
|
||||
{
|
||||
"id": 70,
|
||||
"name": "Climate Control Schedule",
|
||||
"version": 1,
|
||||
"isSecure": false
|
||||
},
|
||||
{
|
||||
"id": 114,
|
||||
"name": "Manufacturer Specific",
|
||||
"version": 1,
|
||||
"isSecure": false
|
||||
},
|
||||
{
|
||||
"id": 117,
|
||||
"name": "Protection",
|
||||
"version": 2,
|
||||
"isSecure": false
|
||||
},
|
||||
{
|
||||
"id": 128,
|
||||
"name": "Battery",
|
||||
"version": 1,
|
||||
"isSecure": false
|
||||
},
|
||||
{
|
||||
"id": 129,
|
||||
"name": "Clock",
|
||||
"version": 1,
|
||||
"isSecure": false
|
||||
},
|
||||
{
|
||||
"id": 132,
|
||||
"name": "Wake Up",
|
||||
"version": 1,
|
||||
"isSecure": false
|
||||
},
|
||||
{
|
||||
"id": 134,
|
||||
"name": "Version",
|
||||
"version": 1,
|
||||
"isSecure": false
|
||||
},
|
||||
{
|
||||
"id": 143,
|
||||
"name": "Multi Command",
|
||||
"version": 1,
|
||||
"isSecure": false
|
||||
}
|
||||
],
|
||||
"endpoints": [
|
||||
{
|
||||
"nodeId": 5,
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"commandClasses": [],
|
||||
"values": [
|
||||
{
|
||||
"endpoint": 0,
|
||||
"commandClass": 67,
|
||||
"commandClassName": "Thermostat Setpoint",
|
||||
"property": "setpoint",
|
||||
"propertyKey": 1,
|
||||
"propertyName": "setpoint",
|
||||
"propertyKeyName": "Heating",
|
||||
"ccVersion": 2,
|
||||
@@ -91,7 +163,7 @@
|
||||
"setpointType": 1
|
||||
}
|
||||
},
|
||||
"value": 25
|
||||
"value": 14
|
||||
},
|
||||
{
|
||||
"endpoint": 0,
|
||||
@@ -262,7 +334,7 @@
|
||||
"unit": "%",
|
||||
"label": "Battery level"
|
||||
},
|
||||
"value": 53
|
||||
"value": 49
|
||||
},
|
||||
{
|
||||
"endpoint": 0,
|
||||
@@ -361,4 +433,4 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -837,6 +837,7 @@
|
||||
"commandClassName": "Thermostat Setpoint",
|
||||
"property": "setpoint",
|
||||
"propertyName": "setpoint",
|
||||
"propertyKey": 1,
|
||||
"propertyKeyName": "Heating",
|
||||
"ccVersion": 3,
|
||||
"metadata": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user