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)
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
def async_pubnub_message(self, device_id, date_time, message):
"""Process a pubnub message."""
@ -245,13 +256,24 @@ class AugustData(AugustSubscriberMixin):
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."""
return await self._async_call_api_op_requires_bridge(
device_id,
self._api.async_lock_async,
self._august_gateway.access_token,
device_id,
hyper_bridge,
)
async def async_unlock(self, device_id):
@ -263,13 +285,14 @@ class AugustData(AugustSubscriberMixin):
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."""
return await self._async_call_api_op_requires_bridge(
device_id,
self._api.async_unlock_async,
self._august_gateway.access_token,
device_id,
hyper_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._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):
"""Lock the device."""
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
await self._call_lock_operation(self._data.async_lock)
async def async_unlock(self, **kwargs):
"""Unlock the device."""
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
await self._call_lock_operation(self._data.async_unlock)

View File

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

View File

@ -2,7 +2,7 @@
"domain": "bmw_connected_drive",
"name": "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"],
"config_flow": true,
"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
# that advertise multiple unrelated (sent in separate discovery packets)
# UPnP devices.
manufacturer = discovery_info.upnp.get(ssdp.ATTR_UPNP_MANUFACTURER, "").lower()
model = discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME, "").lower()
manufacturer = (discovery_info.upnp.get(ssdp.ATTR_UPNP_MANUFACTURER) or "").lower()
model = (discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME) or "").lower()
if manufacturer.startswith("xbmc") or model == "kodi":
# kodi

View File

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

View File

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

View File

@ -34,3 +34,26 @@ def _flux_color_mode_to_hass(
def _effect_brightness(brightness: int) -> int:
"""Convert hass brightness to effect brightness."""
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
import asyncio
from copy import deepcopy
import ipaddress
import logging
import os
@ -352,8 +353,8 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry):
@callback
def _async_import_options_from_data_if_missing(hass: HomeAssistant, entry: ConfigEntry):
options = dict(entry.options)
data = dict(entry.data)
options = deepcopy(dict(entry.options))
data = deepcopy(dict(entry.data))
modified = False
for importable_option in CONFIG_OPTIONS:
if importable_option not in entry.options and importable_option in entry.data:

View File

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

View File

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

View File

