Compare commits

...

58 Commits

Author SHA1 Message Date
Paulus Schoutsen
a8844ff24a Merge pull request #47645 from home-assistant/rc 2021-03-08 17:40:16 -08:00
Paulus Schoutsen
b80c2d426c Bumped version to 2021.3.3 2021-03-08 23:23:04 +00:00
Raman Gupta
b352c5840f Update zwave_js supported features list to be static (#47623) 2021-03-08 23:22:47 +00:00
Shay Levy
9f6007b4e2 Fix Shelly logbook exception when missing COAP (#47620) 2021-03-08 23:22:47 +00:00
J. Nick Koston
58573dc74d Fix turning off scene in homekit (#47604) 2021-03-08 23:22:46 +00:00
J. Nick Koston
a51ad137a1 Fix insteon fan speeds (#47603) 2021-03-08 23:22:45 +00:00
J. Nick Koston
9601cb7445 Ensure template fan value_template always determines on state (#47598) 2021-03-08 23:22:44 +00:00
J. Nick Koston
96b266b2e8 Fix turn on without speed in homekit controller (#47597) 2021-03-08 23:22:44 +00:00
Bram Kragten
3c1aac1034 Update frontend to 20210302.6 (#47592) 2021-03-08 23:22:43 +00:00
J. Nick Koston
0f115f6937 Ensure bond devices recover when wifi disconnects and reconnects (#47591) 2021-03-08 23:22:42 +00:00
Raman Gupta
f1fc6c4b25 Add fallback zwave_js entity name using node ID (#47582)
* add fallback zwave_js entity name using node ID

* add new fixture and test for name that was failing
2021-03-08 23:22:12 +00:00
Tony Roman
37f486941a Allow running and restarting with both ozw and zwave active (#47566)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2021-03-08 23:21:14 +00:00
Diogo Gomes
69f63129aa Correct weather entities forecast time (#47565) 2021-03-08 23:21:14 +00:00
Martin Hjelmare
56efae3cb5 Fix mysensors unload clean up (#47541) 2021-03-08 23:21:13 +00:00
Martin Hjelmare
a2e00324a8 Fix mysensors device tracker (#47536) 2021-03-08 23:21:12 +00:00
Martin Hjelmare
e63f766c20 Bump pymysensors to 0.21.0 (#47530) 2021-03-08 23:21:11 +00:00
ollo69
e7717694a3 Fix AsusWRT wrong api call (#47522) 2021-03-08 23:21:11 +00:00
Martin Hjelmare
ba2b62305b Fix mysensors notify platform (#47517) 2021-03-08 23:21:10 +00:00
Anders Melchiorsen
b25f846136 Fix Sonos polling mode (#47498) 2021-03-08 23:21:09 +00:00
J. Nick Koston
c1a5a18b53 Bump HAP-python to 3.4.0 (#47476)
* Bump HAP-python to 3.3.3

* bump

* fix mocking
2021-03-08 23:21:09 +00:00
Nathan Spencer
3b05a12e62 Adjust litterrobot tests and code to match guidelines (#47060)
* Use SwitchEntity instead of ToggleEntity and adjust test patches as recommended

* Move async_create_entry out of try block in config_flow

* Patch pypi package instead of HA code

* Bump pylitterbot to 2021.2.6, fix tests, and implement other code review suggestions

* Bump pylitterbot to 2021.2.8, remove sleep mode start/end time from vacuum, adjust and add sensors for sleep mode start/end time

* Move icon helper back to Litter-Robot component and isoformat times on time sensors
2021-03-08 23:21:08 +00:00
Paulus Schoutsen
1145c30c4b Merge pull request #47490 from home-assistant/rc 2021-03-05 16:22:59 -08:00
Paulus Schoutsen
939da2403f Bumped version to 2021.3.2 2021-03-05 23:38:27 +00:00
Phil Bruckner
ef79d24a8c Bump amcrest package version to 1.7.1 (#47483) 2021-03-05 23:37:55 +00:00
Raman Gupta
55c1b67de4 Update zwave_js.refresh_value service description (#47469) 2021-03-05 23:37:54 +00:00
Paulus Schoutsen
90e0801c1b Raise error instead of crashing when template passed to call service target (#47467) 2021-03-05 23:37:54 +00:00
Raman Gupta
65859b4107 Bump zwave-js-server-python to 0.21.1 (#47464) 2021-03-05 23:37:52 +00:00
functionpointer
6c45a7d533 Use conn_made callback in MySensors (#47463) 2021-03-05 23:37:52 +00:00
Bram Kragten
ddc6cd6da1 Update frontend to 20210302.5 (#47462) 2021-03-05 23:37:51 +00:00
Joakim Plate
a4369fc352 Bump version with fix for v1 (#47458) 2021-03-05 23:37:50 +00:00
Franck Nijhof
d11da43551 Fix Hue scene overriding Hue default transition times (#47454) 2021-03-05 23:37:49 +00:00
Tobias Sauerwein
f99ef25f88 Fix issue at Netatmo startup (#47452) 2021-03-05 23:37:49 +00:00
mvn23
9fa0de8600 Update pyotgw to 1.1b1 (#47446) 2021-03-05 23:37:48 +00:00
Paulus Schoutsen
91ac4554a2 Merge pull request #47422 from home-assistant/rc 2021-03-04 17:17:29 -08:00
Martin Hjelmare
d9542c2efe Only create snapshot if add-on update will be done (#47424) 2021-03-05 00:38:53 +00:00
Paulus Schoutsen
915ee2f4ee Bumped version to 2021.3.1 2021-03-05 00:22:31 +00:00
Franck Nijhof
ff86f64806 Fix older Roborock models (#47412) 2021-03-05 00:21:55 +00:00
Raman Gupta
25ff2e745d Bump zwave-js-server-python to 0.21.0 (#47408)
Co-authored-by: Tobias Sauerwein <cgtobi@users.noreply.github.com>
2021-03-05 00:18:18 +00:00
Raman Gupta
f53cff49d5 Don't convert Climacell forecast temperatures to celsius because platform does it automatically (#47406) 2021-03-05 00:18:17 +00:00
Aaron Bach
36a2521799 Fix AirVisual exception when config entry contains old integration type (#47405) 2021-03-05 00:18:16 +00:00
Raman Gupta
ee55a04b4b Fix Climacell timezone issue with daily forecasts (#47402) 2021-03-05 00:18:15 +00:00
Sebastian Muszynski
6724d86565 Fix measurement unit (Closes: #47390) (#47398) 2021-03-05 00:18:14 +00:00
J. Nick Koston
14dca8e783 Map silent as a preset mode for fan backcompat (#47396)
The original change did not map silent as a preset mode
because it was not clear if it was a speed or a preset.
2021-03-05 00:18:14 +00:00
Christophe Painchaud
1817147995 Fix RFLink TCP KeepAlive error log (#47395) 2021-03-05 00:18:13 +00:00
Eric Severance
0bf3dea40c Revert "Speed-up wemo discovery (#46821)" (#47392)
This reverts commit 6e52b26c06.
2021-03-05 00:18:12 +00:00
Bram Kragten
5685b4aa33 Update frontend to 20210302.4 (#47383) 2021-03-05 00:18:11 +00:00
Martin Hjelmare
ef9b9663c5 Fix access of missing zwave_js climate unit value (#47380) 2021-03-05 00:18:11 +00:00
Franck Nijhof
d83ccdc97a Don't raise on known non-matching states in numeric state condition (#47378) 2021-03-05 00:18:10 +00:00
Martin Hjelmare
c0840e22dc Fix zwave_js manual reconfiguration of add-on managed entry (#47364) 2021-03-05 00:18:09 +00:00
Anders Melchiorsen
33c3566106 Catch ConditionError in generic_thermostat climate (#47359) 2021-03-05 00:18:09 +00:00
Martin Hjelmare
d175ac8e0d Make zwave_js add-on manager more flexible (#47356) 2021-03-05 00:18:08 +00:00
starkillerOG
c2f7a38d09 Fix Xiaomi Miio setup of switch entity for lumi.acpartner.v3 (#47345) 2021-03-05 00:18:07 +00:00
Franck Nijhof
e9785fcd3d Merge pull request #47319 from home-assistant/rc 2021-03-03 20:05:36 +01:00
Franck Nijhof
b711686e10 Bumped version to 2021.3.0 2021-03-03 19:17:17 +01:00
Paulus Schoutsen
584ad07567 Simplify switch light (#47317) 2021-03-03 19:16:25 +01:00
Erik Montnemery
a89ba0ed8e Improve behaviour when disabling or enabling config entries (#47301) 2021-03-03 19:16:21 +01:00
Bram Kragten
15c89ebada Update frontend to 20210302.3 (#47310) 2021-03-03 16:01:30 +01:00
Raman Gupta
24919e99b8 Correct climacell device info (#47292) 2021-03-03 16:01:26 +01:00
104 changed files with 2359 additions and 943 deletions

View File

@@ -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

View File

@@ -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),
}

View File

@@ -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

View File

@@ -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"]
}

View File

@@ -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):

View File

@@ -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"]
}

View File

@@ -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:

View File

@@ -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():

View File

@@ -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",
}

View File

@@ -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
)

View File

@@ -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 [

View File

@@ -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",

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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):

View File

@@ -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:

View File

@@ -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),

View File

@@ -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"

View File

@@ -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)

View File

@@ -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
)

View File

@@ -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]:

View File

@@ -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"]
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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] = []

View File

@@ -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"

View File

@@ -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,

View File

@@ -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(

View File

@@ -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]]

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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
)

View File

@@ -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):

View File

@@ -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:

View File

@@ -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(

View File

@@ -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():

View File

@@ -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 [

View File

@@ -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
}

View File

@@ -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
),

View File

@@ -7,8 +7,7 @@
"python-openzwave-mqtt[mqtt-client]==1.4.0"
],
"after_dependencies": [
"mqtt",
"zwave"
"mqtt"
],
"codeowners": [
"@cgarwood",

View File

@@ -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"

View File

@@ -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):

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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(

View File

@@ -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"]

View File

@@ -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

View File

@@ -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):

View File

@@ -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"]:

View File

@@ -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)

View File

@@ -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

View File

@@ -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."""

View File

@@ -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(

View File

@@ -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:

View File

@@ -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."""

View File

@@ -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

View File

@@ -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"]
}

View 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

View File

@@ -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)

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"),

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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
)

View 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

View File

@@ -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()

View File

@@ -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()

View File

@@ -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."""

View File

@@ -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)

View File

@@ -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"

View File

@@ -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

View File

@@ -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(

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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):

View File

@@ -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

View File

@@ -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(

View File

@@ -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"

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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}])

View File

@@ -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

View File

@@ -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 @@
]
}
]
}
}

View File

@@ -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