Merge pull request #64317 from home-assistant/rc

This commit is contained in:
Paulus Schoutsen 2022-01-17 16:29:40 -08:00 committed by GitHub
commit 7887f23824
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 2019 additions and 234 deletions

View File

@ -138,6 +138,17 @@ class AugustData(AugustSubscriberMixin):
pubnub.subscribe(self.async_pubnub_message) pubnub.subscribe(self.async_pubnub_message)
self._pubnub_unsub = async_create_pubnub(user_data["UserID"], pubnub) self._pubnub_unsub = async_create_pubnub(user_data["UserID"], pubnub)
if self._locks_by_id:
tasks = []
for lock_id in self._locks_by_id:
detail = self._device_detail_by_id[lock_id]
tasks.append(
self.async_status_async(
lock_id, bool(detail.bridge and detail.bridge.hyper_bridge)
)
)
await asyncio.gather(*tasks)
@callback @callback
def async_pubnub_message(self, device_id, date_time, message): def async_pubnub_message(self, device_id, date_time, message):
"""Process a pubnub message.""" """Process a pubnub message."""
@ -245,13 +256,24 @@ class AugustData(AugustSubscriberMixin):
device_id, device_id,
) )
async def async_lock_async(self, device_id): async def async_status_async(self, device_id, hyper_bridge):
"""Request status of the the device but do not wait for a response since it will come via pubnub."""
return await self._async_call_api_op_requires_bridge(
device_id,
self._api.async_status_async,
self._august_gateway.access_token,
device_id,
hyper_bridge,
)
async def async_lock_async(self, device_id, hyper_bridge):
"""Lock the device but do not wait for a response since it will come via pubnub.""" """Lock the device but do not wait for a response since it will come via pubnub."""
return await self._async_call_api_op_requires_bridge( return await self._async_call_api_op_requires_bridge(
device_id, device_id,
self._api.async_lock_async, self._api.async_lock_async,
self._august_gateway.access_token, self._august_gateway.access_token,
device_id, device_id,
hyper_bridge,
) )
async def async_unlock(self, device_id): async def async_unlock(self, device_id):
@ -263,13 +285,14 @@ class AugustData(AugustSubscriberMixin):
device_id, device_id,
) )
async def async_unlock_async(self, device_id): async def async_unlock_async(self, device_id, hyper_bridge):
"""Unlock the device but do not wait for a response since it will come via pubnub.""" """Unlock the device but do not wait for a response since it will come via pubnub."""
return await self._async_call_api_op_requires_bridge( return await self._async_call_api_op_requires_bridge(
device_id, device_id,
self._api.async_unlock_async, self._api.async_unlock_async,
self._august_gateway.access_token, self._august_gateway.access_token,
device_id, device_id,
hyper_bridge,
) )
async def _async_call_api_op_requires_bridge( async def _async_call_api_op_requires_bridge(

View File

@ -39,17 +39,22 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity):
self._attr_unique_id = f"{self._device_id:s}_lock" self._attr_unique_id = f"{self._device_id:s}_lock"
self._update_from_data() self._update_from_data()
@property
def _hyper_bridge(self):
"""Check if the lock has a paired hyper bridge."""
return bool(self._detail.bridge and self._detail.bridge.hyper_bridge)
async def async_lock(self, **kwargs): async def async_lock(self, **kwargs):
"""Lock the device.""" """Lock the device."""
if self._data.activity_stream.pubnub.connected: if self._data.activity_stream.pubnub.connected:
await self._data.async_lock_async(self._device_id) await self._data.async_lock_async(self._device_id, self._hyper_bridge)
return return
await self._call_lock_operation(self._data.async_lock) await self._call_lock_operation(self._data.async_lock)
async def async_unlock(self, **kwargs): async def async_unlock(self, **kwargs):
"""Unlock the device.""" """Unlock the device."""
if self._data.activity_stream.pubnub.connected: if self._data.activity_stream.pubnub.connected:
await self._data.async_unlock_async(self._device_id) await self._data.async_unlock_async(self._device_id, self._hyper_bridge)
return return
await self._call_lock_operation(self._data.async_unlock) await self._call_lock_operation(self._data.async_unlock)

View File

@ -2,7 +2,7 @@
"domain": "august", "domain": "august",
"name": "August", "name": "August",
"documentation": "https://www.home-assistant.io/integrations/august", "documentation": "https://www.home-assistant.io/integrations/august",
"requirements": ["yalexs==1.1.17"], "requirements": ["yalexs==1.1.19"],
"codeowners": ["@bdraco"], "codeowners": ["@bdraco"],
"dhcp": [ "dhcp": [
{ {

View File

@ -2,7 +2,7 @@
"domain": "bmw_connected_drive", "domain": "bmw_connected_drive",
"name": "BMW Connected Drive", "name": "BMW Connected Drive",
"documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive",
"requirements": ["bimmer_connected==0.8.7"], "requirements": ["bimmer_connected==0.8.10"],
"codeowners": ["@gerard33", "@rikroe"], "codeowners": ["@gerard33", "@rikroe"],
"config_flow": true, "config_flow": true,
"iot_class": "cloud_polling" "iot_class": "cloud_polling"

View File

@ -474,8 +474,8 @@ def _is_ignored_device(discovery_info: ssdp.SsdpServiceInfo) -> bool:
# Special cases for devices with other discovery methods (e.g. mDNS), or # Special cases for devices with other discovery methods (e.g. mDNS), or
# that advertise multiple unrelated (sent in separate discovery packets) # that advertise multiple unrelated (sent in separate discovery packets)
# UPnP devices. # UPnP devices.
manufacturer = discovery_info.upnp.get(ssdp.ATTR_UPNP_MANUFACTURER, "").lower() manufacturer = (discovery_info.upnp.get(ssdp.ATTR_UPNP_MANUFACTURER) or "").lower()
model = discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME, "").lower() model = (discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME) or "").lower()
if manufacturer.startswith("xbmc") or model == "kodi": if manufacturer.startswith("xbmc") or model == "kodi":
# kodi # kodi

View File

@ -71,7 +71,14 @@ from .const import (
TRANSITION_STROBE, TRANSITION_STROBE,
) )
from .entity import FluxOnOffEntity from .entity import FluxOnOffEntity
from .util import _effect_brightness, _flux_color_mode_to_hass, _hass_color_modes from .util import (
_effect_brightness,
_flux_color_mode_to_hass,
_hass_color_modes,
_min_rgb_brightness,
_min_rgbw_brightness,
_min_rgbwc_brightness,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -313,13 +320,13 @@ class FluxLight(FluxOnOffEntity, CoordinatorEntity, LightEntity):
"""Determine brightness from kwargs or current value.""" """Determine brightness from kwargs or current value."""
if (brightness := kwargs.get(ATTR_BRIGHTNESS)) is None: if (brightness := kwargs.get(ATTR_BRIGHTNESS)) is None:
brightness = self.brightness brightness = self.brightness
if not brightness: # If the brightness was previously 0, the light
# If the brightness was previously 0, the light # will not turn on unless brightness is at least 1
# will not turn on unless brightness is at least 1 #
# If the device was on and brightness was not # We previously had a problem with the brightness
# set, it means it was masked by an effect # sometimes reporting as 0 when an effect was in progress,
brightness = 255 if self.is_on else 1 # however this has since been resolved in the upstream library
return brightness return max(1, brightness)
async def _async_set_mode(self, **kwargs: Any) -> None: async def _async_set_mode(self, **kwargs: Any) -> None:
"""Set an effect or color mode.""" """Set an effect or color mode."""
@ -348,6 +355,8 @@ class FluxLight(FluxOnOffEntity, CoordinatorEntity, LightEntity):
return return
# Handle switch to RGB Color Mode # Handle switch to RGB Color Mode
if rgb := kwargs.get(ATTR_RGB_COLOR): if rgb := kwargs.get(ATTR_RGB_COLOR):
if not self._device.requires_turn_on:
rgb = _min_rgb_brightness(rgb)
red, green, blue = rgb red, green, blue = rgb
await self._device.async_set_levels(red, green, blue, brightness=brightness) await self._device.async_set_levels(red, green, blue, brightness=brightness)
return return
@ -355,13 +364,18 @@ class FluxLight(FluxOnOffEntity, CoordinatorEntity, LightEntity):
if rgbw := kwargs.get(ATTR_RGBW_COLOR): if rgbw := kwargs.get(ATTR_RGBW_COLOR):
if ATTR_BRIGHTNESS in kwargs: if ATTR_BRIGHTNESS in kwargs:
rgbw = rgbw_brightness(rgbw, brightness) rgbw = rgbw_brightness(rgbw, brightness)
if not self._device.requires_turn_on:
rgbw = _min_rgbw_brightness(rgbw)
await self._device.async_set_levels(*rgbw) await self._device.async_set_levels(*rgbw)
return return
# Handle switch to RGBWW Color Mode # Handle switch to RGBWW Color Mode
if rgbcw := kwargs.get(ATTR_RGBWW_COLOR): if rgbcw := kwargs.get(ATTR_RGBWW_COLOR):
if ATTR_BRIGHTNESS in kwargs: if ATTR_BRIGHTNESS in kwargs:
rgbcw = rgbcw_brightness(kwargs[ATTR_RGBWW_COLOR], brightness) rgbcw = rgbcw_brightness(kwargs[ATTR_RGBWW_COLOR], brightness)
await self._device.async_set_levels(*rgbcw_to_rgbwc(rgbcw)) rgbwc = rgbcw_to_rgbwc(rgbcw)
if not self._device.requires_turn_on:
rgbwc = _min_rgbwc_brightness(rgbwc)
await self._device.async_set_levels(*rgbwc)
return return
if (white := kwargs.get(ATTR_WHITE)) is not None: if (white := kwargs.get(ATTR_WHITE)) is not None:
await self._device.async_set_levels(w=white) await self._device.async_set_levels(w=white)

View File

@ -3,42 +3,41 @@
"name": "Flux LED/MagicHome", "name": "Flux LED/MagicHome",
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/flux_led", "documentation": "https://www.home-assistant.io/integrations/flux_led",
"requirements": ["flux_led==0.27.45"], "requirements": ["flux_led==0.28.4"],
"quality_scale": "platinum", "quality_scale": "platinum",
"codeowners": ["@icemanch"], "codeowners": ["@icemanch"],
"iot_class": "local_push", "iot_class": "local_push",
"dhcp": [ "dhcp": [
{ {
"macaddress": "18B905*", "macaddress": "18B905*",
"hostname": "[ba][lk]*" "hostname": "[ba][lk]*"
}, },
{ {
"macaddress": "249494*", "macaddress": "249494*",
"hostname": "[ba][lk]*" "hostname": "[ba][lk]*"
}, },
{ {
"macaddress": "7CB94C*", "macaddress": "7CB94C*",
"hostname": "[ba][lk]*" "hostname": "[ba][lk]*"
}, },
{ {
"macaddress": "B4E842*", "macaddress": "B4E842*",
"hostname": "[ba][lk]*" "hostname": "[ba][lk]*"
}, },
{ {
"macaddress": "F0FE6B*", "macaddress": "F0FE6B*",
"hostname": "[ba][lk]*" "hostname": "[ba][lk]*"
}, },
{ {
"macaddress": "8CCE4E*", "macaddress": "8CCE4E*",
"hostname": "lwip*" "hostname": "lwip*"
}, },
{ {
"hostname": "zengge_[0-9a-f][0-9a-f]_*" "hostname": "zengge_[0-9a-f][0-9a-f]_*"
}, },
{ {
"macaddress": "C82E47*", "macaddress": "C82E47*",
"hostname": "sta*" "hostname": "sta*"
} }
] ]
} }

View File

@ -34,3 +34,26 @@ def _flux_color_mode_to_hass(
def _effect_brightness(brightness: int) -> int: def _effect_brightness(brightness: int) -> int:
"""Convert hass brightness to effect brightness.""" """Convert hass brightness to effect brightness."""
return round(brightness / 255 * 100) return round(brightness / 255 * 100)
def _min_rgb_brightness(rgb: tuple[int, int, int]) -> tuple[int, int, int]:
"""Ensure the RGB value will not turn off the device from a turn on command."""
if all(byte == 0 for byte in rgb):
return (1, 1, 1)
return rgb
def _min_rgbw_brightness(rgbw: tuple[int, int, int, int]) -> tuple[int, int, int, int]:
"""Ensure the RGBW value will not turn off the device from a turn on command."""
if all(byte == 0 for byte in rgbw):
return (1, 1, 1, 0)
return rgbw
def _min_rgbwc_brightness(
rgbwc: tuple[int, int, int, int, int]
) -> tuple[int, int, int, int, int]:
"""Ensure the RGBWC value will not turn off the device from a turn on command."""
if all(byte == 0 for byte in rgbwc):
return (1, 1, 1, 0, 0)
return rgbwc

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from copy import deepcopy
import ipaddress import ipaddress
import logging import logging
import os import os
@ -352,8 +353,8 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry):
@callback @callback
def _async_import_options_from_data_if_missing(hass: HomeAssistant, entry: ConfigEntry): def _async_import_options_from_data_if_missing(hass: HomeAssistant, entry: ConfigEntry):
options = dict(entry.options) options = deepcopy(dict(entry.options))
data = dict(entry.data) data = deepcopy(dict(entry.data))
modified = False modified = False
for importable_option in CONFIG_OPTIONS: for importable_option in CONFIG_OPTIONS:
if importable_option not in entry.options and importable_option in entry.data: if importable_option not in entry.options and importable_option in entry.data:

View File

@ -2,9 +2,11 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from copy import deepcopy
import random import random
import re import re
import string import string
from typing import Final
import voluptuous as vol import voluptuous as vol
@ -116,7 +118,7 @@ DEFAULT_DOMAINS = [
"water_heater", "water_heater",
] ]
_EMPTY_ENTITY_FILTER = { _EMPTY_ENTITY_FILTER: Final = {
CONF_INCLUDE_DOMAINS: [], CONF_INCLUDE_DOMAINS: [],
CONF_EXCLUDE_DOMAINS: [], CONF_EXCLUDE_DOMAINS: [],
CONF_INCLUDE_ENTITIES: [], CONF_INCLUDE_ENTITIES: [],
@ -151,7 +153,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
async def async_step_user(self, user_input=None): async def async_step_user(self, user_input=None):
"""Choose specific domains in bridge mode.""" """Choose specific domains in bridge mode."""
if user_input is not None: if user_input is not None:
entity_filter = _EMPTY_ENTITY_FILTER.copy() entity_filter = deepcopy(_EMPTY_ENTITY_FILTER)
entity_filter[CONF_INCLUDE_DOMAINS] = user_input[CONF_INCLUDE_DOMAINS] entity_filter[CONF_INCLUDE_DOMAINS] = user_input[CONF_INCLUDE_DOMAINS]
self.hk_data[CONF_FILTER] = entity_filter self.hk_data[CONF_FILTER] = entity_filter
return await self.async_step_pairing() return await self.async_step_pairing()
@ -492,7 +494,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
self.hk_options.update(user_input) self.hk_options.update(user_input)
return await self.async_step_include_exclude() return await self.async_step_include_exclude()
self.hk_options = dict(self.config_entry.options) self.hk_options = deepcopy(dict(self.config_entry.options))
entity_filter = self.hk_options.get(CONF_FILTER, {}) entity_filter = self.hk_options.get(CONF_FILTER, {})
homekit_mode = self.hk_options.get(CONF_HOMEKIT_MODE, DEFAULT_HOMEKIT_MODE) homekit_mode = self.hk_options.get(CONF_HOMEKIT_MODE, DEFAULT_HOMEKIT_MODE)
domains = entity_filter.get(CONF_INCLUDE_DOMAINS, []) domains = entity_filter.get(CONF_INCLUDE_DOMAINS, [])

View File

@ -3,7 +3,7 @@
"name": "HomeKit", "name": "HomeKit",
"documentation": "https://www.home-assistant.io/integrations/homekit", "documentation": "https://www.home-assistant.io/integrations/homekit",
"requirements": [ "requirements": [
"HAP-python==4.3.0", "HAP-python==4.4.0",
"fnvhash==0.1.0", "fnvhash==0.1.0",
"PyQRCode==1.2.1", "PyQRCode==1.2.1",
"base36==0.1.1" "base36==0.1.1"

View File

@ -174,6 +174,7 @@ class Thermostat(HomeAccessory):
self.char_target_heat_cool.override_properties( self.char_target_heat_cool.override_properties(
valid_values=self.hc_hass_to_homekit valid_values=self.hc_hass_to_homekit
) )
self.char_target_heat_cool.allow_invalid_client_values = True
# Current and target temperature characteristics # Current and target temperature characteristics
self.char_current_temp = serv_thermostat.configure_char( self.char_current_temp = serv_thermostat.configure_char(
@ -252,7 +253,6 @@ class Thermostat(HomeAccessory):
hvac_mode = state.state hvac_mode = state.state
homekit_hvac_mode = HC_HASS_TO_HOMEKIT[hvac_mode] homekit_hvac_mode = HC_HASS_TO_HOMEKIT[hvac_mode]
# Homekit will reset the mode when VIEWING the temp # Homekit will reset the mode when VIEWING the temp
# Ignore it if its the same mode # Ignore it if its the same mode
if ( if (
@ -282,7 +282,7 @@ class Thermostat(HomeAccessory):
target_hc, target_hc,
hc_fallback, hc_fallback,
) )
target_hc = hc_fallback self.char_target_heat_cool.value = target_hc = hc_fallback
break break
params[ATTR_HVAC_MODE] = self.hc_homekit_to_hass[target_hc] params[ATTR_HVAC_MODE] = self.hc_homekit_to_hass[target_hc]

View File

@ -206,7 +206,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return bool(hass.config_entries.async_entries(DOMAIN)) return bool(hass.config_entries.async_entries(DOMAIN))
conf = dict(conf) conf = dict(conf)
hass.data[DATA_KNX_CONFIG] = conf hass.data[DATA_KNX_CONFIG] = conf
# Only import if we haven't before. # Only import if we haven't before.
@ -223,19 +222,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Load a config entry.""" """Load a config entry."""
conf = hass.data.get(DATA_KNX_CONFIG) conf = hass.data.get(DATA_KNX_CONFIG)
# `conf` is None when reloading the integration or no `knx` key in configuration.yaml
# When reloading
if conf is None: if conf is None:
conf = await async_integration_yaml_config(hass, DOMAIN) _conf = await async_integration_yaml_config(hass, DOMAIN)
if not conf or DOMAIN not in conf: if not _conf or DOMAIN not in _conf:
return False _LOGGER.warning(
"No `knx:` key found in configuration.yaml. See "
conf = conf[DOMAIN] "https://www.home-assistant.io/integrations/knx/ "
"for KNX entity configuration documentation"
# If user didn't have configuration.yaml config, generate defaults )
if conf is None: # generate defaults
conf = CONFIG_SCHEMA({DOMAIN: dict(entry.data)})[DOMAIN] conf = CONFIG_SCHEMA({DOMAIN: {}})[DOMAIN]
else:
conf = _conf[DOMAIN]
config = {**conf, **entry.data} config = {**conf, **entry.data}
try: try:
@ -363,7 +362,6 @@ class KNXModule:
self.entry.async_on_unload( self.entry.async_on_unload(
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop)
) )
self.entry.async_on_unload(self.entry.add_update_listener(async_update_entry)) self.entry.async_on_unload(self.entry.add_update_listener(async_update_entry))
def init_xknx(self) -> None: def init_xknx(self) -> None:
@ -403,7 +401,6 @@ class KNXModule:
route_back=self.config.get(ConnectionSchema.CONF_KNX_ROUTE_BACK, False), route_back=self.config.get(ConnectionSchema.CONF_KNX_ROUTE_BACK, False),
auto_reconnect=True, auto_reconnect=True,
) )
return ConnectionConfig(auto_reconnect=True) return ConnectionConfig(auto_reconnect=True)
async def connection_state_changed_cb(self, state: XknxConnectionState) -> None: async def connection_state_changed_cb(self, state: XknxConnectionState) -> None:

View File

@ -44,7 +44,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1 VERSION = 1
_tunnels: list _tunnels: list[GatewayDescriptor]
_gateway_ip: str = "" _gateway_ip: str = ""
_gateway_port: int = DEFAULT_MCAST_PORT _gateway_port: int = DEFAULT_MCAST_PORT
@ -64,25 +64,6 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
async def async_step_type(self, user_input: dict | None = None) -> FlowResult: async def async_step_type(self, user_input: dict | None = None) -> FlowResult:
"""Handle connection type configuration.""" """Handle connection type configuration."""
errors: dict = {}
supported_connection_types = CONF_KNX_INITIAL_CONNECTION_TYPES.copy()
fields = {}
if user_input is None:
gateways = await scan_for_gateways()
if gateways:
supported_connection_types.insert(0, CONF_KNX_AUTOMATIC)
self._tunnels = [
gateway for gateway in gateways if gateway.supports_tunnelling
]
fields = {
vol.Required(CONF_KNX_CONNECTION_TYPE): vol.In(
supported_connection_types
)
}
if user_input is not None: if user_input is not None:
connection_type = user_input[CONF_KNX_CONNECTION_TYPE] connection_type = user_input[CONF_KNX_CONNECTION_TYPE]
if connection_type == CONF_KNX_AUTOMATIC: if connection_type == CONF_KNX_AUTOMATIC:
@ -99,6 +80,22 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
return await self.async_step_manual_tunnel() return await self.async_step_manual_tunnel()
errors: dict = {}
supported_connection_types = CONF_KNX_INITIAL_CONNECTION_TYPES.copy()
fields = {}
gateways = await scan_for_gateways()
if gateways:
# add automatic only if a gateway responded
supported_connection_types.insert(0, CONF_KNX_AUTOMATIC)
self._tunnels = [
gateway for gateway in gateways if gateway.supports_tunnelling
]
fields = {
vol.Required(CONF_KNX_CONNECTION_TYPE): vol.In(supported_connection_types)
}
return self.async_show_form( return self.async_show_form(
step_id="type", data_schema=vol.Schema(fields), errors=errors step_id="type", data_schema=vol.Schema(fields), errors=errors
) )
@ -107,8 +104,6 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
self, user_input: dict | None = None self, user_input: dict | None = None
) -> FlowResult: ) -> FlowResult:
"""General setup.""" """General setup."""
errors: dict = {}
if user_input is not None: if user_input is not None:
return self.async_create_entry( return self.async_create_entry(
title=f"{CONF_KNX_TUNNELING.capitalize()} @ {user_input[CONF_HOST]}", title=f"{CONF_KNX_TUNNELING.capitalize()} @ {user_input[CONF_HOST]}",
@ -129,6 +124,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
}, },
) )
errors: dict = {}
fields = { fields = {
vol.Required(CONF_HOST, default=self._gateway_ip): str, vol.Required(CONF_HOST, default=self._gateway_ip): str,
vol.Required(CONF_PORT, default=self._gateway_port): vol.Coerce(int), vol.Required(CONF_PORT, default=self._gateway_port): vol.Coerce(int),
@ -149,8 +145,6 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
async def async_step_tunnel(self, user_input: dict | None = None) -> FlowResult: async def async_step_tunnel(self, user_input: dict | None = None) -> FlowResult:
"""Select a tunnel from a list. Will be skipped if the gateway scan was unsuccessful or if only one gateway was found.""" """Select a tunnel from a list. Will be skipped if the gateway scan was unsuccessful or if only one gateway was found."""
errors: dict = {}
if user_input is not None: if user_input is not None:
gateway: GatewayDescriptor = next( gateway: GatewayDescriptor = next(
gateway gateway
@ -163,6 +157,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
return await self.async_step_manual_tunnel() return await self.async_step_manual_tunnel()
errors: dict = {}
tunnel_repr = { tunnel_repr = {
str(tunnel) for tunnel in self._tunnels if tunnel.supports_tunnelling str(tunnel) for tunnel in self._tunnels if tunnel.supports_tunnelling
} }
@ -182,8 +177,6 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
async def async_step_routing(self, user_input: dict | None = None) -> FlowResult: async def async_step_routing(self, user_input: dict | None = None) -> FlowResult:
"""Routing setup.""" """Routing setup."""
errors: dict = {}
if user_input is not None: if user_input is not None:
return self.async_create_entry( return self.async_create_entry(
title=CONF_KNX_ROUTING.capitalize(), title=CONF_KNX_ROUTING.capitalize(),
@ -205,6 +198,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
}, },
) )
errors: dict = {}
fields = { fields = {
vol.Required( vol.Required(
CONF_KNX_INDIVIDUAL_ADDRESS, default=XKNX.DEFAULT_ADDRESS CONF_KNX_INDIVIDUAL_ADDRESS, default=XKNX.DEFAULT_ADDRESS
@ -434,7 +428,7 @@ class KNXOptionsFlowHandler(OptionsFlow):
) )
async def scan_for_gateways(stop_on_found: int = 0) -> list: async def scan_for_gateways(stop_on_found: int = 0) -> list[GatewayDescriptor]:
"""Scan for gateways within the network.""" """Scan for gateways within the network."""
xknx = XKNX() xknx = XKNX()
gatewayscanner = GatewayScanner( gatewayscanner = GatewayScanner(

View File

@ -1,7 +1,7 @@
{ {
"domain": "nexia", "domain": "nexia",
"name": "Nexia/American Standard/Trane", "name": "Nexia/American Standard/Trane",
"requirements": ["nexia==0.9.12"], "requirements": ["nexia==0.9.13"],
"codeowners": ["@bdraco"], "codeowners": ["@bdraco"],
"documentation": "https://www.home-assistant.io/integrations/nexia", "documentation": "https://www.home-assistant.io/integrations/nexia",
"config_flow": true, "config_flow": true,

View File

@ -281,10 +281,10 @@ class BlockSleepingClimate(
async def async_set_preset_mode(self, preset_mode: str) -> None: async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set preset mode.""" """Set preset mode."""
if not self._attr_preset_modes: if not self._preset_modes:
return return
preset_index = self._attr_preset_modes.index(preset_mode) preset_index = self._preset_modes.index(preset_mode)
if preset_index == 0: if preset_index == 0:
await self.set_state_full_path(schedule=0) await self.set_state_full_path(schedule=0)

View File

@ -316,6 +316,10 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow):
) )
ssid_filter = {ssid: ssid for ssid in sorted(ssids)} ssid_filter = {ssid: ssid for ssid in sorted(ssids)}
selected_ssids_to_filter = [
ssid for ssid in self.controller.option_ssid_filter if ssid in ssid_filter
]
return self.async_show_form( return self.async_show_form(
step_id="device_tracker", step_id="device_tracker",
data_schema=vol.Schema( data_schema=vol.Schema(
@ -333,7 +337,7 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow):
default=self.controller.option_track_devices, default=self.controller.option_track_devices,
): bool, ): bool,
vol.Optional( vol.Optional(
CONF_SSID_FILTER, default=self.controller.option_ssid_filter CONF_SSID_FILTER, default=selected_ssids_to_filter
): cv.multi_select(ssid_filter), ): cv.multi_select(ssid_filter),
vol.Optional( vol.Optional(
CONF_DETECTION_TIME, CONF_DETECTION_TIME,
@ -365,12 +369,18 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow):
client.mac client.mac
] = f"{client.name or client.hostname} ({client.mac})" ] = f"{client.name or client.hostname} ({client.mac})"
selected_clients_to_block = [
client
for client in self.options.get(CONF_BLOCK_CLIENT, [])
if client in clients_to_block
]
return self.async_show_form( return self.async_show_form(
step_id="client_control", step_id="client_control",
data_schema=vol.Schema( data_schema=vol.Schema(
{ {
vol.Optional( vol.Optional(
CONF_BLOCK_CLIENT, default=self.options[CONF_BLOCK_CLIENT] CONF_BLOCK_CLIENT, default=selected_clients_to_block
): cv.multi_select(clients_to_block), ): cv.multi_select(clients_to_block),
vol.Optional( vol.Optional(
CONF_POE_CLIENTS, CONF_POE_CLIENTS,

View File

@ -88,7 +88,12 @@ from .discovery import (
async_discover_node_values, async_discover_node_values,
async_discover_single_value, async_discover_single_value,
) )
from .helpers import async_enable_statistics, get_device_id, get_unique_id from .helpers import (
async_enable_statistics,
get_device_id,
get_device_id_ext,
get_unique_id,
)
from .migrate import async_migrate_discovered_value from .migrate import async_migrate_discovered_value
from .services import ZWaveServices from .services import ZWaveServices
@ -116,17 +121,27 @@ def register_node_in_dev_reg(
) -> device_registry.DeviceEntry: ) -> device_registry.DeviceEntry:
"""Register node in dev reg.""" """Register node in dev reg."""
device_id = get_device_id(client, node) device_id = get_device_id(client, node)
# If a device already exists but it doesn't match the new node, it means the node device_id_ext = get_device_id_ext(client, node)
# was replaced with a different device and the device needs to be removeed so the device = dev_reg.async_get_device({device_id})
# new device can be created. Otherwise if the device exists and the node is the same,
# the node was replaced with the same device model and we can reuse the device. # Replace the device if it can be determined that this node is not the
if (device := dev_reg.async_get_device({device_id})) and ( # same product as it was previously.
device.model != node.device_config.label if (
or device.manufacturer != node.device_config.manufacturer device_id_ext
and device
and len(device.identifiers) == 2
and device_id_ext not in device.identifiers
): ):
remove_device_func(device) remove_device_func(device)
device = None
if device_id_ext:
ids = {device_id, device_id_ext}
else:
ids = {device_id}
params = { params = {
ATTR_IDENTIFIERS: {device_id}, ATTR_IDENTIFIERS: ids,
ATTR_SW_VERSION: node.firmware_version, ATTR_SW_VERSION: node.firmware_version,
ATTR_NAME: node.name ATTR_NAME: node.name
or node.device_config.description or node.device_config.description
@ -338,7 +353,14 @@ async def async_setup_entry( # noqa: C901
device = dev_reg.async_get_device({dev_id}) device = dev_reg.async_get_device({dev_id})
# We assert because we know the device exists # We assert because we know the device exists
assert device assert device
if not replaced: if replaced:
discovered_value_ids.pop(device.id, None)
async_dispatcher_send(
hass,
f"{DOMAIN}_{client.driver.controller.home_id}.{node.node_id}.node_status_remove_entity",
)
else:
remove_device(device) remove_device(device)
@callback @callback

View File

@ -21,6 +21,7 @@ from homeassistant.const import (
CONF_DOMAIN, CONF_DOMAIN,
CONF_ENTITY_ID, CONF_ENTITY_ID,
CONF_TYPE, CONF_TYPE,
STATE_UNAVAILABLE,
) )
from homeassistant.core import Context, HomeAssistant from homeassistant.core import Context, HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
@ -172,7 +173,17 @@ async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]:
meter_endpoints: dict[int, dict[str, Any]] = defaultdict(dict) meter_endpoints: dict[int, dict[str, Any]] = defaultdict(dict)
for entry in entity_registry.async_entries_for_device(registry, device_id): for entry in entity_registry.async_entries_for_device(
registry, device_id, include_disabled_entities=False
):
# If an entry is unavailable, it is possible that the underlying value
# is no longer valid. Additionally, if an entry is disabled, its
# underlying value is not being monitored by HA so we shouldn't allow
# actions against it.
if (
state := hass.states.get(entry.entity_id)
) and state.state == STATE_UNAVAILABLE:
continue
entity_action = {**base_action, CONF_ENTITY_ID: entry.entity_id} entity_action = {**base_action, CONF_ENTITY_ID: entry.entity_id}
actions.append({**entity_action, CONF_TYPE: SERVICE_REFRESH_VALUE}) actions.append({**entity_action, CONF_TYPE: SERVICE_REFRESH_VALUE})
if entry.domain == LOCK_DOMAIN: if entry.domain == LOCK_DOMAIN:
@ -187,10 +198,9 @@ async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]:
value_id = entry.unique_id.split(".")[1] value_id = entry.unique_id.split(".")[1]
# If this unique ID doesn't have a value ID, we know it is the node status # If this unique ID doesn't have a value ID, we know it is the node status
# sensor which doesn't have any relevant actions # sensor which doesn't have any relevant actions
if re.match(VALUE_ID_REGEX, value_id): if not re.match(VALUE_ID_REGEX, value_id):
value = node.values[value_id]
else:
continue continue
value = node.values[value_id]
# If the value has the meterType CC specific value, we can add a reset_meter # If the value has the meterType CC specific value, we can add a reset_meter
# action for it # action for it
if CC_SPECIFIC_METER_TYPE in value.metadata.cc_specific: if CC_SPECIFIC_METER_TYPE in value.metadata.cc_specific:

View File

@ -20,6 +20,7 @@ from .migrate import async_add_migration_entity_value
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
EVENT_VALUE_UPDATED = "value updated" EVENT_VALUE_UPDATED = "value updated"
EVENT_VALUE_REMOVED = "value removed"
EVENT_DEAD = "dead" EVENT_DEAD = "dead"
EVENT_ALIVE = "alive" EVENT_ALIVE = "alive"
@ -99,6 +100,10 @@ class ZWaveBaseEntity(Entity):
self.async_on_remove( self.async_on_remove(
self.info.node.on(EVENT_VALUE_UPDATED, self._value_changed) self.info.node.on(EVENT_VALUE_UPDATED, self._value_changed)
) )
self.async_on_remove(
self.info.node.on(EVENT_VALUE_REMOVED, self._value_removed)
)
for status_event in (EVENT_ALIVE, EVENT_DEAD): for status_event in (EVENT_ALIVE, EVENT_DEAD):
self.async_on_remove( self.async_on_remove(
self.info.node.on(status_event, self._node_status_alive_or_dead) self.info.node.on(status_event, self._node_status_alive_or_dead)
@ -171,7 +176,7 @@ class ZWaveBaseEntity(Entity):
@callback @callback
def _value_changed(self, event_data: dict) -> None: def _value_changed(self, event_data: dict) -> None:
"""Call when (one of) our watched values changes. """Call when a value associated with our node changes.
Should not be overridden by subclasses. Should not be overridden by subclasses.
""" """
@ -193,6 +198,25 @@ class ZWaveBaseEntity(Entity):
self.on_value_update() self.on_value_update()
self.async_write_ha_state() self.async_write_ha_state()
@callback
def _value_removed(self, event_data: dict) -> None:
"""Call when a value associated with our node is removed.
Should not be overridden by subclasses.
"""
value_id = event_data["value"].value_id
if value_id != self.info.primary_value.value_id:
return
LOGGER.debug(
"[%s] Primary value %s is being removed",
self.entity_id,
value_id,
)
self.hass.async_create_task(self.async_remove())
@callback @callback
def get_zwave_value( def get_zwave_value(
self, self,

View File

@ -66,6 +66,19 @@ def get_device_id(client: ZwaveClient, node: ZwaveNode) -> tuple[str, str]:
return (DOMAIN, f"{client.driver.controller.home_id}-{node.node_id}") return (DOMAIN, f"{client.driver.controller.home_id}-{node.node_id}")
@callback
def get_device_id_ext(client: ZwaveClient, node: ZwaveNode) -> tuple[str, str] | None:
"""Get extended device registry identifier for Z-Wave node."""
if None in (node.manufacturer_id, node.product_type, node.product_id):
return None
domain, dev_id = get_device_id(client, node)
return (
domain,
f"{dev_id}-{node.manufacturer_id}:{node.product_type}:{node.product_id}",
)
@callback @callback
def get_home_and_node_id_from_device_id(device_id: tuple[str, ...]) -> list[str]: def get_home_and_node_id_from_device_id(device_id: tuple[str, ...]) -> list[str]:
""" """

View File

@ -520,4 +520,11 @@ class ZWaveNodeStatusSensor(SensorEntity):
self.async_poll_value, self.async_poll_value,
) )
) )
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{self.unique_id}_remove_entity",
self.async_remove,
)
)
self.async_write_ha_state() self.async_write_ha_state()

View File

@ -7,7 +7,7 @@ from homeassistant.backports.enum import StrEnum
MAJOR_VERSION: Final = 2021 MAJOR_VERSION: Final = 2021
MINOR_VERSION: Final = 12 MINOR_VERSION: Final = 12
PATCH_VERSION: Final = "9" PATCH_VERSION: Final = "10"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0)

View File

@ -584,7 +584,9 @@ class DeviceRegistry:
configuration_url=device["configuration_url"], configuration_url=device["configuration_url"],
# type ignores (if tuple arg was cast): likely https://github.com/python/mypy/issues/8625 # type ignores (if tuple arg was cast): likely https://github.com/python/mypy/issues/8625
connections={tuple(conn) for conn in device["connections"]}, # type: ignore[misc] connections={tuple(conn) for conn in device["connections"]}, # type: ignore[misc]
disabled_by=device["disabled_by"], disabled_by=DeviceEntryDisabler(device["disabled_by"])
if device["disabled_by"]
else None,
entry_type=DeviceEntryType(device["entry_type"]) entry_type=DeviceEntryType(device["entry_type"])
if device["entry_type"] if device["entry_type"]
else None, else None,

View File

@ -14,7 +14,7 @@ Adafruit-SHT31==1.0.2
# Adafruit_BBIO==1.1.1 # Adafruit_BBIO==1.1.1
# homeassistant.components.homekit # homeassistant.components.homekit
HAP-python==4.3.0 HAP-python==4.4.0
# homeassistant.components.mastodon # homeassistant.components.mastodon
Mastodon.py==1.5.1 Mastodon.py==1.5.1
@ -388,7 +388,7 @@ beautifulsoup4==4.10.0
bellows==0.29.0 bellows==0.29.0
# homeassistant.components.bmw_connected_drive # homeassistant.components.bmw_connected_drive
bimmer_connected==0.8.7 bimmer_connected==0.8.10
# homeassistant.components.bizkaibus # homeassistant.components.bizkaibus
bizkaibus==0.1.1 bizkaibus==0.1.1
@ -659,7 +659,7 @@ fjaraskupan==1.0.2
flipr-api==1.4.1 flipr-api==1.4.1
# homeassistant.components.flux_led # homeassistant.components.flux_led
flux_led==0.27.45 flux_led==0.28.4
# homeassistant.components.homekit # homeassistant.components.homekit
fnvhash==0.1.0 fnvhash==0.1.0
@ -1066,7 +1066,7 @@ nettigo-air-monitor==1.2.1
neurio==0.3.1 neurio==0.3.1
# homeassistant.components.nexia # homeassistant.components.nexia
nexia==0.9.12 nexia==0.9.13
# homeassistant.components.nextcloud # homeassistant.components.nextcloud
nextcloudmonitor==1.1.0 nextcloudmonitor==1.1.0
@ -2466,7 +2466,7 @@ xs1-api-client==3.0.0
yalesmartalarmclient==0.3.4 yalesmartalarmclient==0.3.4
# homeassistant.components.august # homeassistant.components.august
yalexs==1.1.17 yalexs==1.1.19
# homeassistant.components.yeelight # homeassistant.components.yeelight
yeelight==0.7.8 yeelight==0.7.8

View File

@ -7,7 +7,7 @@
AEMET-OpenData==0.2.1 AEMET-OpenData==0.2.1
# homeassistant.components.homekit # homeassistant.components.homekit
HAP-python==4.3.0 HAP-python==4.4.0
# homeassistant.components.flick_electric # homeassistant.components.flick_electric
PyFlick==0.0.2 PyFlick==0.0.2
@ -257,7 +257,7 @@ base36==0.1.1
bellows==0.29.0 bellows==0.29.0
# homeassistant.components.bmw_connected_drive # homeassistant.components.bmw_connected_drive
bimmer_connected==0.8.7 bimmer_connected==0.8.10
# homeassistant.components.blebox # homeassistant.components.blebox
blebox_uniapi==1.3.3 blebox_uniapi==1.3.3
@ -399,7 +399,7 @@ fjaraskupan==1.0.2
flipr-api==1.4.1 flipr-api==1.4.1
# homeassistant.components.flux_led # homeassistant.components.flux_led
flux_led==0.27.45 flux_led==0.28.4
# homeassistant.components.homekit # homeassistant.components.homekit
fnvhash==0.1.0 fnvhash==0.1.0
@ -654,7 +654,7 @@ netmap==0.7.0.2
nettigo-air-monitor==1.2.1 nettigo-air-monitor==1.2.1
# homeassistant.components.nexia # homeassistant.components.nexia
nexia==0.9.12 nexia==0.9.13
# homeassistant.components.nfandroidtv # homeassistant.components.nfandroidtv
notifications-android-tv==0.1.3 notifications-android-tv==0.1.3
@ -1464,7 +1464,7 @@ xmltodict==0.12.0
yalesmartalarmclient==0.3.4 yalesmartalarmclient==0.3.4
# homeassistant.components.august # homeassistant.components.august
yalexs==1.1.17 yalexs==1.1.19
# homeassistant.components.yeelight # homeassistant.components.yeelight
yeelight==0.7.8 yeelight==0.7.8

View File

@ -162,10 +162,17 @@ async def _create_august_with_devices( # noqa: C901
"unlock_return_activities" "unlock_return_activities"
] = unlock_return_activities_side_effect ] = unlock_return_activities_side_effect
return await _mock_setup_august_with_api_side_effects( api_instance, entry = await _mock_setup_august_with_api_side_effects(
hass, api_call_side_effects, pubnub hass, api_call_side_effects, pubnub
) )
if device_data["locks"]:
# Ensure we sync status when the integration is loaded if there
# are any locks
assert api_instance.async_status_async.mock_calls
return entry
async def _mock_setup_august_with_api_side_effects(hass, api_call_side_effects, pubnub): async def _mock_setup_august_with_api_side_effects(hass, api_call_side_effects, pubnub):
api_instance = MagicMock(name="Api") api_instance = MagicMock(name="Api")
@ -207,9 +214,10 @@ async def _mock_setup_august_with_api_side_effects(hass, api_call_side_effects,
api_instance.async_unlock_async = AsyncMock() api_instance.async_unlock_async = AsyncMock()
api_instance.async_lock_async = AsyncMock() api_instance.async_lock_async = AsyncMock()
api_instance.async_status_async = AsyncMock()
api_instance.async_get_user = AsyncMock(return_value={"UserID": "abc"}) api_instance.async_get_user = AsyncMock(return_value={"UserID": "abc"})
return await _mock_setup_august(hass, api_instance, pubnub) return api_instance, await _mock_setup_august(hass, api_instance, pubnub)
def _mock_august_authentication(token_text, token_timestamp, state): def _mock_august_authentication(token_text, token_timestamp, state):

View File

@ -39,6 +39,9 @@ from homeassistant.components.light import (
ATTR_RGBWW_COLOR, ATTR_RGBWW_COLOR,
ATTR_SUPPORTED_COLOR_MODES, ATTR_SUPPORTED_COLOR_MODES,
ATTR_WHITE, ATTR_WHITE,
COLOR_MODE_RGB,
COLOR_MODE_RGBW,
COLOR_MODE_RGBWW,
DOMAIN as LIGHT_DOMAIN, DOMAIN as LIGHT_DOMAIN,
) )
from homeassistant.const import ( from homeassistant.const import (
@ -247,9 +250,11 @@ async def test_rgb_light(hass: HomeAssistant) -> None:
blocking=True, blocking=True,
) )
# If the bulb is on and we are using existing brightness # If the bulb is on and we are using existing brightness
# and brightness was 0 it means we could not read it because # and brightness was 0 older devices will not be able to turn on
# an effect is in progress so we use 255 # so we need to make sure its at least 1 and that we
bulb.async_set_levels.assert_called_with(10, 10, 30, brightness=255) # call it before the turn on command since the device
# does not support auto on
bulb.async_set_levels.assert_called_with(10, 10, 30, brightness=1)
bulb.async_set_levels.reset_mock() bulb.async_set_levels.reset_mock()
bulb.brightness = 128 bulb.brightness = 128
@ -304,9 +309,9 @@ async def test_rgb_light_auto_on(hass: HomeAssistant) -> None:
assert state.state == STATE_ON assert state.state == STATE_ON
attributes = state.attributes attributes = state.attributes
assert attributes[ATTR_BRIGHTNESS] == 128 assert attributes[ATTR_BRIGHTNESS] == 128
assert attributes[ATTR_COLOR_MODE] == "rgb" assert attributes[ATTR_COLOR_MODE] == COLOR_MODE_RGB
assert attributes[ATTR_EFFECT_LIST] == bulb.effect_list assert attributes[ATTR_EFFECT_LIST] == bulb.effect_list
assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["rgb"] assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [COLOR_MODE_RGB]
assert attributes[ATTR_HS_COLOR] == (0, 100) assert attributes[ATTR_HS_COLOR] == (0, 100)
await hass.services.async_call( await hass.services.async_call(
@ -331,6 +336,19 @@ async def test_rgb_light_auto_on(hass: HomeAssistant) -> None:
bulb.async_set_levels.reset_mock() bulb.async_set_levels.reset_mock()
bulb.async_turn_on.reset_mock() bulb.async_turn_on.reset_mock()
await hass.services.async_call(
LIGHT_DOMAIN,
"turn_on",
{ATTR_ENTITY_ID: entity_id, ATTR_RGB_COLOR: (0, 0, 0)},
blocking=True,
)
# If the bulb is off and we are using existing brightness
# it has to be at least 1 or the bulb won't turn on
bulb.async_turn_on.assert_not_called()
bulb.async_set_levels.assert_called_with(1, 1, 1, brightness=1)
bulb.async_set_levels.reset_mock()
bulb.async_turn_on.reset_mock()
# Should still be called with no kwargs # Should still be called with no kwargs
await hass.services.async_call( await hass.services.async_call(
LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True
@ -357,10 +375,11 @@ async def test_rgb_light_auto_on(hass: HomeAssistant) -> None:
blocking=True, blocking=True,
) )
# If the bulb is on and we are using existing brightness # If the bulb is on and we are using existing brightness
# and brightness was 0 it means we could not read it because # and brightness was 0 we need to set it to at least 1
# an effect is in progress so we use 255 # or the device may not turn on
bulb.async_turn_on.assert_not_called() bulb.async_turn_on.assert_not_called()
bulb.async_set_levels.assert_called_with(10, 10, 30, brightness=255) bulb.async_set_brightness.assert_not_called()
bulb.async_set_levels.assert_called_with(10, 10, 30, brightness=1)
bulb.async_set_levels.reset_mock() bulb.async_set_levels.reset_mock()
bulb.brightness = 128 bulb.brightness = 128
@ -395,6 +414,236 @@ async def test_rgb_light_auto_on(hass: HomeAssistant) -> None:
bulb.async_set_effect.reset_mock() bulb.async_set_effect.reset_mock()
async def test_rgbw_light_auto_on(hass: HomeAssistant) -> None:
"""Test an rgbw light that does not need the turn on command sent."""
config_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE},
unique_id=MAC_ADDRESS,
)
config_entry.add_to_hass(hass)
bulb = _mocked_bulb()
bulb.requires_turn_on = False
bulb.raw_state = bulb.raw_state._replace(model_num=0x33) # RGB only model
bulb.color_modes = {FLUX_COLOR_MODE_RGBW}
bulb.color_mode = FLUX_COLOR_MODE_RGBW
with _patch_discovery(), _patch_wifibulb(device=bulb):
await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}})
await hass.async_block_till_done()
entity_id = "light.bulb_rgbcw_ddeeff"
state = hass.states.get(entity_id)
assert state.state == STATE_ON
attributes = state.attributes
assert attributes[ATTR_BRIGHTNESS] == 128
assert attributes[ATTR_COLOR_MODE] == COLOR_MODE_RGBW
assert attributes[ATTR_EFFECT_LIST] == bulb.effect_list
assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [COLOR_MODE_RGBW]
assert attributes[ATTR_HS_COLOR] == (0.0, 83.529)
await hass.services.async_call(
LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True
)
bulb.async_turn_off.assert_called_once()
await async_mock_device_turn_off(hass, bulb)
assert hass.states.get(entity_id).state == STATE_OFF
bulb.brightness = 0
await hass.services.async_call(
LIGHT_DOMAIN,
"turn_on",
{ATTR_ENTITY_ID: entity_id, ATTR_RGBW_COLOR: (10, 10, 30, 0)},
blocking=True,
)
# If the bulb is off and we are using existing brightness
# it has to be at least 1 or the bulb won't turn on
bulb.async_turn_on.assert_not_called()
bulb.async_set_levels.assert_called_with(10, 10, 30, 0)
bulb.async_set_levels.reset_mock()
bulb.async_turn_on.reset_mock()
# Should still be called with no kwargs
await hass.services.async_call(
LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True
)
bulb.async_turn_on.assert_called_once()
await async_mock_device_turn_on(hass, bulb)
assert hass.states.get(entity_id).state == STATE_ON
bulb.async_turn_on.reset_mock()
await hass.services.async_call(
LIGHT_DOMAIN,
"turn_on",
{ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100},
blocking=True,
)
bulb.async_turn_on.assert_not_called()
bulb.async_set_brightness.assert_called_with(100)
bulb.async_set_brightness.reset_mock()
await hass.services.async_call(
LIGHT_DOMAIN,
"turn_on",
{ATTR_ENTITY_ID: entity_id, ATTR_RGBW_COLOR: (0, 0, 0, 0)},
blocking=True,
)
# If the bulb is on and we are using existing brightness
# and brightness was 0 we need to set it to at least 1
# or the device may not turn on
bulb.async_turn_on.assert_not_called()
bulb.async_set_brightness.assert_not_called()
bulb.async_set_levels.assert_called_with(1, 1, 1, 0)
bulb.async_set_levels.reset_mock()
bulb.brightness = 128
await hass.services.async_call(
LIGHT_DOMAIN,
"turn_on",
{ATTR_ENTITY_ID: entity_id, ATTR_HS_COLOR: (10, 30)},
blocking=True,
)
bulb.async_turn_on.assert_not_called()
bulb.async_set_levels.assert_called_with(110, 19, 0, 255)
bulb.async_set_levels.reset_mock()
await hass.services.async_call(
LIGHT_DOMAIN,
"turn_on",
{ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "random"},
blocking=True,
)
bulb.async_turn_on.assert_not_called()
bulb.async_set_effect.assert_called_once()
bulb.async_set_effect.reset_mock()
await hass.services.async_call(
LIGHT_DOMAIN,
"turn_on",
{ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "purple_fade"},
blocking=True,
)
bulb.async_turn_on.assert_not_called()
bulb.async_set_effect.assert_called_with("purple_fade", 50, 50)
bulb.async_set_effect.reset_mock()
async def test_rgbww_light_auto_on(hass: HomeAssistant) -> None:
"""Test an rgbww light that does not need the turn on command sent."""
config_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE},
unique_id=MAC_ADDRESS,
)
config_entry.add_to_hass(hass)
bulb = _mocked_bulb()
bulb.requires_turn_on = False
bulb.raw_state = bulb.raw_state._replace(model_num=0x33) # RGB only model
bulb.color_modes = {FLUX_COLOR_MODE_RGBWW}
bulb.color_mode = FLUX_COLOR_MODE_RGBWW
with _patch_discovery(), _patch_wifibulb(device=bulb):
await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}})
await hass.async_block_till_done()
entity_id = "light.bulb_rgbcw_ddeeff"
state = hass.states.get(entity_id)
assert state.state == STATE_ON
attributes = state.attributes
assert attributes[ATTR_BRIGHTNESS] == 128
assert attributes[ATTR_COLOR_MODE] == COLOR_MODE_RGBWW
assert attributes[ATTR_EFFECT_LIST] == bulb.effect_list
assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [COLOR_MODE_RGBWW]
assert attributes[ATTR_HS_COLOR] == (3.237, 94.51)
await hass.services.async_call(
LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True
)
bulb.async_turn_off.assert_called_once()
await async_mock_device_turn_off(hass, bulb)
assert hass.states.get(entity_id).state == STATE_OFF
bulb.brightness = 0
await hass.services.async_call(
LIGHT_DOMAIN,
"turn_on",
{ATTR_ENTITY_ID: entity_id, ATTR_RGBWW_COLOR: (10, 10, 30, 0, 0)},
blocking=True,
)
# If the bulb is off and we are using existing brightness
# it has to be at least 1 or the bulb won't turn on
bulb.async_turn_on.assert_not_called()
bulb.async_set_levels.assert_called_with(10, 10, 30, 0, 0)
bulb.async_set_levels.reset_mock()
bulb.async_turn_on.reset_mock()
# Should still be called with no kwargs
await hass.services.async_call(
LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True
)
bulb.async_turn_on.assert_called_once()
await async_mock_device_turn_on(hass, bulb)
assert hass.states.get(entity_id).state == STATE_ON
bulb.async_turn_on.reset_mock()
await hass.services.async_call(
LIGHT_DOMAIN,
"turn_on",
{ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100},
blocking=True,
)
bulb.async_turn_on.assert_not_called()
bulb.async_set_brightness.assert_called_with(100)
bulb.async_set_brightness.reset_mock()
await hass.services.async_call(
LIGHT_DOMAIN,
"turn_on",
{ATTR_ENTITY_ID: entity_id, ATTR_RGBWW_COLOR: (0, 0, 0, 0, 0)},
blocking=True,
)
# If the bulb is on and we are using existing brightness
# and brightness was 0 we need to set it to at least 1
# or the device may not turn on
bulb.async_turn_on.assert_not_called()
bulb.async_set_brightness.assert_not_called()
bulb.async_set_levels.assert_called_with(1, 1, 1, 0, 0)
bulb.async_set_levels.reset_mock()
bulb.brightness = 128
await hass.services.async_call(
LIGHT_DOMAIN,
"turn_on",
{ATTR_ENTITY_ID: entity_id, ATTR_HS_COLOR: (10, 30)},
blocking=True,
)
bulb.async_turn_on.assert_not_called()
bulb.async_set_levels.assert_called_with(14, 0, 30, 255, 255)
bulb.async_set_levels.reset_mock()
await hass.services.async_call(
LIGHT_DOMAIN,
"turn_on",
{ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "random"},
blocking=True,
)
bulb.async_turn_on.assert_not_called()
bulb.async_set_effect.assert_called_once()
bulb.async_set_effect.reset_mock()
await hass.services.async_call(
LIGHT_DOMAIN,
"turn_on",
{ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "purple_fade"},
blocking=True,
)
bulb.async_turn_on.assert_not_called()
bulb.async_set_effect.assert_called_with("purple_fade", 50, 50)
bulb.async_set_effect.reset_mock()
async def test_rgb_cct_light(hass: HomeAssistant) -> None: async def test_rgb_cct_light(hass: HomeAssistant) -> None:
"""Test an rgb cct light.""" """Test an rgb cct light."""
config_entry = MockConfigEntry( config_entry = MockConfigEntry(

View File

@ -2,9 +2,14 @@
from unittest.mock import patch from unittest.mock import patch
from homeassistant import config_entries, data_entry_flow from homeassistant import config_entries, data_entry_flow
from homeassistant.components.homekit.const import DOMAIN, SHORT_BRIDGE_NAME from homeassistant.components.homekit.const import (
CONF_FILTER,
DOMAIN,
SHORT_BRIDGE_NAME,
)
from homeassistant.config_entries import SOURCE_IGNORE, SOURCE_IMPORT from homeassistant.config_entries import SOURCE_IGNORE, SOURCE_IMPORT
from homeassistant.const import CONF_NAME, CONF_PORT from homeassistant.const import CONF_NAME, CONF_PORT
from homeassistant.helpers.entityfilter import CONF_INCLUDE_DOMAINS
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from .util import PATH_HOMEKIT, async_init_entry from .util import PATH_HOMEKIT, async_init_entry
@ -347,6 +352,10 @@ async def test_options_flow_exclude_mode_basic(hass, mock_get_source_ip):
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "include_exclude" assert result["step_id"] == "include_exclude"
# Inject garbage to ensure the options data
# is being deep copied and we cannot mutate it in flight
config_entry.options[CONF_FILTER][CONF_INCLUDE_DOMAINS].append("garbage")
result2 = await hass.config_entries.options.async_configure( result2 = await hass.config_entries.options.async_configure(
result["flow_id"], result["flow_id"],
user_input={"entities": ["climate.old"], "include_exclude_mode": "exclude"}, user_input={"entities": ["climate.old"], "include_exclude_mode": "exclude"},

View File

@ -1266,6 +1266,7 @@ async def test_thermostat_hvac_modes_with_heat_only(hass, hk_driver):
await hass.async_block_till_done() await hass.async_block_till_done()
hap = acc.char_target_heat_cool.to_HAP() hap = acc.char_target_heat_cool.to_HAP()
assert hap["valid-values"] == [HC_HEAT_COOL_OFF, HC_HEAT_COOL_HEAT] assert hap["valid-values"] == [HC_HEAT_COOL_OFF, HC_HEAT_COOL_HEAT]
assert acc.char_target_heat_cool.allow_invalid_client_values is True
assert acc.char_target_heat_cool.value == HC_HEAT_COOL_HEAT assert acc.char_target_heat_cool.value == HC_HEAT_COOL_HEAT
acc.char_target_heat_cool.set_value(HC_HEAT_COOL_HEAT) acc.char_target_heat_cool.set_value(HC_HEAT_COOL_HEAT)
@ -1303,6 +1304,29 @@ async def test_thermostat_hvac_modes_with_heat_only(hass, hk_driver):
assert call_set_hvac_mode[0].data[ATTR_ENTITY_ID] == entity_id assert call_set_hvac_mode[0].data[ATTR_ENTITY_ID] == entity_id
assert call_set_hvac_mode[0].data[ATTR_HVAC_MODE] == HVAC_MODE_HEAT assert call_set_hvac_mode[0].data[ATTR_HVAC_MODE] == HVAC_MODE_HEAT
acc.char_target_heat_cool.client_update_value(HC_HEAT_COOL_OFF)
await hass.async_block_till_done()
assert acc.char_target_heat_cool.value == HC_HEAT_COOL_OFF
hass.states.async_set(
entity_id, HVAC_MODE_OFF, {ATTR_HVAC_MODES: [HVAC_MODE_HEAT, HVAC_MODE_OFF]}
)
await hass.async_block_till_done()
hk_driver.set_characteristics(
{
HAP_REPR_CHARS: [
{
HAP_REPR_AID: acc.aid,
HAP_REPR_IID: char_target_heat_cool_iid,
HAP_REPR_VALUE: HC_HEAT_COOL_AUTO,
},
]
},
"mock_addr",
)
await hass.async_block_till_done()
assert acc.char_target_heat_cool.value == HC_HEAT_COOL_HEAT
async def test_thermostat_hvac_modes_with_cool_only(hass, hk_driver): async def test_thermostat_hvac_modes_with_cool_only(hass, hk_driver):
"""Test if unsupported HVAC modes are deactivated in HomeKit and siri calls get converted to cool.""" """Test if unsupported HVAC modes are deactivated in HomeKit and siri calls get converted to cool."""

View File

@ -473,6 +473,24 @@ def fortrezz_ssa1_siren_state_fixture():
return json.loads(load_fixture("zwave_js/fortrezz_ssa1_siren_state.json")) return json.loads(load_fixture("zwave_js/fortrezz_ssa1_siren_state.json"))
@pytest.fixture(name="fortrezz_ssa3_siren_state", scope="session")
def fortrezz_ssa3_siren_state_fixture():
"""Load the fortrezz ssa3 siren node state fixture data."""
return json.loads(load_fixture("zwave_js/fortrezz_ssa3_siren_state.json"))
@pytest.fixture(name="zp3111_not_ready_state", scope="session")
def zp3111_not_ready_state_fixture():
"""Load the zp3111 4-in-1 sensor not-ready node state fixture data."""
return json.loads(load_fixture("zwave_js/zp3111-5_not_ready_state.json"))
@pytest.fixture(name="zp3111_state", scope="session")
def zp3111_state_fixture():
"""Load the zp3111 4-in-1 sensor node state fixture data."""
return json.loads(load_fixture("zwave_js/zp3111-5_state.json"))
@pytest.fixture(name="client") @pytest.fixture(name="client")
def mock_client_fixture(controller_state, version_state, log_config_state): def mock_client_fixture(controller_state, version_state, log_config_state):
"""Mock a client.""" """Mock a client."""
@ -905,3 +923,19 @@ def fortrezz_ssa1_siren_fixture(client, fortrezz_ssa1_siren_state):
def firmware_file_fixture(): def firmware_file_fixture():
"""Return mock firmware file stream.""" """Return mock firmware file stream."""
return io.BytesIO(bytes(10)) return io.BytesIO(bytes(10))
@pytest.fixture(name="zp3111_not_ready")
def zp3111_not_ready_fixture(client, zp3111_not_ready_state):
"""Mock a zp3111 4-in-1 sensor node in a not-ready state."""
node = Node(client, copy.deepcopy(zp3111_not_ready_state))
client.driver.controller.nodes[node.node_id] = node
return node
@pytest.fixture(name="zp3111")
def zp3111_fixture(client, zp3111_state):
"""Mock a zp3111 4-in-1 sensor node."""
node = Node(client, copy.deepcopy(zp3111_state))
client.driver.controller.nodes[node.node_id] = node
return node

View File

@ -0,0 +1,68 @@
{
"nodeId": 22,
"index": 0,
"status": 1,
"ready": false,
"isListening": false,
"isRouting": true,
"isSecure": "unknown",
"interviewAttempts": 1,
"endpoints": [
{
"nodeId": 22,
"index": 0,
"deviceClass": {
"basic": {
"key": 4,
"label": "Routing Slave"
},
"generic": {
"key": 7,
"label": "Notification Sensor"
},
"specific": {
"key": 1,
"label": "Notification Sensor"
},
"mandatorySupportedCCs": [],
"mandatoryControlledCCs": []
}
}
],
"values": [],
"isFrequentListening": false,
"maxDataRate": 100000,
"supportedDataRates": [
40000,
100000
],
"protocolVersion": 3,
"supportsBeaming": true,
"supportsSecurity": false,
"nodeType": 1,
"deviceClass": {
"basic": {
"key": 4,
"label": "Routing Slave"
},
"generic": {
"key": 7,
"label": "Notification Sensor"
},
"specific": {
"key": 1,
"label": "Notification Sensor"
},
"mandatorySupportedCCs": [],
"mandatoryControlledCCs": []
},
"commandClasses": [],
"interviewStage": "ProtocolInfo",
"statistics": {
"commandsTX": 0,
"commandsRX": 0,
"commandsDroppedRX": 0,
"commandsDroppedTX": 0,
"timeoutResponse": 0
}
}

View File

@ -0,0 +1,706 @@
{
"nodeId": 22,
"index": 0,
"installerIcon": 3079,
"userIcon": 3079,
"status": 2,
"ready": true,
"isListening": false,
"isRouting": true,
"isSecure": false,
"manufacturerId": 265,
"productId": 8449,
"productType": 8225,
"firmwareVersion": "5.1",
"zwavePlusVersion": 1,
"deviceConfig": {
"filename": "/cache/db/devices/0x0109/zp3111-5.json",
"isEmbedded": true,
"manufacturer": "Vision Security",
"manufacturerId": 265,
"label": "ZP3111-5",
"description": "4-in-1 Sensor",
"devices": [
{
"productType": 8225,
"productId": 8449
}
],
"firmwareVersion": {
"min": "0.0",
"max": "255.255"
},
"paramInformation": {
"_map": {}
},
"metadata": {
"inclusion": "To add the ZP3111 to the Z-Wave network (inclusion), place the Z-Wave primary controller into inclusion mode. Press the Program Switch of ZP3111 for sending the NIF. After sending NIF, Z-Wave will send the auto inclusion, otherwise, ZP3111 will go to sleep after 20 seconds.",
"exclusion": "To remove the ZP3111 from the Z-Wave network (exclusion), place the Z-Wave primary controller into “exclusion” mode, and following its instruction to delete the ZP3111 to the controller. Press the Program Switch of ZP3111 once to be excluded.",
"reset": "Remove cover to trigged tamper switch, LED flash once & send out Alarm Report. Press Program Switch 10 times within 10 seconds, ZP3111 will send the “Device Reset Locally Notification” command and reset to the factory default. (Remark: This is to be used only in the case of primary controller being inoperable or otherwise unavailable.)",
"manual": "https://products.z-wavealliance.org/ProductManual/File?folder=&filename=MarketCertificationFiles/2479/ZP3111-5_R2_20170316.pdf"
}
},
"label": "ZP3111-5",
"interviewAttempts": 1,
"endpoints": [
{
"nodeId": 22,
"index": 0,
"installerIcon": 3079,
"userIcon": 3079,
"deviceClass": {
"basic": {
"key": 4,
"label": "Routing Slave"
},
"generic": {
"key": 7,
"label": "Notification Sensor"
},
"specific": {
"key": 1,
"label": "Notification Sensor"
},
"mandatorySupportedCCs": [],
"mandatoryControlledCCs": []
}
}
],
"values": [
{
"endpoint": 0,
"commandClass": 134,
"commandClassName": "Version",
"property": "libraryType",
"propertyName": "libraryType",
"ccVersion": 2,
"metadata": {
"type": "number",
"readable": true,
"writeable": false,
"label": "Library type",
"states": {
"0": "Unknown",
"1": "Static Controller",
"2": "Controller",
"3": "Enhanced Slave",
"4": "Slave",
"5": "Installer",
"6": "Routing Slave",
"7": "Bridge Controller",
"8": "Device under Test",
"9": "N/A",
"10": "AV Remote",
"11": "AV Device"
}
},
"value": 3
},
{
"endpoint": 0,
"commandClass": 134,
"commandClassName": "Version",
"property": "protocolVersion",
"propertyName": "protocolVersion",
"ccVersion": 2,
"metadata": {
"type": "string",
"readable": true,
"writeable": false,
"label": "Z-Wave protocol version"
},
"value": "4.5"
},
{
"endpoint": 0,
"commandClass": 134,
"commandClassName": "Version",
"property": "firmwareVersions",
"propertyName": "firmwareVersions",
"ccVersion": 2,
"metadata": {
"type": "string[]",
"readable": true,
"writeable": false,
"label": "Z-Wave chip firmware versions"
},
"value": [
"5.1",
"10.1"
]
},
{
"endpoint": 0,
"commandClass": 134,
"commandClassName": "Version",
"property": "hardwareVersion",
"propertyName": "hardwareVersion",
"ccVersion": 2,
"metadata": {
"type": "number",
"readable": true,
"writeable": false,
"label": "Z-Wave chip hardware version"
},
"value": 1
},
{
"endpoint": 0,
"commandClass": 114,
"commandClassName": "Manufacturer Specific",
"property": "manufacturerId",
"propertyName": "manufacturerId",
"ccVersion": 2,
"metadata": {
"type": "number",
"readable": true,
"writeable": false,
"label": "Manufacturer ID",
"min": 0,
"max": 65535
},
"value": 265
},
{
"endpoint": 0,
"commandClass": 114,
"commandClassName": "Manufacturer Specific",
"property": "productType",
"propertyName": "productType",
"ccVersion": 2,
"metadata": {
"type": "number",
"readable": true,
"writeable": false,
"label": "Product type",
"min": 0,
"max": 65535
},
"value": 8225
},
{
"endpoint": 0,
"commandClass": 114,
"commandClassName": "Manufacturer Specific",
"property": "productId",
"propertyName": "productId",
"ccVersion": 2,
"metadata": {
"type": "number",
"readable": true,
"writeable": false,
"label": "Product ID",
"min": 0,
"max": 65535
},
"value": 8449
},
{
"endpoint": 0,
"commandClass": 128,
"commandClassName": "Battery",
"property": "level",
"propertyName": "level",
"ccVersion": 1,
"metadata": {
"type": "number",
"readable": true,
"writeable": false,
"label": "Battery level",
"min": 0,
"max": 100,
"unit": "%"
},
"value": 0
},
{
"endpoint": 0,
"commandClass": 128,
"commandClassName": "Battery",
"property": "isLow",
"propertyName": "isLow",
"ccVersion": 1,
"metadata": {
"type": "boolean",
"readable": true,
"writeable": false,
"label": "Low battery level"
},
"value": true
},
{
"endpoint": 0,
"commandClass": 113,
"commandClassName": "Notification",
"property": "Home Security",
"propertyKey": "Cover status",
"propertyName": "Home Security",
"propertyKeyName": "Cover status",
"ccVersion": 4,
"metadata": {
"type": "number",
"readable": true,
"writeable": false,
"label": "Cover status",
"ccSpecific": {
"notificationType": 7
},
"min": 0,
"max": 255,
"states": {
"0": "idle",
"3": "Tampering, product cover removed"
}
},
"value": 0
},
{
"endpoint": 0,
"commandClass": 113,
"commandClassName": "Notification",
"property": "Home Security",
"propertyKey": "Motion sensor status",
"propertyName": "Home Security",
"propertyKeyName": "Motion sensor status",
"ccVersion": 4,
"metadata": {
"type": "number",
"readable": true,
"writeable": false,
"label": "Motion sensor status",
"ccSpecific": {
"notificationType": 7
},
"min": 0,
"max": 255,
"states": {
"0": "idle",
"8": "Motion detection"
}
},
"value": 0
},
{
"endpoint": 0,
"commandClass": 113,
"commandClassName": "Notification",
"property": "alarmType",
"propertyName": "alarmType",
"ccVersion": 4,
"metadata": {
"type": "number",
"readable": true,
"writeable": false,
"label": "Alarm Type",
"min": 0,
"max": 255
}
},
{
"endpoint": 0,
"commandClass": 113,
"commandClassName": "Notification",
"property": "alarmLevel",
"propertyName": "alarmLevel",
"ccVersion": 4,
"metadata": {
"type": "number",
"readable": true,
"writeable": false,
"label": "Alarm Level",
"min": 0,
"max": 255
}
},
{
"endpoint": 0,
"commandClass": 49,
"commandClassName": "Multilevel Sensor",
"property": "Air temperature",
"propertyName": "Air temperature",
"ccVersion": 7,
"metadata": {
"type": "number",
"readable": true,
"writeable": false,
"label": "Air temperature",
"ccSpecific": {
"sensorType": 1,
"scale": 0
},
"unit": "°C"
},
"value": 21.98
},
{
"endpoint": 0,
"commandClass": 49,
"commandClassName": "Multilevel Sensor",
"property": "Illuminance",
"propertyName": "Illuminance",
"ccVersion": 7,
"metadata": {
"type": "number",
"readable": true,
"writeable": false,
"label": "Illuminance",
"ccSpecific": {
"sensorType": 3,
"scale": 0
},
"unit": "%"
},
"value": 7.31
},
{
"endpoint": 0,
"commandClass": 49,
"commandClassName": "Multilevel Sensor",
"property": "Humidity",
"propertyName": "Humidity",
"ccVersion": 7,
"metadata": {
"type": "number",
"readable": true,
"writeable": false,
"label": "Humidity",
"ccSpecific": {
"sensorType": 5,
"scale": 0
},
"unit": "%"
},
"value": 51.98
},
{
"endpoint": 0,
"commandClass": 112,
"commandClassName": "Configuration",
"property": 1,
"propertyName": "Temperature Scale",
"ccVersion": 1,
"metadata": {
"type": "number",
"readable": true,
"writeable": true,
"label": "Temperature Scale",
"default": 0,
"min": 0,
"max": 1,
"states": {
"0": "Celsius",
"1": "Fahrenheit"
},
"valueSize": 1,
"format": 0,
"allowManualEntry": false,
"isFromConfig": true
},
"value": 0
},
{
"endpoint": 0,
"commandClass": 112,
"commandClassName": "Configuration",
"property": 2,
"propertyName": "Temperature offset",
"ccVersion": 1,
"metadata": {
"type": "number",
"readable": true,
"writeable": true,
"label": "Temperature offset",
"default": 1,
"min": 0,
"max": 50,
"valueSize": 1,
"format": 0,
"allowManualEntry": true,
"isFromConfig": true
},
"value": 10
},
{
"endpoint": 0,
"commandClass": 112,
"commandClassName": "Configuration",
"property": 3,
"propertyName": "Humidity",
"ccVersion": 1,
"metadata": {
"type": "number",
"readable": true,
"writeable": true,
"description": "Configure Relative Humidity",
"label": "Humidity",
"default": 10,
"min": 1,
"max": 50,
"unit": "percent",
"valueSize": 1,
"format": 0,
"allowManualEntry": true,
"isFromConfig": true
},
"value": 10
},
{
"endpoint": 0,
"commandClass": 112,
"commandClassName": "Configuration",
"property": 4,
"propertyName": "Light Sensor",
"ccVersion": 1,
"metadata": {
"type": "number",
"readable": true,
"writeable": true,
"label": "Light Sensor",
"default": 10,
"min": 1,
"max": 50,
"unit": "percent",
"valueSize": 1,
"format": 0,
"allowManualEntry": true,
"isFromConfig": true
},
"value": 10
},
{
"endpoint": 0,
"commandClass": 112,
"commandClassName": "Configuration",
"property": 5,
"propertyName": "Trigger Interval",
"ccVersion": 1,
"metadata": {
"type": "number",
"readable": true,
"writeable": true,
"description": "Set the trigger interval for motion sensor re-activation.",
"label": "Trigger Interval",
"default": 180,
"min": 1,
"max": 255,
"unit": "seconds",
"valueSize": 1,
"format": 1,
"allowManualEntry": true,
"isFromConfig": true
},
"value": 3
},
{
"endpoint": 0,
"commandClass": 112,
"commandClassName": "Configuration",
"property": 6,
"propertyName": "Motion Sensor Sensitivity",
"ccVersion": 1,
"metadata": {
"type": "number",
"readable": true,
"writeable": true,
"description": "Adjust sensitivity of the motion sensor.",
"label": "Motion Sensor Sensitivity",
"default": 4,
"min": 1,
"max": 7,
"states": {
"1": "highest",
"2": "higher",
"3": "high",
"4": "normal",
"5": "low",
"6": "lower",
"7": "lowest"
},
"valueSize": 1,
"format": 0,
"allowManualEntry": false,
"isFromConfig": true
},
"value": 4
},
{
"endpoint": 0,
"commandClass": 112,
"commandClassName": "Configuration",
"property": 7,
"propertyName": "LED indicator mode",
"ccVersion": 1,
"metadata": {
"type": "number",
"readable": true,
"writeable": true,
"label": "LED indicator mode",
"default": 3,
"min": 1,
"max": 3,
"states": {
"1": "Off",
"2": "Pulsing Temperature, Flashing Motion",
"3": "Flashing Temperature and Motion"
},
"valueSize": 1,
"format": 0,
"allowManualEntry": false,
"isFromConfig": true
},
"value": 3
},
{
"endpoint": 0,
"commandClass": 132,
"commandClassName": "Wake Up",
"property": "wakeUpInterval",
"propertyName": "wakeUpInterval",
"ccVersion": 2,
"metadata": {
"type": "number",
"default": 3600,
"readable": false,
"writeable": true,
"label": "Wake Up interval",
"min": 600,
"max": 604800,
"steps": 600
},
"value": 3600
},
{
"endpoint": 0,
"commandClass": 132,
"commandClassName": "Wake Up",
"property": "controllerNodeId",
"propertyName": "controllerNodeId",
"ccVersion": 2,
"metadata": {
"type": "any",
"readable": true,
"writeable": false,
"label": "Node ID of the controller"
},
"value": 1
}
],
"isFrequentListening": false,
"maxDataRate": 100000,
"supportedDataRates": [
40000,
100000
],
"protocolVersion": 3,
"supportsBeaming": true,
"supportsSecurity": false,
"nodeType": 1,
"zwavePlusNodeType": 0,
"zwavePlusRoleType": 6,
"deviceClass": {
"basic": {
"key": 4,
"label": "Routing Slave"
},
"generic": {
"key": 7,
"label": "Notification Sensor"
},
"specific": {
"key": 1,
"label": "Notification Sensor"
},
"mandatorySupportedCCs": [],
"mandatoryControlledCCs": []
},
"commandClasses": [
{
"id": 94,
"name": "Z-Wave Plus Info",
"version": 2,
"isSecure": false
},
{
"id": 134,
"name": "Version",
"version": 2,
"isSecure": false
},
{
"id": 114,
"name": "Manufacturer Specific",
"version": 2,
"isSecure": false
},
{
"id": 90,
"name": "Device Reset Locally",
"version": 1,
"isSecure": false
},
{
"id": 133,
"name": "Association",
"version": 2,
"isSecure": false
},
{
"id": 89,
"name": "Association Group Information",
"version": 1,
"isSecure": false
},
{
"id": 115,
"name": "Powerlevel",
"version": 1,
"isSecure": false
},
{
"id": 128,
"name": "Battery",
"version": 1,
"isSecure": false
},
{
"id": 113,
"name": "Notification",
"version": 4,
"isSecure": false
},
{
"id": 49,
"name": "Multilevel Sensor",
"version": 7,
"isSecure": false
},
{
"id": 112,
"name": "Configuration",
"version": 1,
"isSecure": false
},
{
"id": 132,
"name": "Wake Up",
"version": 2,
"isSecure": false
},
{
"id": 122,
"name": "Firmware Update Meta Data",
"version": 2,
"isSecure": false
}
],
"interviewStage": "Complete",
"deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0109:0x2021:0x2101:5.1",
"statistics": {
"commandsTX": 39,
"commandsRX": 38,
"commandsDroppedRX": 0,
"commandsDroppedTX": 0,
"timeoutResponse": 0
},
"highestSecurityClass": -1
}

View File

@ -11,6 +11,7 @@ from homeassistant.components import automation
from homeassistant.components.zwave_js import DOMAIN, device_action from homeassistant.components.zwave_js import DOMAIN, device_action
from homeassistant.components.zwave_js.helpers import get_device_id from homeassistant.components.zwave_js.helpers import get_device_id
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, device_registry from homeassistant.helpers import config_validation as cv, device_registry
@ -583,3 +584,23 @@ async def test_failure_scenarios(
) )
== {} == {}
) )
async def test_unavailable_entity_actions(
hass: HomeAssistant,
client: Client,
lock_schlage_be469: Node,
integration: ConfigEntry,
) -> None:
"""Test unavailable entities are not included in actions list."""
entity_id_unavailable = "binary_sensor.touchscreen_deadbolt_home_security_intrusion"
hass.states.async_set(entity_id_unavailable, STATE_UNAVAILABLE, force_update=True)
await hass.async_block_till_done()
node = lock_schlage_be469
dev_reg = device_registry.async_get(hass)
device = dev_reg.async_get_device({get_device_id(client, node)})
assert device
actions = await async_get_device_automations(hass, "action", device.id)
assert not any(
action.get("entity_id") == entity_id_unavailable for action in actions
)

View File

@ -12,7 +12,11 @@ from homeassistant.components.zwave_js.const import DOMAIN
from homeassistant.components.zwave_js.helpers import get_device_id from homeassistant.components.zwave_js.helpers import get_device_id
from homeassistant.config_entries import DISABLED_USER, ConfigEntryState from homeassistant.config_entries import DISABLED_USER, ConfigEntryState
from homeassistant.const import STATE_UNAVAILABLE from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers import (
area_registry as ar,
device_registry as dr,
entity_registry as er,
)
from .common import AIR_TEMPERATURE_SENSOR, EATON_RF9640_ENTITY from .common import AIR_TEMPERATURE_SENSOR, EATON_RF9640_ENTITY
@ -159,7 +163,7 @@ async def test_new_entity_on_value_added(hass, multisensor_6, client, integratio
async def test_on_node_added_ready(hass, multisensor_6_state, client, integration): async def test_on_node_added_ready(hass, multisensor_6_state, client, integration):
"""Test we handle a ready node added event.""" """Test we handle a node added event with a ready node."""
dev_reg = dr.async_get(hass) dev_reg = dr.async_get(hass)
node = Node(client, deepcopy(multisensor_6_state)) node = Node(client, deepcopy(multisensor_6_state))
event = {"node": node} event = {"node": node}
@ -182,38 +186,34 @@ async def test_on_node_added_ready(hass, multisensor_6_state, client, integratio
assert dev_reg.async_get_device(identifiers={(DOMAIN, air_temperature_device_id)}) assert dev_reg.async_get_device(identifiers={(DOMAIN, air_temperature_device_id)})
async def test_on_node_added_not_ready(hass, multisensor_6_state, client, integration): async def test_on_node_added_not_ready(
"""Test we handle a non ready node added event.""" hass, zp3111_not_ready_state, client, integration
):
"""Test we handle a node added event with a non-ready node."""
dev_reg = dr.async_get(hass) dev_reg = dr.async_get(hass)
node_data = deepcopy(multisensor_6_state) # Copy to allow modification in tests. device_id = f"{client.driver.controller.home_id}-{zp3111_not_ready_state['nodeId']}"
node = Node(client, node_data)
node.data["ready"] = False
event = {"node": node}
air_temperature_device_id = f"{client.driver.controller.home_id}-{node.node_id}"
state = hass.states.get(AIR_TEMPERATURE_SENSOR) assert len(hass.states.async_all()) == 0
assert not dev_reg.devices
assert not state # entity and device not yet added event = Event(
assert not dev_reg.async_get_device( type="node added",
identifiers={(DOMAIN, air_temperature_device_id)} data={
"source": "controller",
"event": "node added",
"node": deepcopy(zp3111_not_ready_state),
},
) )
client.driver.receive_event(event)
client.driver.controller.emit("node added", event)
await hass.async_block_till_done() await hass.async_block_till_done()
state = hass.states.get(AIR_TEMPERATURE_SENSOR) # the only entity is the node status sensor
assert len(hass.states.async_all()) == 1
assert not state # entity not yet added but device added in registry device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)})
assert dev_reg.async_get_device(identifiers={(DOMAIN, air_temperature_device_id)}) assert device
# no extended device identifier yet
node.data["ready"] = True assert len(device.identifiers) == 1
node.emit("ready", event)
await hass.async_block_till_done()
state = hass.states.get(AIR_TEMPERATURE_SENSOR)
assert state # entity added
assert state.state != STATE_UNAVAILABLE
async def test_existing_node_ready(hass, client, multisensor_6, integration): async def test_existing_node_ready(hass, client, multisensor_6, integration):
@ -221,12 +221,157 @@ async def test_existing_node_ready(hass, client, multisensor_6, integration):
dev_reg = dr.async_get(hass) dev_reg = dr.async_get(hass)
node = multisensor_6 node = multisensor_6
air_temperature_device_id = f"{client.driver.controller.home_id}-{node.node_id}" air_temperature_device_id = f"{client.driver.controller.home_id}-{node.node_id}"
air_temperature_device_id_ext = (
f"{air_temperature_device_id}-{node.manufacturer_id}:"
f"{node.product_type}:{node.product_id}"
)
state = hass.states.get(AIR_TEMPERATURE_SENSOR) state = hass.states.get(AIR_TEMPERATURE_SENSOR)
assert state # entity and device added assert state # entity and device added
assert state.state != STATE_UNAVAILABLE assert state.state != STATE_UNAVAILABLE
assert dev_reg.async_get_device(identifiers={(DOMAIN, air_temperature_device_id)})
device = dev_reg.async_get_device(identifiers={(DOMAIN, air_temperature_device_id)})
assert device
assert device == dev_reg.async_get_device(
identifiers={(DOMAIN, air_temperature_device_id_ext)}
)
async def test_existing_node_not_ready(hass, zp3111_not_ready, client, integration):
"""Test we handle a non-ready node that exists during integration setup."""
dev_reg = dr.async_get(hass)
node = zp3111_not_ready
device_id = f"{client.driver.controller.home_id}-{node.node_id}"
device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)})
assert device.name == f"Node {node.node_id}"
assert not device.manufacturer
assert not device.model
assert not device.sw_version
# the only entity is the node status sensor
assert len(hass.states.async_all()) == 1
device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)})
assert device
# no extended device identifier yet
assert len(device.identifiers) == 1
async def test_existing_node_not_replaced_when_not_ready(
hass, zp3111, zp3111_not_ready_state, zp3111_state, client, integration
):
"""Test when a node added event with a non-ready node is received.
The existing node should not be replaced, and no customization should be lost.
"""
dev_reg = dr.async_get(hass)
er_reg = er.async_get(hass)
kitchen_area = ar.async_get(hass).async_create("Kitchen")
device_id = f"{client.driver.controller.home_id}-{zp3111.node_id}"
device_id_ext = (
f"{device_id}-{zp3111.manufacturer_id}:"
f"{zp3111.product_type}:{zp3111.product_id}"
)
device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)})
assert device
assert device.name == "4-in-1 Sensor"
assert not device.name_by_user
assert device.manufacturer == "Vision Security"
assert device.model == "ZP3111-5"
assert device.sw_version == "5.1"
assert not device.area_id
assert device == dev_reg.async_get_device(identifiers={(DOMAIN, device_id_ext)})
motion_entity = "binary_sensor.4_in_1_sensor_home_security_motion_detection"
state = hass.states.get(motion_entity)
assert state
assert state.name == "4-in-1 Sensor: Home Security - Motion detection"
dev_reg.async_update_device(
device.id, name_by_user="Custom Device Name", area_id=kitchen_area.id
)
custom_device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)})
assert custom_device
assert custom_device.name == "4-in-1 Sensor"
assert custom_device.name_by_user == "Custom Device Name"
assert custom_device.manufacturer == "Vision Security"
assert custom_device.model == "ZP3111-5"
assert device.sw_version == "5.1"
assert custom_device.area_id == kitchen_area.id
assert custom_device == dev_reg.async_get_device(
identifiers={(DOMAIN, device_id_ext)}
)
custom_entity = "binary_sensor.custom_motion_sensor"
er_reg.async_update_entity(
motion_entity, new_entity_id=custom_entity, name="Custom Entity Name"
)
await hass.async_block_till_done()
state = hass.states.get(custom_entity)
assert state
assert state.name == "Custom Entity Name"
assert not hass.states.get(motion_entity)
event = Event(
type="node added",
data={
"source": "controller",
"event": "node added",
"node": deepcopy(zp3111_not_ready_state),
},
)
client.driver.receive_event(event)
await hass.async_block_till_done()
device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)})
assert device
assert device == dev_reg.async_get_device(identifiers={(DOMAIN, device_id_ext)})
assert device.id == custom_device.id
assert device.identifiers == custom_device.identifiers
assert device.name == f"Node {zp3111.node_id}"
assert device.name_by_user == "Custom Device Name"
assert not device.manufacturer
assert not device.model
assert not device.sw_version
assert device.area_id == kitchen_area.id
state = hass.states.get(custom_entity)
assert state
assert state.name == "Custom Entity Name"
event = Event(
type="ready",
data={
"source": "node",
"event": "ready",
"nodeId": zp3111_state["nodeId"],
"nodeState": deepcopy(zp3111_state),
},
)
client.driver.receive_event(event)
await hass.async_block_till_done()
device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)})
assert device
assert device == dev_reg.async_get_device(identifiers={(DOMAIN, device_id_ext)})
assert device.id == custom_device.id
assert device.identifiers == custom_device.identifiers
assert device.name == "4-in-1 Sensor"
assert device.name_by_user == "Custom Device Name"
assert device.manufacturer == "Vision Security"
assert device.model == "ZP3111-5"
assert device.area_id == kitchen_area.id
assert device.sw_version == "5.1"
state = hass.states.get(custom_entity)
assert state
assert state.state != STATE_UNAVAILABLE
assert state.name == "Custom Entity Name"
async def test_null_name(hass, client, null_name_check, integration): async def test_null_name(hass, client, null_name_check, integration):
@ -235,38 +380,6 @@ async def test_null_name(hass, client, null_name_check, integration):
assert hass.states.get(f"switch.node_{node.node_id}") assert hass.states.get(f"switch.node_{node.node_id}")
async def test_existing_node_not_ready(hass, client, multisensor_6):
"""Test we handle a non ready node that exists during integration setup."""
dev_reg = dr.async_get(hass)
node = multisensor_6
node.data = deepcopy(node.data) # Copy to allow modification in tests.
node.data["ready"] = False
event = {"node": node}
air_temperature_device_id = f"{client.driver.controller.home_id}-{node.node_id}"
entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"})
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get(AIR_TEMPERATURE_SENSOR)
assert not state # entity not yet added
assert dev_reg.async_get_device( # device should be added
identifiers={(DOMAIN, air_temperature_device_id)}
)
node.data["ready"] = True
node.emit("ready", event)
await hass.async_block_till_done()
state = hass.states.get(AIR_TEMPERATURE_SENSOR)
assert state # entity and device added
assert state.state != STATE_UNAVAILABLE
assert dev_reg.async_get_device(identifiers={(DOMAIN, air_temperature_device_id)})
async def test_start_addon( async def test_start_addon(
hass, addon_installed, install_addon, addon_options, set_addon_options, start_addon hass, addon_installed, install_addon, addon_options, set_addon_options, start_addon
): ):
@ -738,63 +851,460 @@ async def test_node_removed(hass, multisensor_6_state, client, integration):
assert not dev_reg.async_get(old_device.id) assert not dev_reg.async_get(old_device.id)
async def test_replace_same_node(hass, multisensor_6_state, client, integration): async def test_replace_same_node(
hass, multisensor_6, multisensor_6_state, client, integration
):
"""Test when a node is replaced with itself that the device remains.""" """Test when a node is replaced with itself that the device remains."""
dev_reg = dr.async_get(hass) dev_reg = dr.async_get(hass)
node = Node(client, deepcopy(multisensor_6_state)) node_id = multisensor_6.node_id
device_id = f"{client.driver.controller.home_id}-{node.node_id}" multisensor_6_state = deepcopy(multisensor_6_state)
event = {"node": node}
client.driver.controller.emit("node added", event) device_id = f"{client.driver.controller.home_id}-{node_id}"
multisensor_6_device_id = (
f"{device_id}-{multisensor_6.manufacturer_id}:"
f"{multisensor_6.product_type}:{multisensor_6.product_id}"
)
device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)})
assert device
assert device == dev_reg.async_get_device(
identifiers={(DOMAIN, multisensor_6_device_id)}
)
assert device.manufacturer == "AEON Labs"
assert device.model == "ZW100"
dev_id = device.id
assert hass.states.get(AIR_TEMPERATURE_SENSOR)
# A replace node event has the extra field "replaced" set to True
# to distinguish it from an exclusion
event = Event(
type="node removed",
data={
"source": "controller",
"event": "node removed",
"replaced": True,
"node": multisensor_6_state,
},
)
client.driver.receive_event(event)
await hass.async_block_till_done() await hass.async_block_till_done()
old_device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)})
assert old_device.id
event = {"node": node, "replaced": True} # Device should still be there after the node was removed
device = dev_reg.async_get(dev_id)
assert device
client.driver.controller.emit("node removed", event) # When the node is replaced, a non-ready node added event is emitted
event = Event(
type="node added",
data={
"source": "controller",
"event": "node added",
"node": {
"nodeId": node_id,
"index": 0,
"status": 4,
"ready": False,
"isSecure": "unknown",
"interviewAttempts": 1,
"endpoints": [{"nodeId": node_id, "index": 0, "deviceClass": None}],
"values": [],
"deviceClass": None,
"commandClasses": [],
"interviewStage": "None",
"statistics": {
"commandsTX": 0,
"commandsRX": 0,
"commandsDroppedRX": 0,
"commandsDroppedTX": 0,
"timeoutResponse": 0,
},
},
},
)
# Device is still not removed
client.driver.receive_event(event)
await hass.async_block_till_done() await hass.async_block_till_done()
# Assert device has remained
assert dev_reg.async_get(old_device.id)
event = {"node": node} device = dev_reg.async_get(dev_id)
assert device
client.driver.controller.emit("node added", event) event = Event(
type="ready",
data={
"source": "node",
"event": "ready",
"nodeId": node_id,
"nodeState": multisensor_6_state,
},
)
client.driver.receive_event(event)
await hass.async_block_till_done() await hass.async_block_till_done()
# Assert device has remained
assert dev_reg.async_get(old_device.id) # Device is the same
device = dev_reg.async_get(dev_id)
assert device
assert device == dev_reg.async_get_device(identifiers={(DOMAIN, device_id)})
assert device == dev_reg.async_get_device(
identifiers={(DOMAIN, multisensor_6_device_id)}
)
assert device.manufacturer == "AEON Labs"
assert device.model == "ZW100"
assert hass.states.get(AIR_TEMPERATURE_SENSOR)
async def test_replace_different_node( async def test_replace_different_node(
hass, multisensor_6_state, hank_binary_switch_state, client, integration hass,
multisensor_6,
multisensor_6_state,
hank_binary_switch_state,
client,
integration,
): ):
"""Test when a node is replaced with a different node.""" """Test when a node is replaced with a different node."""
hank_binary_switch_state = deepcopy(hank_binary_switch_state)
multisensor_6_state = deepcopy(multisensor_6_state)
hank_binary_switch_state["nodeId"] = multisensor_6_state["nodeId"]
dev_reg = dr.async_get(hass) dev_reg = dr.async_get(hass)
old_node = Node(client, multisensor_6_state) node_id = multisensor_6.node_id
device_id = f"{client.driver.controller.home_id}-{old_node.node_id}" hank_binary_switch_state = deepcopy(hank_binary_switch_state)
new_node = Node(client, hank_binary_switch_state) hank_binary_switch_state["nodeId"] = node_id
event = {"node": old_node}
device_id = f"{client.driver.controller.home_id}-{node_id}"
multisensor_6_device_id = (
f"{device_id}-{multisensor_6.manufacturer_id}:"
f"{multisensor_6.product_type}:{multisensor_6.product_id}"
)
hank_device_id = (
f"{device_id}-{hank_binary_switch_state['manufacturerId']}:"
f"{hank_binary_switch_state['productType']}:"
f"{hank_binary_switch_state['productId']}"
)
client.driver.controller.emit("node added", event)
await hass.async_block_till_done()
device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)})
assert device assert device
assert device == dev_reg.async_get_device(
identifiers={(DOMAIN, multisensor_6_device_id)}
)
assert device.manufacturer == "AEON Labs"
assert device.model == "ZW100"
dev_id = device.id
event = {"node": old_node, "replaced": True} assert hass.states.get(AIR_TEMPERATURE_SENSOR)
client.driver.controller.emit("node removed", event) # A replace node event has the extra field "replaced" set to True
# to distinguish it from an exclusion
event = Event(
type="node removed",
data={
"source": "controller",
"event": "node removed",
"replaced": True,
"node": multisensor_6_state,
},
)
client.driver.receive_event(event)
await hass.async_block_till_done() await hass.async_block_till_done()
# Device should still be there after the node was removed # Device should still be there after the node was removed
device = dev_reg.async_get(dev_id)
assert device assert device
event = {"node": new_node} # When the node is replaced, a non-ready node added event is emitted
event = Event(
type="node added",
data={
"source": "controller",
"event": "node added",
"node": {
"nodeId": multisensor_6.node_id,
"index": 0,
"status": 4,
"ready": False,
"isSecure": "unknown",
"interviewAttempts": 1,
"endpoints": [
{"nodeId": multisensor_6.node_id, "index": 0, "deviceClass": None}
],
"values": [],
"deviceClass": None,
"commandClasses": [],
"interviewStage": "None",
"statistics": {
"commandsTX": 0,
"commandsRX": 0,
"commandsDroppedRX": 0,
"commandsDroppedTX": 0,
"timeoutResponse": 0,
},
},
},
)
client.driver.controller.emit("node added", event) # Device is still not removed
client.driver.receive_event(event)
await hass.async_block_till_done() await hass.async_block_till_done()
device = dev_reg.async_get(device.id)
# assert device is new device = dev_reg.async_get(dev_id)
assert device assert device
event = Event(
type="ready",
data={
"source": "node",
"event": "ready",
"nodeId": node_id,
"nodeState": hank_binary_switch_state,
},
)
client.driver.receive_event(event)
await hass.async_block_till_done()
# Old device and entities were removed, but the ID is re-used
device = dev_reg.async_get(dev_id)
assert device
assert device == dev_reg.async_get_device(identifiers={(DOMAIN, device_id)})
assert device == dev_reg.async_get_device(identifiers={(DOMAIN, hank_device_id)})
assert not dev_reg.async_get_device(identifiers={(DOMAIN, multisensor_6_device_id)})
assert device.manufacturer == "HANK Electronics Ltd." assert device.manufacturer == "HANK Electronics Ltd."
assert device.model == "HKZW-SO01"
assert not hass.states.get(AIR_TEMPERATURE_SENSOR)
assert hass.states.get("switch.smart_plug_with_two_usb_ports")
async def test_node_model_change(hass, zp3111, client, integration):
"""Test when a node's model is changed due to an updated device config file.
The device and entities should not be removed.
"""
dev_reg = dr.async_get(hass)
er_reg = er.async_get(hass)
device_id = f"{client.driver.controller.home_id}-{zp3111.node_id}"
device_id_ext = (
f"{device_id}-{zp3111.manufacturer_id}:"
f"{zp3111.product_type}:{zp3111.product_id}"
)
# Verify device and entities have default names/ids
device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)})
assert device
assert device == dev_reg.async_get_device(identifiers={(DOMAIN, device_id_ext)})
assert device.manufacturer == "Vision Security"
assert device.model == "ZP3111-5"
assert device.name == "4-in-1 Sensor"
assert not device.name_by_user
dev_id = device.id
motion_entity = "binary_sensor.4_in_1_sensor_home_security_motion_detection"
state = hass.states.get(motion_entity)
assert state
assert state.name == "4-in-1 Sensor: Home Security - Motion detection"
# Customize device and entity names/ids
dev_reg.async_update_device(device.id, name_by_user="Custom Device Name")
device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)})
assert device
assert device.id == dev_id
assert device == dev_reg.async_get_device(identifiers={(DOMAIN, device_id_ext)})
assert device.manufacturer == "Vision Security"
assert device.model == "ZP3111-5"
assert device.name == "4-in-1 Sensor"
assert device.name_by_user == "Custom Device Name"
custom_entity = "binary_sensor.custom_motion_sensor"
er_reg.async_update_entity(
motion_entity, new_entity_id=custom_entity, name="Custom Entity Name"
)
await hass.async_block_till_done()
assert not hass.states.get(motion_entity)
state = hass.states.get(custom_entity)
assert state
assert state.name == "Custom Entity Name"
# Unload the integration
assert await hass.config_entries.async_unload(integration.entry_id)
await hass.async_block_till_done()
assert integration.state is ConfigEntryState.NOT_LOADED
assert not hass.data.get(DOMAIN)
# Simulate changes to the node labels
zp3111.device_config.data["description"] = "New Device Name"
zp3111.device_config.data["label"] = "New Device Model"
zp3111.device_config.data["manufacturer"] = "New Device Manufacturer"
# Reload integration, it will re-add the nodes
integration.add_to_hass(hass)
await hass.config_entries.async_setup(integration.entry_id)
await hass.async_block_till_done()
# Device name changes, but the customization is the same
device = dev_reg.async_get(dev_id)
assert device
assert device.id == dev_id
assert device.manufacturer == "New Device Manufacturer"
assert device.model == "New Device Model"
assert device.name == "New Device Name"
assert device.name_by_user == "Custom Device Name"
assert not hass.states.get(motion_entity)
state = hass.states.get(custom_entity)
assert state
assert state.name == "Custom Entity Name"
async def test_disabled_node_status_entity_on_node_replaced(
hass, zp3111_state, zp3111, client, integration
):
"""Test that when a node replacement event is received the node status sensor is removed."""
node_status_entity = "sensor.4_in_1_sensor_node_status"
state = hass.states.get(node_status_entity)
assert state
assert state.state != STATE_UNAVAILABLE
event = Event(
type="node removed",
data={
"source": "controller",
"event": "node removed",
"replaced": True,
"node": zp3111_state,
},
)
client.driver.receive_event(event)
await hass.async_block_till_done()
state = hass.states.get(node_status_entity)
assert state
assert state.state == STATE_UNAVAILABLE
async def test_disabled_entity_on_value_removed(hass, zp3111, client, integration):
"""Test that when entity primary values are removed the entity is removed."""
er_reg = er.async_get(hass)
# re-enable this default-disabled entity
sensor_cover_entity = "sensor.4_in_1_sensor_home_security_cover_status"
er_reg.async_update_entity(entity_id=sensor_cover_entity, disabled_by=None)
await hass.async_block_till_done()
# must reload the integration when enabling an entity
await hass.config_entries.async_unload(integration.entry_id)
await hass.async_block_till_done()
assert integration.state is ConfigEntryState.NOT_LOADED
integration.add_to_hass(hass)
await hass.config_entries.async_setup(integration.entry_id)
await hass.async_block_till_done()
assert integration.state is ConfigEntryState.LOADED
state = hass.states.get(sensor_cover_entity)
assert state
assert state.state != STATE_UNAVAILABLE
# check for expected entities
binary_cover_entity = (
"binary_sensor.4_in_1_sensor_home_security_tampering_product_cover_removed"
)
state = hass.states.get(binary_cover_entity)
assert state
assert state.state != STATE_UNAVAILABLE
battery_level_entity = "sensor.4_in_1_sensor_battery_level"
state = hass.states.get(battery_level_entity)
assert state
assert state.state != STATE_UNAVAILABLE
unavailable_entities = {
state.entity_id
for state in hass.states.async_all()
if state.state == STATE_UNAVAILABLE
}
# This value ID removal does not remove any entity
event = Event(
type="value removed",
data={
"source": "node",
"event": "value removed",
"nodeId": zp3111.node_id,
"args": {
"commandClassName": "Wake Up",
"commandClass": 132,
"endpoint": 0,
"property": "wakeUpInterval",
"prevValue": 3600,
"propertyName": "wakeUpInterval",
},
},
)
client.driver.receive_event(event)
await hass.async_block_till_done()
assert all(state != STATE_UNAVAILABLE for state in hass.states.async_all())
# This value ID removal only affects the battery level entity
event = Event(
type="value removed",
data={
"source": "node",
"event": "value removed",
"nodeId": zp3111.node_id,
"args": {
"commandClassName": "Battery",
"commandClass": 128,
"endpoint": 0,
"property": "level",
"prevValue": 100,
"propertyName": "level",
},
},
)
client.driver.receive_event(event)
await hass.async_block_till_done()
state = hass.states.get(battery_level_entity)
assert state
assert state.state == STATE_UNAVAILABLE
# This value ID removal affects its multiple notification sensors
event = Event(
type="value removed",
data={
"source": "node",
"event": "value removed",
"nodeId": zp3111.node_id,
"args": {
"commandClassName": "Notification",
"commandClass": 113,
"endpoint": 0,
"property": "Home Security",
"propertyKey": "Cover status",
"prevValue": 0,
"propertyName": "Home Security",
"propertyKeyName": "Cover status",
},
},
)
client.driver.receive_event(event)
await hass.async_block_till_done()
state = hass.states.get(binary_cover_entity)
assert state
assert state.state == STATE_UNAVAILABLE
state = hass.states.get(sensor_cover_entity)
assert state
assert state.state == STATE_UNAVAILABLE
# existing entities and the entities with removed values should be unavailable
new_unavailable_entities = {
state.entity_id
for state in hass.states.async_all()
if state.state == STATE_UNAVAILABLE
}
assert (
unavailable_entities
| {battery_level_entity, binary_cover_entity, sensor_cover_entity}
== new_unavailable_entities
)

View File

@ -640,6 +640,7 @@ async def test_loading_saving_data(hass, registry, area_registry):
identifiers={("hue", "abc")}, identifiers={("hue", "abc")},
manufacturer="manufacturer", manufacturer="manufacturer",
model="light", model="light",
entry_type=device_registry.DeviceEntryType.SERVICE,
) )
assert orig_light4.id == orig_light3.id assert orig_light4.id == orig_light3.id
@ -679,6 +680,15 @@ async def test_loading_saving_data(hass, registry, area_registry):
assert orig_light == new_light assert orig_light == new_light
assert orig_light4 == new_light4 assert orig_light4 == new_light4
# Ensure enums converted
for (old, new) in (
(orig_via, new_via),
(orig_light, new_light),
(orig_light4, new_light4),
):
assert old.disabled_by is new.disabled_by
assert old.entry_type is new.entry_type
# Ensure a save/load cycle does not keep suggested area # Ensure a save/load cycle does not keep suggested area
new_kitchen_light = registry2.async_get_device({("hue", "999")}) new_kitchen_light = registry2.async_get_device({("hue", "999")})
assert orig_kitchen_light.suggested_area == "Kitchen" assert orig_kitchen_light.suggested_area == "Kitchen"