@ -174,6 +174,7 @@ class Thermostat(HomeAccessory):
self.char_target_heat_cool.override_properties(
valid_values=self.hc_hass_to_homekit
)
self.char_target_heat_cool.allow_invalid_client_values = True
# Current and target temperature characteristics
self.char_current_temp = serv_thermostat.configure_char(
@ -252,7 +253,6 @@ class Thermostat(HomeAccessory):
hvac_mode = state.state
homekit_hvac_mode = HC_HASS_TO_HOMEKIT[hvac_mode]
# Homekit will reset the mode when VIEWING the temp
# Ignore it if its the same mode
if (
@ -282,7 +282,7 @@ class Thermostat(HomeAccessory):
target_hc,
hc_fallback,
)
target_hc = hc_fallback
self.char_target_heat_cool.value = target_hc = hc_fallback
break
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))
conf = dict(conf)
hass.data[DATA_KNX_CONFIG] = conf
# 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:
"""Load a config entry."""
conf = hass.data.get(DATA_KNX_CONFIG)
# When reloading
# `conf` is None when reloading the integration or no `knx` key in configuration.yaml
if conf is None:
conf = await async_integration_yaml_config(hass, DOMAIN)
if not conf or DOMAIN not in conf:
return False
conf = conf[DOMAIN]
# If user didn't have configuration.yaml config, generate defaults
if conf is None:
conf = CONFIG_SCHEMA({DOMAIN: dict(entry.data)})[DOMAIN]
_conf = await async_integration_yaml_config(hass, DOMAIN)
if not _conf or DOMAIN not in _conf:
_LOGGER.warning(
"No `knx:` key found in configuration.yaml. See "
"https://www.home-assistant.io/integrations/knx/ "
"for KNX entity configuration documentation"
)
# generate defaults
conf = CONFIG_SCHEMA({DOMAIN: {}})[DOMAIN]
else:
conf = _conf[DOMAIN]
config = {**conf, **entry.data}
try:
@ -363,7 +362,6 @@ class KNXModule:
self.entry.async_on_unload(
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop)
)
self.entry.async_on_unload(self.entry.add_update_listener(async_update_entry))
def init_xknx(self) -> None:
@ -403,7 +401,6 @@ class KNXModule:
route_back=self.config.get(ConnectionSchema.CONF_KNX_ROUTE_BACK, False),
auto_reconnect=True,
)
return ConnectionConfig(auto_reconnect=True)
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
_tunnels: list
_tunnels: list[GatewayDescriptor]
_gateway_ip: str = ""
_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:
"""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:
connection_type = user_input[CONF_KNX_CONNECTION_TYPE]
if connection_type == CONF_KNX_AUTOMATIC:
@ -99,6 +80,22 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
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(
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
) -> FlowResult:
"""General setup."""
errors: dict = {}
if user_input is not None:
return self.async_create_entry(
title=f"{CONF_KNX_TUNNELING.capitalize()} @ {user_input[CONF_HOST]}",
@ -129,6 +124,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
},
)
errors: dict = {}
fields = {
vol.Required(CONF_HOST, default=self._gateway_ip): str,
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:
"""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:
gateway: GatewayDescriptor = next(
gateway
@ -163,6 +157,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
return await self.async_step_manual_tunnel()
errors: dict = {}
tunnel_repr = {
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:
"""Routing setup."""
errors: dict = {}
if user_input is not None:
return self.async_create_entry(
title=CONF_KNX_ROUTING.capitalize(),
@ -205,6 +198,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
},
)
errors: dict = {}
fields = {
vol.Required(
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."""
xknx = XKNX()
gatewayscanner = GatewayScanner(

View File

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

View File

@ -281,10 +281,10 @@ class BlockSleepingClimate(
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set preset mode."""
if not self._attr_preset_modes:
if not self._preset_modes:
return
preset_index = self._attr_preset_modes.index(preset_mode)
preset_index = self._preset_modes.index(preset_mode)
if preset_index == 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)}
selected_ssids_to_filter = [
ssid for ssid in self.controller.option_ssid_filter if ssid in ssid_filter
]
return self.async_show_form(
step_id="device_tracker",
data_schema=vol.Schema(
@ -333,7 +337,7 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow):
default=self.controller.option_track_devices,
): bool,
vol.Optional(
CONF_SSID_FILTER, default=self.controller.option_ssid_filter
CONF_SSID_FILTER, default=selected_ssids_to_filter
): cv.multi_select(ssid_filter),
vol.Optional(
CONF_DETECTION_TIME,
@ -365,12 +369,18 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow):
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(
step_id="client_control",
data_schema=vol.Schema(
{
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),
vol.Optional(
CONF_POE_CLIENTS,

View File

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

View File

@ -21,6 +21,7 @@ from homeassistant.const import (
CONF_DOMAIN,
CONF_ENTITY_ID,
CONF_TYPE,
STATE_UNAVAILABLE,
)
from homeassistant.core import Context, HomeAssistant
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)
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}
actions.append({**entity_action, CONF_TYPE: SERVICE_REFRESH_VALUE})
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]
# 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
if re.match(VALUE_ID_REGEX, value_id):
value = node.values[value_id]
else:
if not re.match(VALUE_ID_REGEX, value_id):
continue
value = node.values[value_id]
# If the value has the meterType CC specific value, we can add a reset_meter
# action for it
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__)
EVENT_VALUE_UPDATED = "value updated"
EVENT_VALUE_REMOVED = "value removed"
EVENT_DEAD = "dead"
EVENT_ALIVE = "alive"
@ -99,6 +100,10 @@ class ZWaveBaseEntity(Entity):
self.async_on_remove(
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):
self.async_on_remove(
self.info.node.on(status_event, self._node_status_alive_or_dead)
@ -171,7 +176,7 @@ class ZWaveBaseEntity(Entity):
@callback
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.
"""
@ -193,6 +198,25 @@ class ZWaveBaseEntity(Entity):
self.on_value_update()
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
def get_zwave_value(
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}")
@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
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_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{self.unique_id}_remove_entity",
self.async_remove,
)
)
self.async_write_ha_state()

View File

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

View File

@ -584,7 +584,9 @@ class DeviceRegistry:
configuration_url=device["configuration_url"],
# 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]
disabled_by=device["disabled_by"],
disabled_by=DeviceEntryDisabler(device["disabled_by"])
if device["disabled_by"]
else None,
entry_type=DeviceEntryType(device["entry_type"])
if device["entry_type"]
else None,

View File

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

View File

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

View File

@ -162,10 +162,17 @@ async def _create_august_with_devices( # noqa: C901
"unlock_return_activities"
] = 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
)
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):
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_lock_async = AsyncMock()
api_instance.async_status_async = AsyncMock()
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):

View File

@ -39,6 +39,9 @@ from homeassistant.components.light import (
ATTR_RGBWW_COLOR,
ATTR_SUPPORTED_COLOR_MODES,
ATTR_WHITE,
COLOR_MODE_RGB,
COLOR_MODE_RGBW,
COLOR_MODE_RGBWW,
DOMAIN as LIGHT_DOMAIN,
)
from homeassistant.const import (
@ -247,9 +250,11 @@ async def test_rgb_light(hass: HomeAssistant) -> None:
blocking=True,
)
# If the bulb is on and we are using existing brightness
# and brightness was 0 it means we could not read it because
# an effect is in progress so we use 255
bulb.async_set_levels.assert_called_with(10, 10, 30, brightness=255)
# and brightness was 0 older devices will not be able to turn on
# so we need to make sure its at least 1 and that we
# 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.brightness = 128
@ -304,9 +309,9 @@ async def test_rgb_light_auto_on(hass: HomeAssistant) -> None:
assert state.state == STATE_ON
attributes = state.attributes
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_SUPPORTED_COLOR_MODES] == ["rgb"]
assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [COLOR_MODE_RGB]
assert attributes[ATTR_HS_COLOR] == (0, 100)
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_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
await hass.services.async_call(
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,
)
# If the bulb is on and we are using existing brightness
# and brightness was 0 it means we could not read it because
# an effect is in progress so we use 255
# 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_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.brightness = 128
@ -395,6 +414,236 @@ async def test_rgb_light_auto_on(hass: HomeAssistant) -> None:
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:
"""Test an rgb cct light."""
config_entry = MockConfigEntry(

View File

@ -2,9 +2,14 @@
from unittest.mock import patch
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.const import CONF_NAME, CONF_PORT
from homeassistant.helpers.entityfilter import CONF_INCLUDE_DOMAINS
from homeassistant.setup import async_setup_component
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["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(
result["flow_id"],
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()
hap = acc.char_target_heat_cool.to_HAP()
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
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_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):
"""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"))
@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")
def mock_client_fixture(controller_state, version_state, log_config_state):
"""Mock a client."""
@ -905,3 +923,19 @@ def fortrezz_ssa1_siren_fixture(client, fortrezz_ssa1_siren_state):
def firmware_file_fixture():
"""Return mock firmware file stream."""
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.helpers import get_device_id
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
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.config_entries import DISABLED_USER, ConfigEntryState
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
@ -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):
"""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)
node = Node(client, deepcopy(multisensor_6_state))
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)})
async def test_on_node_added_not_ready(hass, multisensor_6_state, client, integration):
"""Test we handle a non ready node added event."""
async def test_on_node_added_not_ready(
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)
node_data = deepcopy(multisensor_6_state) # Copy to allow modification in tests.
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}"
device_id = f"{client.driver.controller.home_id}-{zp3111_not_ready_state['nodeId']}"
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
assert not dev_reg.async_get_device(
identifiers={(DOMAIN, air_temperature_device_id)}
event = Event(
type="node added",
data={
"source": "controller",
"event": "node added",
"node": deepcopy(zp3111_not_ready_state),
},
)
client.driver.controller.emit("node added", event)
client.driver.receive_event(event)
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
assert dev_reg.async_get_device(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 added
assert state.state != STATE_UNAVAILABLE
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_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)
node = multisensor_6
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)
assert state # entity and device added
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):
@ -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}")
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(
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)
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."""
dev_reg = dr.async_get(hass)
node = Node(client, deepcopy(multisensor_6_state))
device_id = f"{client.driver.controller.home_id}-{node.node_id}"
event = {"node": node}
node_id = multisensor_6.node_id
multisensor_6_state = deepcopy(multisensor_6_state)
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()
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()
# 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()
# 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(
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."""
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)
old_node = Node(client, multisensor_6_state)
device_id = f"{client.driver.controller.home_id}-{old_node.node_id}"
new_node = Node(client, hank_binary_switch_state)
event = {"node": old_node}
node_id = multisensor_6.node_id
hank_binary_switch_state = deepcopy(hank_binary_switch_state)
hank_binary_switch_state["nodeId"] = node_id
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)})
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()
# Device should still be there after the node was removed
device = dev_reg.async_get(dev_id)
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()
device = dev_reg.async_get(device.id)
# assert device is new
device = dev_reg.async_get(dev_id)
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.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")},
manufacturer="manufacturer",
model="light",
entry_type=device_registry.DeviceEntryType.SERVICE,
)
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_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
new_kitchen_light = registry2.async_get_device({("hue", "999")})
assert orig_kitchen_light.suggested_area == "Kitchen"