From 9b7eb6b5a191a9f88dc96463f177496d97f59f62 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 3 Oct 2022 14:24:11 -0400 Subject: [PATCH] Reduce coverage gaps for zwave_js (#79520) Co-authored-by: Martin Hjelmare --- homeassistant/components/zwave_js/__init__.py | 30 +++++------- .../components/zwave_js/diagnostics.py | 35 ++------------ homeassistant/components/zwave_js/helpers.py | 41 +++++++++++++++- tests/components/zwave_js/common.py | 25 ++++++++++ tests/components/zwave_js/test_addon.py | 15 ++++++ .../components/zwave_js/test_binary_sensor.py | 48 ++++++++++++++++++- tests/components/zwave_js/test_climate.py | 29 +++++++++++ 7 files changed, 170 insertions(+), 53 deletions(-) create mode 100644 tests/components/zwave_js/test_addon.py diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index f8828e8cdd0..9082048badf 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -142,7 +142,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await client.connect() except InvalidServerVersion as err: if use_addon: - async_ensure_addon_updated(hass) + addon_manager = _get_addon_manager(hass) + addon_manager.async_schedule_update_addon(catch_error=True) else: async_create_issue( hass, @@ -205,8 +206,7 @@ async def start_client( LOGGER.info("Connection to Zwave JS Server initialized") - if client.driver is None: - raise RuntimeError("Driver not ready.") + assert client.driver await driver_events.setup(client.driver) @@ -789,17 +789,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: info = hass.data[DOMAIN][entry.entry_id] driver_events: DriverEvents = info[DATA_DRIVER_EVENTS] - tasks: list[asyncio.Task | Coroutine] = [] - for platform, task in driver_events.platform_setup_tasks.items(): - if task.done(): - tasks.append( - hass.config_entries.async_forward_entry_unload(entry, platform) - ) - else: - task.cancel() - tasks.append(task) + tasks: list[Coroutine] = [ + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform, task in driver_events.platform_setup_tasks.items() + if not task.cancel() + ] - unload_ok = all(await asyncio.gather(*tasks)) + unload_ok = all(await asyncio.gather(*tasks)) if tasks else True if DATA_CLIENT_LISTEN_TASK in info: await disconnect_client(hass, entry) @@ -842,9 +838,7 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_ensure_addon_running(hass: HomeAssistant, entry: ConfigEntry) -> None: """Ensure that Z-Wave JS add-on is installed and running.""" - addon_manager: AddonManager = get_addon_manager(hass) - if addon_manager.task_in_progress(): - raise ConfigEntryNotReady + addon_manager = _get_addon_manager(hass) try: addon_info = await addon_manager.async_get_addon_info() except AddonError as err: @@ -911,9 +905,9 @@ async def async_ensure_addon_running(hass: HomeAssistant, entry: ConfigEntry) -> @callback -def async_ensure_addon_updated(hass: HomeAssistant) -> None: +def _get_addon_manager(hass: HomeAssistant) -> AddonManager: """Ensure that Z-Wave JS add-on is updated and running.""" addon_manager: AddonManager = get_addon_manager(hass) if addon_manager.task_in_progress(): raise ConfigEntryNotReady - addon_manager.async_schedule_update_addon(catch_error=True) + return addon_manager diff --git a/homeassistant/components/zwave_js/diagnostics.py b/homeassistant/components/zwave_js/diagnostics.py index ef34a2f12de..068be7feb0b 100644 --- a/homeassistant/components/zwave_js/diagnostics.py +++ b/homeassistant/components/zwave_js/diagnostics.py @@ -2,7 +2,6 @@ from __future__ import annotations from copy import deepcopy -from dataclasses import astuple, dataclass from typing import Any from zwave_js_server.client import Client @@ -21,27 +20,13 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DATA_CLIENT, DOMAIN, USER_AGENT from .helpers import ( + ZwaveValueMatcher, get_home_and_node_id_from_device_entry, get_state_key_from_unique_id, get_value_id_from_unique_id, + value_matches_matcher, ) - -@dataclass -class ZwaveValueMatcher: - """Class to allow matching a Z-Wave Value.""" - - property_: str | int | None = None - command_class: int | None = None - endpoint: int | None = None - property_key: str | int | None = None - - def __post_init__(self) -> None: - """Post initialization check.""" - if all(val is None for val in astuple(self)): - raise ValueError("At least one of the fields must be set.") - - KEYS_TO_REDACT = {"homeId", "location"} VALUES_TO_REDACT = ( @@ -55,21 +40,7 @@ def redact_value_of_zwave_value(zwave_value: ValueDataType) -> ValueDataType: if zwave_value.get("value") in (None, ""): return zwave_value for value_to_redact in VALUES_TO_REDACT: - command_class = None - if "commandClass" in zwave_value: - command_class = CommandClass(zwave_value["commandClass"]) - zwave_value_id = ZwaveValueMatcher( - property_=zwave_value.get("property"), - command_class=command_class, - endpoint=zwave_value.get("endpoint"), - property_key=zwave_value.get("propertyKey"), - ) - if all( - redacted_field_val is None or redacted_field_val == zwave_value_field_val - for redacted_field_val, zwave_value_field_val in zip( - astuple(value_to_redact), astuple(zwave_value_id) - ) - ): + if value_matches_matcher(value_to_redact, zwave_value): redacted_value: ValueDataType = deepcopy(zwave_value) redacted_value["value"] = REDACTED return redacted_value diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 6949f3654a5..792bd4fc1b1 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -2,18 +2,19 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import astuple, dataclass import logging from typing import Any, cast import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import ConfigurationValueType +from zwave_js_server.const import CommandClass, ConfigurationValueType from zwave_js_server.model.driver import Driver from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import ( ConfigurationValue, Value as ZwaveValue, + ValueDataType, get_value_id_str, ) @@ -55,6 +56,42 @@ class ZwaveValueID: property_key: str | int | None = None +@dataclass +class ZwaveValueMatcher: + """Class to allow matching a Z-Wave Value.""" + + property_: str | int | None = None + command_class: int | None = None + endpoint: int | None = None + property_key: str | int | None = None + + def __post_init__(self) -> None: + """Post initialization check.""" + if all(val is None for val in astuple(self)): + raise ValueError("At least one of the fields must be set.") + + +def value_matches_matcher( + matcher: ZwaveValueMatcher, value_data: ValueDataType +) -> bool: + """Return whether value matches matcher.""" + command_class = None + if "commandClass" in value_data: + command_class = CommandClass(value_data["commandClass"]) + zwave_value_id = ZwaveValueMatcher( + property_=value_data.get("property"), + command_class=command_class, + endpoint=value_data.get("endpoint"), + property_key=value_data.get("propertyKey"), + ) + return all( + redacted_field_val is None or redacted_field_val == zwave_value_field_val + for redacted_field_val, zwave_value_field_val in zip( + astuple(matcher), astuple(zwave_value_id) + ) + ) + + @callback def get_value_id_from_unique_id(unique_id: str) -> str | None: """ diff --git a/tests/components/zwave_js/common.py b/tests/components/zwave_js/common.py index c2079564dcf..49fbe96f162 100644 --- a/tests/components/zwave_js/common.py +++ b/tests/components/zwave_js/common.py @@ -1,4 +1,16 @@ """Provide common test tools for Z-Wave JS.""" +from __future__ import annotations + +from copy import deepcopy +from typing import Any + +from zwave_js_server.model.node.data_model import NodeDataType + +from homeassistant.components.zwave_js.helpers import ( + ZwaveValueMatcher, + value_matches_matcher, +) + AIR_TEMPERATURE_SENSOR = "sensor.multisensor_6_air_temperature" BATTERY_SENSOR = "sensor.multisensor_6_battery_level" TAMPER_SENSOR = "binary_sensor.multisensor_6_tampering_product_cover_removed" @@ -37,3 +49,16 @@ HUMIDIFIER_ADC_T3000_ENTITY = "humidifier.adc_t3000_humidifier" DEHUMIDIFIER_ADC_T3000_ENTITY = "humidifier.adc_t3000_dehumidifier" PROPERTY_ULTRAVIOLET = "Ultraviolet" + + +def replace_value_of_zwave_value( + node_data: NodeDataType, matchers: list[ZwaveValueMatcher], new_value: Any +) -> NodeDataType: + """Replace the value of a zwave value that matches the input matchers.""" + new_node_data = deepcopy(node_data) + for value_data in new_node_data["values"]: + for matcher in matchers: + if value_matches_matcher(matcher, value_data): + value_data["value"] = new_value + + return new_node_data diff --git a/tests/components/zwave_js/test_addon.py b/tests/components/zwave_js/test_addon.py new file mode 100644 index 00000000000..754be808cea --- /dev/null +++ b/tests/components/zwave_js/test_addon.py @@ -0,0 +1,15 @@ +"""Tests for Z-Wave JS addon module.""" +import pytest + +from homeassistant.components.zwave_js.addon import AddonError, get_addon_manager + + +async def test_not_installed_raises_exception(hass, addon_not_installed): + """Test addon not installed raises exception.""" + addon_manager = get_addon_manager(hass) + + with pytest.raises(AddonError): + await addon_manager.async_configure_addon("/test", "123", "456", "789", "012") + + with pytest.raises(AddonError): + await addon_manager.async_update_addon() diff --git a/tests/components/zwave_js/test_binary_sensor.py b/tests/components/zwave_js/test_binary_sensor.py index 2a1c13b0db2..3d4971e1ce4 100644 --- a/tests/components/zwave_js/test_binary_sensor.py +++ b/tests/components/zwave_js/test_binary_sensor.py @@ -3,7 +3,7 @@ from zwave_js_server.event import Event from zwave_js_server.model.node import Node from homeassistant.components.binary_sensor import BinarySensorDeviceClass -from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON +from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity import EntityCategory @@ -69,6 +69,29 @@ async def test_enabled_legacy_sensor(hass, ecolink_door_sensor, integration): state = hass.states.get(ENABLED_LEGACY_BINARY_SENSOR) assert state.state == STATE_ON + # Test state updates from value updated event + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 53, + "args": { + "commandClassName": "Binary Sensor", + "commandClass": 48, + "endpoint": 0, + "property": "Any", + "newValue": None, + "prevValue": True, + "propertyName": "Any", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(ENABLED_LEGACY_BINARY_SENSOR) + assert state.state == STATE_UNKNOWN + async def test_disabled_legacy_sensor(hass, multisensor_6, integration): """Test disabled legacy boolean binary sensor.""" @@ -198,3 +221,26 @@ async def test_property_sensor_door_status(hass, lock_august_pro, integration): state = hass.states.get(PROPERTY_DOOR_STATUS_BINARY_SENSOR) assert state assert state.state == STATE_OFF + + # door state unknown + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 6, + "args": { + "commandClassName": "Door Lock", + "commandClass": 98, + "endpoint": 0, + "property": "doorStatus", + "newValue": None, + "prevValue": "open", + "propertyName": "doorStatus", + }, + }, + ) + node.receive_event(event) + state = hass.states.get(PROPERTY_DOOR_STATUS_BINARY_SENSOR) + assert state + assert state.state == STATE_UNKNOWN diff --git a/tests/components/zwave_js/test_climate.py b/tests/components/zwave_js/test_climate.py index 4b4519c07b9..755423e5e43 100644 --- a/tests/components/zwave_js/test_climate.py +++ b/tests/components/zwave_js/test_climate.py @@ -1,6 +1,11 @@ """Test the Z-Wave JS climate platform.""" import pytest +from zwave_js_server.const import CommandClass +from zwave_js_server.const.command_class.thermostat import ( + THERMOSTAT_OPERATING_STATE_PROPERTY, +) from zwave_js_server.event import Event +from zwave_js_server.model.node import Node from homeassistant.components.climate import ( ATTR_CURRENT_HUMIDITY, @@ -25,6 +30,7 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.components.zwave_js.climate import ATTR_FAN_STATE +from homeassistant.components.zwave_js.helpers import ZwaveValueMatcher from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, @@ -37,6 +43,7 @@ from .common import ( CLIMATE_FLOOR_THERMOSTAT_ENTITY, CLIMATE_MAIN_HEAT_ACTIONNER, CLIMATE_RADIO_THERMOSTAT_ENTITY, + replace_value_of_zwave_value, ) @@ -632,3 +639,25 @@ async def test_temp_unit_fix( state = hass.states.get("climate.z_wave_thermostat") assert state assert state.attributes["current_temperature"] == 21.1 + + +async def test_thermostat_unknown_values( + hass, client, climate_radio_thermostat_ct100_plus_state, integration +): + """Test a thermostat v2 with unknown values.""" + node_state = replace_value_of_zwave_value( + climate_radio_thermostat_ct100_plus_state, + [ + ZwaveValueMatcher( + THERMOSTAT_OPERATING_STATE_PROPERTY, + command_class=CommandClass.THERMOSTAT_OPERATING_STATE, + ) + ], + None, + ) + node = Node(client, node_state) + client.driver.controller.emit("node added", {"node": node}) + await hass.async_block_till_done() + state = hass.states.get(CLIMATE_RADIO_THERMOSTAT_ENTITY) + + assert ATTR_HVAC_ACTION not in state.attributes