diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index 3a88243b0b9..f82bdcd96b8 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -39,6 +39,7 @@ from .const import ( DATA_COORDINATOR, DOMAIN, INTEGRATION_TYPE_GEOGRAPHY_COORDS, + INTEGRATION_TYPE_GEOGRAPHY_NAME, INTEGRATION_TYPE_NODE_PRO, LOGGER, ) @@ -142,12 +143,21 @@ def _standardize_geography_config_entry(hass, config_entry): if not config_entry.options: # If the config entry doesn't already have any options set, set defaults: entry_updates["options"] = {CONF_SHOW_ON_MAP: True} - if CONF_INTEGRATION_TYPE not in config_entry.data: - # If the config entry data doesn't contain the integration type, add it: - entry_updates["data"] = { - **config_entry.data, - CONF_INTEGRATION_TYPE: INTEGRATION_TYPE_GEOGRAPHY_COORDS, - } + if config_entry.data.get(CONF_INTEGRATION_TYPE) not in [ + INTEGRATION_TYPE_GEOGRAPHY_COORDS, + INTEGRATION_TYPE_GEOGRAPHY_NAME, + ]: + # If the config entry data doesn't contain an integration type that we know + # about, infer it from the data we have: + entry_updates["data"] = {**config_entry.data} + if CONF_CITY in config_entry.data: + entry_updates["data"][ + CONF_INTEGRATION_TYPE + ] = INTEGRATION_TYPE_GEOGRAPHY_NAME + else: + entry_updates["data"][ + CONF_INTEGRATION_TYPE + ] = INTEGRATION_TYPE_GEOGRAPHY_COORDS if not entry_updates: return diff --git a/homeassistant/components/climacell/weather.py b/homeassistant/components/climacell/weather.py index c77bbfbd50a..e5a24197d6b 100644 --- a/homeassistant/components/climacell/weather.py +++ b/homeassistant/components/climacell/weather.py @@ -22,7 +22,6 @@ from homeassistant.const import ( LENGTH_MILES, PRESSURE_HPA, PRESSURE_INHG, - TEMP_CELSIUS, TEMP_FAHRENHEIT, ) from homeassistant.helpers.entity import Entity @@ -31,7 +30,6 @@ from homeassistant.helpers.typing import HomeAssistantType from homeassistant.util import dt as dt_util from homeassistant.util.distance import convert as distance_convert from homeassistant.util.pressure import convert as pressure_convert -from homeassistant.util.temperature import convert as temp_convert from . import ClimaCellDataUpdateCoordinator, ClimaCellEntity from .const import ( @@ -102,10 +100,6 @@ def _forecast_dict( precipitation = ( distance_convert(precipitation / 12, LENGTH_FEET, LENGTH_METERS) * 1000 ) - if temp: - temp = temp_convert(temp, TEMP_FAHRENHEIT, TEMP_CELSIUS) - if temp_low: - temp_low = temp_convert(temp_low, TEMP_FAHRENHEIT, TEMP_CELSIUS) if wind_speed: wind_speed = distance_convert(wind_speed, LENGTH_MILES, LENGTH_KILOMETERS) @@ -260,6 +254,7 @@ class ClimaCellWeatherEntity(ClimaCellEntity, WeatherEntity): if self.forecast_type == DAILY: use_datetime = False + forecast_dt = dt_util.start_of_local_day(forecast_dt) precipitation = self._get_cc_value( forecast, CC_ATTR_PRECIPITATION_DAILY ) diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index 18f46b3d619..1fbf35b1603 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -77,6 +77,7 @@ _NOT_SPEED_INTERVAL = "interval" _NOT_SPEED_IDLE = "idle" _NOT_SPEED_FAVORITE = "favorite" _NOT_SPEED_SLEEP = "sleep" +_NOT_SPEED_SILENT = "silent" _NOT_SPEEDS_FILTER = { _NOT_SPEED_OFF, @@ -85,6 +86,7 @@ _NOT_SPEEDS_FILTER = { _NOT_SPEED_SMART, _NOT_SPEED_INTERVAL, _NOT_SPEED_IDLE, + _NOT_SPEED_SILENT, _NOT_SPEED_SLEEP, _NOT_SPEED_FAVORITE, } @@ -652,7 +654,7 @@ def speed_list_without_preset_modes(speed_list: List): output: ["1", "2", "3", "4", "5", "6", "7"] input: ["Auto", "Silent", "Favorite", "Idle", "Medium", "High", "Strong"] - output: ["Silent", "Medium", "High", "Strong"] + output: ["Medium", "High", "Strong"] """ return [speed for speed in speed_list if speed.lower() not in _NOT_SPEEDS_FILTER] @@ -674,7 +676,7 @@ def preset_modes_from_speed_list(speed_list: List): output: ["smart"] input: ["Auto", "Silent", "Favorite", "Idle", "Medium", "High", "Strong"] - output: ["Auto", "Favorite", "Idle"] + output: ["Auto", "Silent", "Favorite", "Idle"] """ return [ diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 4d4127fc2f2..a5b4c6f10d5 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20210302.3" + "home-assistant-frontend==20210302.4" ], "dependencies": [ "api", diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index 5fbdf499146..7062267de19 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -35,6 +35,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import DOMAIN as HA_DOMAIN, CoreState, callback +from homeassistant.exceptions import ConditionError from homeassistant.helpers import condition import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import ( @@ -439,12 +440,16 @@ class GenericThermostat(ClimateEntity, RestoreEntity): current_state = STATE_ON else: current_state = HVAC_MODE_OFF - long_enough = condition.state( - self.hass, - self.heater_entity_id, - current_state, - self.min_cycle_duration, - ) + try: + long_enough = condition.state( + self.hass, + self.heater_entity_id, + current_state, + self.min_cycle_duration, + ) + except ConditionError: + long_enough = False + if not long_enough: return diff --git a/homeassistant/components/rflink/__init__.py b/homeassistant/components/rflink/__init__.py index 3cff3beed3c..68783c3426a 100644 --- a/homeassistant/components/rflink/__init__.py +++ b/homeassistant/components/rflink/__init__.py @@ -203,25 +203,28 @@ async def async_setup(hass, config): # TCP port when host configured, otherwise serial port port = config[DOMAIN][CONF_PORT] - # TCP KEEPALIVE will be enabled if value > 0 - keepalive_idle_timer = config[DOMAIN][CONF_KEEPALIVE_IDLE] - if keepalive_idle_timer < 0: - _LOGGER.error( - "A bogus TCP Keepalive IDLE timer was provided (%d secs), " - "default value will be used. " - "Recommended values: 60-3600 (seconds)", - keepalive_idle_timer, - ) - keepalive_idle_timer = DEFAULT_TCP_KEEPALIVE_IDLE_TIMER - elif keepalive_idle_timer == 0: - keepalive_idle_timer = None - elif keepalive_idle_timer <= 30: - _LOGGER.warning( - "A very short TCP Keepalive IDLE timer was provided (%d secs), " - "and may produce unexpected disconnections from RFlink device." - " Recommended values: 60-3600 (seconds)", - keepalive_idle_timer, - ) + keepalive_idle_timer = None + # TCP KeepAlive only if this is TCP based connection (not serial) + if host is not None: + # TCP KEEPALIVE will be enabled if value > 0 + keepalive_idle_timer = config[DOMAIN][CONF_KEEPALIVE_IDLE] + if keepalive_idle_timer < 0: + _LOGGER.error( + "A bogus TCP Keepalive IDLE timer was provided (%d secs), " + "it will be disabled. " + "Recommended values: 60-3600 (seconds)", + keepalive_idle_timer, + ) + keepalive_idle_timer = None + elif keepalive_idle_timer == 0: + keepalive_idle_timer = None + elif keepalive_idle_timer <= 30: + _LOGGER.warning( + "A very short TCP Keepalive IDLE timer was provided (%d secs) " + "and may produce unexpected disconnections from RFlink device." + " Recommended values: 60-3600 (seconds)", + keepalive_idle_timer, + ) @callback def reconnect(exc=None): diff --git a/homeassistant/components/wemo/__init__.py b/homeassistant/components/wemo/__init__.py index df737f101ba..db380ae11ca 100644 --- a/homeassistant/components/wemo/__init__.py +++ b/homeassistant/components/wemo/__init__.py @@ -1,4 +1,5 @@ """Support for WeMo device discovery.""" +import asyncio import logging import pywemo @@ -15,14 +16,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later -from homeassistant.util.async_ import gather_with_concurrency from .const import DOMAIN -# Max number of devices to initialize at once. This limit is in place to -# avoid tying up too many executor threads with WeMo device setup. -MAX_CONCURRENCY = 3 - # Mapping from Wemo model_name to domain. WEMO_MODEL_DISPATCH = { "Bridge": LIGHT_DOMAIN, @@ -118,12 +114,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): static_conf = config.get(CONF_STATIC, []) if static_conf: _LOGGER.debug("Adding statically configured WeMo devices...") - for device in await gather_with_concurrency( - MAX_CONCURRENCY, + for device in await asyncio.gather( *[ hass.async_add_executor_job(validate_static_config, host, port) for host, port in static_conf - ], + ] ): if device: wemo_dispatcher.async_add_unique_device(hass, device) @@ -192,44 +187,15 @@ class WemoDiscovery: self._wemo_dispatcher = wemo_dispatcher self._stop = None self._scan_delay = 0 - self._upnp_entries = set() - - async def async_add_from_upnp_entry(self, entry: pywemo.ssdp.UPNPEntry) -> None: - """Create a WeMoDevice from an UPNPEntry and add it to the dispatcher. - - Uses the self._upnp_entries set to avoid interrogating the same device - multiple times. - """ - if entry in self._upnp_entries: - return - try: - device = await self._hass.async_add_executor_job( - pywemo.discovery.device_from_uuid_and_location, - entry.udn, - entry.location, - ) - except pywemo.PyWeMoException as err: - _LOGGER.error("Unable to setup WeMo %r (%s)", entry, err) - else: - self._wemo_dispatcher.async_add_unique_device(self._hass, device) - self._upnp_entries.add(entry) async def async_discover_and_schedule(self, *_) -> None: """Periodically scan the network looking for WeMo devices.""" _LOGGER.debug("Scanning network for WeMo devices...") try: - # pywemo.ssdp.scan is a light-weight UDP UPnP scan for WeMo devices. - entries = await self._hass.async_add_executor_job(pywemo.ssdp.scan) - - # async_add_from_upnp_entry causes multiple HTTP requests to be sent - # to the WeMo device for the initial setup of the WeMoDevice - # instance. This may take some time to complete. The per-device - # setup work is done in parallel to speed up initial setup for the - # component. - await gather_with_concurrency( - MAX_CONCURRENCY, - *[self.async_add_from_upnp_entry(entry) for entry in entries], - ) + for device in await self._hass.async_add_executor_job( + pywemo.discover_devices + ): + self._wemo_dispatcher.async_add_unique_device(self._hass, device) finally: # Run discovery more frequently after hass has just started. self._scan_delay = min( diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index a8b32a31576..9c5f72d5877 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -23,7 +23,7 @@ from .gateway import ConnectXiaomiGateway _LOGGER = logging.getLogger(__name__) -GATEWAY_PLATFORMS = ["alarm_control_panel", "sensor", "light"] +GATEWAY_PLATFORMS = ["alarm_control_panel", "sensor", "switch", "light"] SWITCH_PLATFORMS = ["switch"] VACUUM_PLATFORMS = ["vacuum"] diff --git a/homeassistant/components/xiaomi_miio/const.py b/homeassistant/components/xiaomi_miio/const.py index d6c39146f6a..b2b63344c17 100644 --- a/homeassistant/components/xiaomi_miio/const.py +++ b/homeassistant/components/xiaomi_miio/const.py @@ -21,9 +21,8 @@ MODELS_SWITCH = [ "chuangmi.plug.v2", "chuangmi.plug.hmi205", "chuangmi.plug.hmi206", - "lumi.acpartner.v3", ] -MODELS_VACUUM = ["roborock.vacuum"] +MODELS_VACUUM = ["roborock.vacuum", "rockrobo.vacuum"] MODELS_ALL_DEVICES = MODELS_SWITCH + MODELS_VACUUM MODELS_ALL = MODELS_ALL_DEVICES + MODELS_GATEWAY diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index 821fe164ea9..a47b32fc6d1 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -22,7 +22,6 @@ from homeassistant.const import ( DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, - LIGHT_LUX, PERCENTAGE, PRESSURE_HPA, TEMP_CELSIUS, @@ -38,6 +37,7 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "Xiaomi Miio Sensor" DATA_KEY = "sensor.xiaomi_miio" +UNIT_LUMEN = "lm" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -302,7 +302,7 @@ class XiaomiGatewayIlluminanceSensor(Entity): @property def unit_of_measurement(self): """Return the unit of measurement of this entity.""" - return LIGHT_LUX + return UNIT_LUMEN @property def device_class(self): diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index 3cc95572e6c..e523dfb0bb7 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -22,6 +22,7 @@ import homeassistant.helpers.config_validation as cv from .const import ( CONF_DEVICE, CONF_FLOW_TYPE, + CONF_GATEWAY, CONF_MODEL, DOMAIN, SERVICE_SET_POWER_MODE, @@ -129,16 +130,19 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the switch from a config entry.""" entities = [] - if config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE: + host = config_entry.data[CONF_HOST] + token = config_entry.data[CONF_TOKEN] + name = config_entry.title + model = config_entry.data[CONF_MODEL] + unique_id = config_entry.unique_id + + if config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE or ( + config_entry.data[CONF_FLOW_TYPE] == CONF_GATEWAY + and model == "lumi.acpartner.v3" + ): if DATA_KEY not in hass.data: hass.data[DATA_KEY] = {} - host = config_entry.data[CONF_HOST] - token = config_entry.data[CONF_TOKEN] - name = config_entry.title - model = config_entry.data[CONF_MODEL] - unique_id = config_entry.unique_id - _LOGGER.debug("Initializing with host %s (token %s...)", host, token[:5]) if model in ["chuangmi.plug.v1", "chuangmi.plug.v3", "chuangmi.plug.hmi208"]: diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index d4e349645cf..0da394721ac 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -48,7 +48,8 @@ from .const import ( ZWAVE_JS_EVENT, ) from .discovery import async_discover_values -from .helpers import get_device_id, get_old_value_id, get_unique_id +from .helpers import get_device_id +from .migrate import async_migrate_discovered_value from .services import ZWaveServices CONNECT_TIMEOUT = 10 @@ -98,31 +99,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: dev_reg = await device_registry.async_get_registry(hass) ent_reg = entity_registry.async_get(hass) - @callback - def migrate_entity(platform: str, old_unique_id: str, new_unique_id: str) -> None: - """Check if entity with old unique ID exists, and if so migrate it to new ID.""" - if entity_id := ent_reg.async_get_entity_id(platform, DOMAIN, old_unique_id): - LOGGER.debug( - "Migrating entity %s from old unique ID '%s' to new unique ID '%s'", - entity_id, - old_unique_id, - new_unique_id, - ) - try: - ent_reg.async_update_entity( - entity_id, - new_unique_id=new_unique_id, - ) - except ValueError: - LOGGER.debug( - ( - "Entity %s can't be migrated because the unique ID is taken. " - "Cleaning it up since it is likely no longer valid." - ), - entity_id, - ) - ent_reg.async_remove(entity_id) - @callback def async_on_node_ready(node: ZwaveNode) -> None: """Handle node ready event.""" @@ -136,49 +112,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: LOGGER.debug("Discovered entity: %s", disc_info) # This migration logic was added in 2021.3 to handle a breaking change to - # the value_id format. Some time in the future, this code block - # (as well as get_old_value_id helper and migrate_entity closure) can be - # removed. - value_ids = [ - # 2021.2.* format - get_old_value_id(disc_info.primary_value), - # 2021.3.0b0 format - disc_info.primary_value.value_id, - ] - - new_unique_id = get_unique_id( - client.driver.controller.home_id, - disc_info.primary_value.value_id, - ) - - for value_id in value_ids: - old_unique_id = get_unique_id( - client.driver.controller.home_id, - f"{disc_info.primary_value.node.node_id}.{value_id}", - ) - # Most entities have the same ID format, but notification binary sensors - # have a state key in their ID so we need to handle them differently - if ( - disc_info.platform == "binary_sensor" - and disc_info.platform_hint == "notification" - ): - for state_key in disc_info.primary_value.metadata.states: - # ignore idle key (0) - if state_key == "0": - continue - - migrate_entity( - disc_info.platform, - f"{old_unique_id}.{state_key}", - f"{new_unique_id}.{state_key}", - ) - - # Once we've iterated through all state keys, we can move on to the - # next item - continue - - migrate_entity(disc_info.platform, old_unique_id, new_unique_id) - + # the value_id format. Some time in the future, this call (as well as the + # helper functions) can be removed. + async_migrate_discovered_value(ent_reg, client, disc_info) async_dispatcher_send( hass, f"{DOMAIN}_{entry.entry_id}_add_{disc_info.platform}", disc_info ) @@ -483,11 +419,15 @@ async def async_ensure_addon_running(hass: HomeAssistant, entry: ConfigEntry) -> network_key: str = entry.data[CONF_NETWORK_KEY] if not addon_is_installed: - addon_manager.async_schedule_install_addon(usb_path, network_key) + addon_manager.async_schedule_install_setup_addon( + usb_path, network_key, catch_error=True + ) raise ConfigEntryNotReady if not addon_is_running: - addon_manager.async_schedule_setup_addon(usb_path, network_key) + addon_manager.async_schedule_setup_addon( + usb_path, network_key, catch_error=True + ) raise ConfigEntryNotReady @@ -497,4 +437,4 @@ def async_ensure_addon_updated(hass: HomeAssistant) -> None: addon_manager: AddonManager = get_addon_manager(hass) if addon_manager.task_in_progress(): raise ConfigEntryNotReady - addon_manager.async_schedule_update_addon() + addon_manager.async_schedule_update_addon(catch_error=True) diff --git a/homeassistant/components/zwave_js/addon.py b/homeassistant/components/zwave_js/addon.py index 54169dcaf94..818e46a34aa 100644 --- a/homeassistant/components/zwave_js/addon.py +++ b/homeassistant/components/zwave_js/addon.py @@ -67,8 +67,8 @@ class AddonManager: """Set up the add-on manager.""" self._hass = hass self._install_task: Optional[asyncio.Task] = None + self._start_task: Optional[asyncio.Task] = None self._update_task: Optional[asyncio.Task] = None - self._setup_task: Optional[asyncio.Task] = None def task_in_progress(self) -> bool: """Return True if any of the add-on tasks are in progress.""" @@ -76,7 +76,7 @@ class AddonManager: task and not task.done() for task in ( self._install_task, - self._setup_task, + self._start_task, self._update_task, ) ) @@ -125,8 +125,21 @@ class AddonManager: await async_install_addon(self._hass, ADDON_SLUG) @callback - def async_schedule_install_addon( - self, usb_path: str, network_key: str + def async_schedule_install_addon(self, catch_error: bool = False) -> asyncio.Task: + """Schedule a task that installs the Z-Wave JS add-on. + + Only schedule a new install task if the there's no running task. + """ + if not self._install_task or self._install_task.done(): + LOGGER.info("Z-Wave JS add-on is not installed. Installing add-on") + self._install_task = self._async_schedule_addon_operation( + self.async_install_addon, catch_error=catch_error + ) + return self._install_task + + @callback + def async_schedule_install_setup_addon( + self, usb_path: str, network_key: str, catch_error: bool = False ) -> asyncio.Task: """Schedule a task that installs and sets up the Z-Wave JS add-on. @@ -136,7 +149,9 @@ class AddonManager: LOGGER.info("Z-Wave JS add-on is not installed. Installing add-on") self._install_task = self._async_schedule_addon_operation( self.async_install_addon, - partial(self.async_setup_addon, usb_path, network_key), + partial(self.async_configure_addon, usb_path, network_key), + self.async_start_addon, + catch_error=catch_error, ) return self._install_task @@ -158,10 +173,11 @@ class AddonManager: if not update_available: return + await self.async_create_snapshot() await async_update_addon(self._hass, ADDON_SLUG) @callback - def async_schedule_update_addon(self) -> asyncio.Task: + def async_schedule_update_addon(self, catch_error: bool = False) -> asyncio.Task: """Schedule a task that updates and sets up the Z-Wave JS add-on. Only schedule a new update task if the there's no running task. @@ -169,7 +185,8 @@ class AddonManager: if not self._update_task or self._update_task.done(): LOGGER.info("Trying to update the Z-Wave JS add-on") self._update_task = self._async_schedule_addon_operation( - self.async_create_snapshot, self.async_update_addon + self.async_update_addon, + catch_error=catch_error, ) return self._update_task @@ -178,12 +195,25 @@ class AddonManager: """Start the Z-Wave JS add-on.""" await async_start_addon(self._hass, ADDON_SLUG) + @callback + def async_schedule_start_addon(self, catch_error: bool = False) -> asyncio.Task: + """Schedule a task that starts the Z-Wave JS add-on. + + Only schedule a new start task if the there's no running task. + """ + if not self._start_task or self._start_task.done(): + LOGGER.info("Z-Wave JS add-on is not running. Starting add-on") + self._start_task = self._async_schedule_addon_operation( + self.async_start_addon, catch_error=catch_error + ) + return self._start_task + @api_error("Failed to stop the Z-Wave JS add-on") async def async_stop_addon(self) -> None: """Stop the Z-Wave JS add-on.""" await async_stop_addon(self._hass, ADDON_SLUG) - async def async_setup_addon(self, usb_path: str, network_key: str) -> None: + async def async_configure_addon(self, usb_path: str, network_key: str) -> None: """Configure and start Z-Wave JS add-on.""" addon_options = await self.async_get_addon_options() @@ -195,22 +225,22 @@ class AddonManager: if new_addon_options != addon_options: await self.async_set_addon_options(new_addon_options) - await self.async_start_addon() - @callback def async_schedule_setup_addon( - self, usb_path: str, network_key: str + self, usb_path: str, network_key: str, catch_error: bool = False ) -> asyncio.Task: """Schedule a task that configures and starts the Z-Wave JS add-on. Only schedule a new setup task if the there's no running task. """ - if not self._setup_task or self._setup_task.done(): + if not self._start_task or self._start_task.done(): LOGGER.info("Z-Wave JS add-on is not running. Starting add-on") - self._setup_task = self._async_schedule_addon_operation( - partial(self.async_setup_addon, usb_path, network_key) + self._start_task = self._async_schedule_addon_operation( + partial(self.async_configure_addon, usb_path, network_key), + self.async_start_addon, + catch_error=catch_error, ) - return self._setup_task + return self._start_task @api_error("Failed to create a snapshot of the Z-Wave JS add-on.") async def async_create_snapshot(self) -> None: @@ -227,7 +257,9 @@ class AddonManager: ) @callback - def _async_schedule_addon_operation(self, *funcs: Callable) -> asyncio.Task: + def _async_schedule_addon_operation( + self, *funcs: Callable, catch_error: bool = False + ) -> asyncio.Task: """Schedule an add-on task.""" async def addon_operation() -> None: @@ -236,6 +268,8 @@ class AddonManager: try: await func() except AddonError as err: + if not catch_error: + raise LOGGER.error(err) break diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index 54966538aae..325cf14b379 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -118,25 +118,17 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): super().__init__(config_entry, client, info) self._hvac_modes: Dict[str, Optional[int]] = {} self._hvac_presets: Dict[str, Optional[int]] = {} - self._unit_value: ZwaveValue = None + self._unit_value: Optional[ZwaveValue] = None self._current_mode = self.get_zwave_value( THERMOSTAT_MODE_PROPERTY, command_class=CommandClass.THERMOSTAT_MODE ) self._setpoint_values: Dict[ThermostatSetpointType, ZwaveValue] = {} for enum in ThermostatSetpointType: - # Some devices don't include a property key so we need to check for value - # ID's, both with and without the property key self._setpoint_values[enum] = self.get_zwave_value( THERMOSTAT_SETPOINT_PROPERTY, command_class=CommandClass.THERMOSTAT_SETPOINT, value_property_key=enum.value.key, - value_property_key_name=enum.value.name, - add_to_watched_value_ids=True, - ) or self.get_zwave_value( - THERMOSTAT_SETPOINT_PROPERTY, - command_class=CommandClass.THERMOSTAT_SETPOINT, - value_property_key_name=enum.value.name, add_to_watched_value_ids=True, ) # Use the first found setpoint value to always determine the temperature unit @@ -215,7 +207,11 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): @property def temperature_unit(self) -> str: """Return the unit of measurement used by the platform.""" - if "f" in self._unit_value.metadata.unit.lower(): + if ( + self._unit_value + and self._unit_value.metadata.unit + and "f" in self._unit_value.metadata.unit.lower() + ): return TEMP_FAHRENHEIT return TEMP_CELSIUS diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 37923c574b4..4929f7e7869 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -117,7 +117,15 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id( version_info.home_id, raise_on_progress=False ) - self._abort_if_unique_id_configured(user_input) + # Make sure we disable any add-on handling + # if the controller is reconfigured in a manual step. + self._abort_if_unique_id_configured( + updates={ + **user_input, + CONF_USE_ADDON: False, + CONF_INTEGRATION_CREATED_ADDON: False, + } + ) self.ws_address = user_input[CONF_URL] return self._async_create_entry_from_vars() @@ -172,11 +180,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self, user_input: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: """Handle logic when on Supervisor host.""" - # Only one entry with Supervisor add-on support is allowed. - for entry in self.hass.config_entries.async_entries(DOMAIN): - if entry.data.get(CONF_USE_ADDON): - return await self.async_step_manual() - if user_input is None: return self.async_show_form( step_id="on_supervisor", data_schema=ON_SUPERVISOR_SCHEMA @@ -289,7 +292,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): assert self.hass addon_manager: AddonManager = get_addon_manager(self.hass) try: - await addon_manager.async_start_addon() + await addon_manager.async_schedule_start_addon() # Sleep some seconds to let the add-on start properly before connecting. for _ in range(ADDON_SETUP_TIMEOUT_ROUNDS): await asyncio.sleep(ADDON_SETUP_TIMEOUT) @@ -338,7 +341,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): version_info.home_id, raise_on_progress=False ) - self._abort_if_unique_id_configured() + self._abort_if_unique_id_configured( + updates={ + CONF_URL: self.ws_address, + CONF_USB_PATH: self.usb_path, + CONF_NETWORK_KEY: self.network_key, + } + ) return self._async_create_entry_from_vars() async def _async_get_addon_info(self) -> dict: @@ -381,7 +390,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Install the Z-Wave JS add-on.""" addon_manager: AddonManager = get_addon_manager(self.hass) try: - await addon_manager.async_install_addon() + await addon_manager.async_schedule_install_addon() finally: # Continue the flow after show progress when the task is done. self.hass.async_create_task( diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index d0ed9eb5291..c061abc4d0d 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -169,7 +169,6 @@ class ZWaveBaseEntity(Entity): command_class: Optional[int] = None, endpoint: Optional[int] = None, value_property_key: Optional[int] = None, - value_property_key_name: Optional[str] = None, add_to_watched_value_ids: bool = True, check_all_endpoints: bool = False, ) -> Optional[ZwaveValue]: @@ -188,7 +187,6 @@ class ZWaveBaseEntity(Entity): value_property, endpoint=endpoint, property_key=value_property_key, - property_key_name=value_property_key_name, ) return_value = self.info.node.values.get(value_id) @@ -203,7 +201,6 @@ class ZWaveBaseEntity(Entity): value_property, endpoint=endpoint_.index, property_key=value_property_key, - property_key_name=value_property_key_name, ) return_value = self.info.node.values.get(value_id) if return_value: diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 9582b7ee054..16baeb816c2 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -3,7 +3,6 @@ from typing import List, Tuple, cast from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.model.node import Node as ZwaveNode -from zwave_js_server.model.value import Value as ZwaveValue from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -13,16 +12,6 @@ from homeassistant.helpers.entity_registry import async_get as async_get_ent_reg from .const import DATA_CLIENT, DOMAIN -@callback -def get_old_value_id(value: ZwaveValue) -> str: - """Get old value ID so we can migrate entity unique ID.""" - command_class = value.command_class - endpoint = value.endpoint or "00" - property_ = value.property_ - property_key_name = value.property_key_name or "00" - return f"{value.node.node_id}-{command_class}-{endpoint}-{property_}-{property_key_name}" - - @callback def get_unique_id(home_id: str, value_id: str) -> str: """Get unique ID from home ID and value ID.""" diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index d9c31210bea..b501ecb58e7 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -228,7 +228,6 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): "targetColor", CommandClass.SWITCH_COLOR, value_property_key=None, - value_property_key_name=None, ) if combined_color_val and isinstance(combined_color_val.value, dict): colors_dict = {} @@ -252,7 +251,6 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): "targetColor", CommandClass.SWITCH_COLOR, value_property_key=property_key.key, - value_property_key_name=property_key.name, ) if target_zwave_value is None: # guard for unsupported color @@ -318,31 +316,26 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): "currentColor", CommandClass.SWITCH_COLOR, value_property_key=ColorComponent.RED.value.key, - value_property_key_name=ColorComponent.RED.value.name, ) green_val = self.get_zwave_value( "currentColor", CommandClass.SWITCH_COLOR, value_property_key=ColorComponent.GREEN.value.key, - value_property_key_name=ColorComponent.GREEN.value.name, ) blue_val = self.get_zwave_value( "currentColor", CommandClass.SWITCH_COLOR, value_property_key=ColorComponent.BLUE.value.key, - value_property_key_name=ColorComponent.BLUE.value.name, ) ww_val = self.get_zwave_value( "currentColor", CommandClass.SWITCH_COLOR, value_property_key=ColorComponent.WARM_WHITE.value.key, - value_property_key_name=ColorComponent.WARM_WHITE.value.name, ) cw_val = self.get_zwave_value( "currentColor", CommandClass.SWITCH_COLOR, value_property_key=ColorComponent.COLD_WHITE.value.key, - value_property_key_name=ColorComponent.COLD_WHITE.value.name, ) # prefer the (new) combined color property # https://github.com/zwave-js/node-zwave-js/pull/1782 @@ -350,7 +343,6 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): "currentColor", CommandClass.SWITCH_COLOR, value_property_key=None, - value_property_key_name=None, ) if combined_color_val and isinstance(combined_color_val.value, dict): multi_color = combined_color_val.value diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index c812515a179..2a6f036fa80 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -3,7 +3,7 @@ "name": "Z-Wave JS", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave_js", - "requirements": ["zwave-js-server-python==0.20.1"], + "requirements": ["zwave-js-server-python==0.21.0"], "codeowners": ["@home-assistant/z-wave"], "dependencies": ["http", "websocket_api"] } diff --git a/homeassistant/components/zwave_js/migrate.py b/homeassistant/components/zwave_js/migrate.py new file mode 100644 index 00000000000..49c18073de5 --- /dev/null +++ b/homeassistant/components/zwave_js/migrate.py @@ -0,0 +1,113 @@ +"""Functions used to migrate unique IDs for Z-Wave JS entities.""" +import logging +from typing import List + +from zwave_js_server.client import Client as ZwaveClient +from zwave_js_server.model.value import Value as ZwaveValue + +from homeassistant.core import callback +from homeassistant.helpers.entity_registry import EntityRegistry + +from .const import DOMAIN +from .discovery import ZwaveDiscoveryInfo +from .helpers import get_unique_id + +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_migrate_entity( + ent_reg: EntityRegistry, platform: str, old_unique_id: str, new_unique_id: str +) -> None: + """Check if entity with old unique ID exists, and if so migrate it to new ID.""" + if entity_id := ent_reg.async_get_entity_id(platform, DOMAIN, old_unique_id): + _LOGGER.debug( + "Migrating entity %s from old unique ID '%s' to new unique ID '%s'", + entity_id, + old_unique_id, + new_unique_id, + ) + try: + ent_reg.async_update_entity( + entity_id, + new_unique_id=new_unique_id, + ) + except ValueError: + _LOGGER.debug( + ( + "Entity %s can't be migrated because the unique ID is taken. " + "Cleaning it up since it is likely no longer valid." + ), + entity_id, + ) + ent_reg.async_remove(entity_id) + + +@callback +def async_migrate_discovered_value( + ent_reg: EntityRegistry, client: ZwaveClient, disc_info: ZwaveDiscoveryInfo +) -> None: + """Migrate unique ID for entity/entities tied to discovered value.""" + new_unique_id = get_unique_id( + client.driver.controller.home_id, + disc_info.primary_value.value_id, + ) + + # 2021.2.*, 2021.3.0b0, and 2021.3.0 formats + for value_id in get_old_value_ids(disc_info.primary_value): + old_unique_id = get_unique_id( + client.driver.controller.home_id, + value_id, + ) + # Most entities have the same ID format, but notification binary sensors + # have a state key in their ID so we need to handle them differently + if ( + disc_info.platform == "binary_sensor" + and disc_info.platform_hint == "notification" + ): + for state_key in disc_info.primary_value.metadata.states: + # ignore idle key (0) + if state_key == "0": + continue + + async_migrate_entity( + ent_reg, + disc_info.platform, + f"{old_unique_id}.{state_key}", + f"{new_unique_id}.{state_key}", + ) + + # Once we've iterated through all state keys, we can move on to the + # next item + continue + + async_migrate_entity(ent_reg, disc_info.platform, old_unique_id, new_unique_id) + + +@callback +def get_old_value_ids(value: ZwaveValue) -> List[str]: + """Get old value IDs so we can migrate entity unique ID.""" + value_ids = [] + + # Pre 2021.3.0 value ID + command_class = value.command_class + endpoint = value.endpoint or "00" + property_ = value.property_ + property_key_name = value.property_key_name or "00" + value_ids.append( + f"{value.node.node_id}.{value.node.node_id}-{command_class}-{endpoint}-" + f"{property_}-{property_key_name}" + ) + + endpoint = "00" if value.endpoint is None else value.endpoint + property_key = "00" if value.property_key is None else value.property_key + property_key_name = value.property_key_name or "00" + + value_id = ( + f"{value.node.node_id}-{command_class}-{endpoint}-" + f"{property_}-{property_key}-{property_key_name}" + ) + # 2021.3.0b0 and 2021.3.0 value IDs + value_ids.extend([f"{value.node.node_id}.{value_id}", value_id]) + + return value_ids diff --git a/homeassistant/const.py b/homeassistant/const.py index ec2ab3bff0c..e454f3ab09d 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 2021 MINOR_VERSION = 3 -PATCH_VERSION = "0" +PATCH_VERSION = "1" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 8, 0) diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 40087650141..c592681a015 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -265,10 +265,9 @@ def async_numeric_state( "numeric_state", f"template error: {ex}" ) from ex + # Known states that never match the numeric condition if value in (STATE_UNAVAILABLE, STATE_UNKNOWN): - raise ConditionErrorMessage( - "numeric_state", f"state of {entity_id} is unavailable" - ) + return False try: fvalue = float(value) @@ -281,13 +280,15 @@ def async_numeric_state( if below is not None: if isinstance(below, str): below_entity = hass.states.get(below) - if not below_entity or below_entity.state in ( + if not below_entity: + raise ConditionErrorMessage( + "numeric_state", f"unknown 'below' entity {below}" + ) + if below_entity.state in ( STATE_UNAVAILABLE, STATE_UNKNOWN, ): - raise ConditionErrorMessage( - "numeric_state", f"the 'below' entity {below} is unavailable" - ) + return False try: if fvalue >= float(below_entity.state): return False @@ -302,13 +303,15 @@ def async_numeric_state( if above is not None: if isinstance(above, str): above_entity = hass.states.get(above) - if not above_entity or above_entity.state in ( + if not above_entity: + raise ConditionErrorMessage( + "numeric_state", f"unknown 'above' entity {above}" + ) + if above_entity.state in ( STATE_UNAVAILABLE, STATE_UNKNOWN, ): - raise ConditionErrorMessage( - "numeric_state", f"the 'above' entity {above} is unavailable" - ) + return False try: if fvalue <= float(above_entity.state): return False diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 752a3755169..919b53f64ed 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -15,7 +15,7 @@ defusedxml==0.6.0 distro==1.5.0 emoji==1.2.0 hass-nabucasa==0.41.0 -home-assistant-frontend==20210302.3 +home-assistant-frontend==20210302.4 httpx==0.16.1 jinja2>=2.11.3 netdisco==2.8.2 diff --git a/requirements_all.txt b/requirements_all.txt index ffbd439ee2e..5d69ddd65bd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -763,7 +763,7 @@ hole==0.5.1 holidays==0.10.5.2 # homeassistant.components.frontend -home-assistant-frontend==20210302.3 +home-assistant-frontend==20210302.4 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 @@ -2397,4 +2397,4 @@ zigpy==0.32.0 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.20.1 +zwave-js-server-python==0.21.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 318b04e5e70..5fdf5100605 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -412,7 +412,7 @@ hole==0.5.1 holidays==0.10.5.2 # homeassistant.components.frontend -home-assistant-frontend==20210302.3 +home-assistant-frontend==20210302.4 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 @@ -1234,4 +1234,4 @@ zigpy-znp==0.4.0 zigpy==0.32.0 # homeassistant.components.zwave_js -zwave-js-server-python==0.20.1 +zwave-js-server-python==0.21.0 diff --git a/tests/components/homeassistant/triggers/test_numeric_state.py b/tests/components/homeassistant/triggers/test_numeric_state.py index 831e20b78a1..9eb9ac79a94 100644 --- a/tests/components/homeassistant/triggers/test_numeric_state.py +++ b/tests/components/homeassistant/triggers/test_numeric_state.py @@ -10,12 +10,7 @@ import homeassistant.components.automation as automation from homeassistant.components.homeassistant.triggers import ( numeric_state as numeric_state_trigger, ) -from homeassistant.const import ( - ATTR_ENTITY_ID, - ENTITY_MATCH_ALL, - SERVICE_TURN_OFF, - STATE_UNAVAILABLE, -) +from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, SERVICE_TURN_OFF from homeassistant.core import Context from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -347,52 +342,6 @@ async def test_if_fires_on_entity_unavailable_at_startup(hass, calls): assert len(calls) == 0 -async def test_if_not_fires_on_entity_unavailable(hass, calls): - """Test the firing with entity changing to unavailable.""" - # set initial state - hass.states.async_set("test.entity", 9) - await hass.async_block_till_done() - - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "numeric_state", - "entity_id": "test.entity", - "above": 10, - }, - "action": {"service": "test.automation"}, - } - }, - ) - - # 11 is above 10 - hass.states.async_set("test.entity", 11) - await hass.async_block_till_done() - assert len(calls) == 1 - - # Going to unavailable and back should not fire - hass.states.async_set("test.entity", STATE_UNAVAILABLE) - await hass.async_block_till_done() - assert len(calls) == 1 - hass.states.async_set("test.entity", 11) - await hass.async_block_till_done() - assert len(calls) == 1 - - # Crossing threshold via unavailable should fire - hass.states.async_set("test.entity", 9) - await hass.async_block_till_done() - assert len(calls) == 1 - hass.states.async_set("test.entity", STATE_UNAVAILABLE) - await hass.async_block_till_done() - assert len(calls) == 1 - hass.states.async_set("test.entity", 11) - await hass.async_block_till_done() - assert len(calls) == 2 - - @pytest.mark.parametrize("above", (10, "input_number.value_10")) async def test_if_fires_on_entity_change_below_to_above(hass, calls, above): """Test the firing with changed entity.""" @@ -1522,7 +1471,7 @@ async def test_if_fires_on_change_with_for_template_3(hass, calls, above, below) assert len(calls) == 1 -async def test_if_not_fires_on_error_with_for_template(hass, caplog, calls): +async def test_if_not_fires_on_error_with_for_template(hass, calls): """Test for not firing on error with for template.""" hass.states.async_set("test.entity", 0) await hass.async_block_till_done() @@ -1547,17 +1496,11 @@ async def test_if_not_fires_on_error_with_for_template(hass, caplog, calls): await hass.async_block_till_done() assert len(calls) == 0 - caplog.clear() - caplog.set_level(logging.WARNING) - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=3)) hass.states.async_set("test.entity", "unavailable") await hass.async_block_till_done() assert len(calls) == 0 - assert len(caplog.record_tuples) == 1 - assert caplog.record_tuples[0][1] == logging.WARNING - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=3)) hass.states.async_set("test.entity", 101) await hass.async_block_till_done() diff --git a/tests/components/wemo/test_init.py b/tests/components/wemo/test_init.py index 7c2b43dfd8c..374222d8688 100644 --- a/tests/components/wemo/test_init.py +++ b/tests/components/wemo/test_init.py @@ -100,41 +100,28 @@ async def test_static_config_with_invalid_host(hass): async def test_discovery(hass, pywemo_registry): """Verify that discovery dispatches devices to the platform for setup.""" - def create_device(uuid, location): + def create_device(counter): """Create a unique mock Motion detector device for each counter value.""" device = create_autospec(pywemo.Motion, instance=True) - device.host = location - device.port = MOCK_PORT - device.name = f"{MOCK_NAME}_{uuid}" - device.serialnumber = f"{MOCK_SERIAL_NUMBER}_{uuid}" + device.host = f"{MOCK_HOST}_{counter}" + device.port = MOCK_PORT + counter + device.name = f"{MOCK_NAME}_{counter}" + device.serialnumber = f"{MOCK_SERIAL_NUMBER}_{counter}" device.model_name = "Motion" device.get_state.return_value = 0 # Default to Off return device - def create_upnp_entry(counter): - return pywemo.ssdp.UPNPEntry.from_response( - "\r\n".join( - [ - "", - f"LOCATION: http://192.168.1.100:{counter}/setup.xml", - f"USN: uuid:Socket-1_0-SERIAL{counter}::upnp:rootdevice", - "", - ] - ) - ) - - upnp_entries = [create_upnp_entry(0), create_upnp_entry(1)] + pywemo_devices = [create_device(0), create_device(1)] # Setup the component and start discovery. with patch( - "pywemo.discovery.device_from_uuid_and_location", side_effect=create_device - ), patch("pywemo.ssdp.scan", return_value=upnp_entries) as mock_scan: + "pywemo.discover_devices", return_value=pywemo_devices + ) as mock_discovery: assert await async_setup_component( hass, DOMAIN, {DOMAIN: {CONF_DISCOVERY: True}} ) await pywemo_registry.semaphore.acquire() # Returns after platform setup. - mock_scan.assert_called() - # Add two of the same entries to test deduplication. - upnp_entries.extend([create_upnp_entry(2), create_upnp_entry(2)]) + mock_discovery.assert_called() + pywemo_devices.append(create_device(2)) # Test that discovery runs periodically and the async_dispatcher_send code works. async_fire_time_changed( diff --git a/tests/components/zwave_js/common.py b/tests/components/zwave_js/common.py index a5ee628754e..ec54e139404 100644 --- a/tests/components/zwave_js/common.py +++ b/tests/components/zwave_js/common.py @@ -16,6 +16,7 @@ PROPERTY_DOOR_STATUS_BINARY_SENSOR = ( CLIMATE_RADIO_THERMOSTAT_ENTITY = "climate.z_wave_thermostat" CLIMATE_DANFOSS_LC13_ENTITY = "climate.living_connect_z_thermostat" CLIMATE_FLOOR_THERMOSTAT_ENTITY = "climate.floor_thermostat" +CLIMATE_MAIN_HEAT_ACTIONNER = "climate.main_heat_actionner" BULB_6_MULTI_COLOR_LIGHT_ENTITY = "light.bulb_6_multi_color" EATON_RF9640_ENTITY = "light.allloaddimmer" AEON_SMART_SWITCH_LIGHT_ENTITY = "light.smart_switch_6" diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 50cacd97422..aa9da282635 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -240,6 +240,12 @@ def nortek_thermostat_state_fixture(): return json.loads(load_fixture("zwave_js/nortek_thermostat_state.json")) +@pytest.fixture(name="srt321_hrt4_zw_state", scope="session") +def srt321_hrt4_zw_state_fixture(): + """Load the climate HRT4-ZW / SRT321 / SRT322 thermostat node state fixture data.""" + return json.loads(load_fixture("zwave_js/srt321_hrt4_zw_state.json")) + + @pytest.fixture(name="chain_actuator_zws12_state", scope="session") def window_cover_state_fixture(): """Load the window cover node state fixture data.""" @@ -417,6 +423,14 @@ def nortek_thermostat_fixture(client, nortek_thermostat_state): return node +@pytest.fixture(name="srt321_hrt4_zw") +def srt321_hrt4_zw_fixture(client, srt321_hrt4_zw_state): + """Mock a HRT4-ZW / SRT321 / SRT322 thermostat node.""" + node = Node(client, copy.deepcopy(srt321_hrt4_zw_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="aeotec_radiator_thermostat") def aeotec_radiator_thermostat_fixture(client, aeotec_radiator_thermostat_state): """Mock a Aeotec thermostat node.""" diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index dcbd924c86e..1b3f29e9cb1 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -70,7 +70,7 @@ async def test_websocket_api(hass, integration, multisensor_6, hass_ws_client): result = msg["result"] assert len(result) == 61 - key = "52-112-0-2-00-00" + key = "52-112-0-2" assert result[key]["property"] == 2 assert result[key]["metadata"]["type"] == "number" assert result[key]["configuration_value_type"] == "enumerated" diff --git a/tests/components/zwave_js/test_climate.py b/tests/components/zwave_js/test_climate.py index 44804825885..fe3e0708acc 100644 --- a/tests/components/zwave_js/test_climate.py +++ b/tests/components/zwave_js/test_climate.py @@ -31,6 +31,7 @@ from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE from .common import ( CLIMATE_DANFOSS_LC13_ENTITY, CLIMATE_FLOOR_THERMOSTAT_ENTITY, + CLIMATE_MAIN_HEAT_ACTIONNER, CLIMATE_RADIO_THERMOSTAT_ENTITY, ) @@ -404,7 +405,7 @@ async def test_setpoint_thermostat(hass, client, climate_danfoss_lc_13, integrat assert state assert state.state == HVAC_MODE_HEAT - assert state.attributes[ATTR_TEMPERATURE] == 25 + assert state.attributes[ATTR_TEMPERATURE] == 14 assert state.attributes[ATTR_HVAC_MODES] == [HVAC_MODE_HEAT] assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE @@ -431,6 +432,7 @@ async def test_setpoint_thermostat(hass, client, climate_danfoss_lc_13, integrat "commandClassName": "Thermostat Setpoint", "property": "setpoint", "propertyName": "setpoint", + "propertyKey": 1, "propertyKeyName": "Heating", "ccVersion": 2, "metadata": { @@ -440,7 +442,7 @@ async def test_setpoint_thermostat(hass, client, climate_danfoss_lc_13, integrat "unit": "\u00b0C", "ccSpecific": {"setpointType": 1}, }, - "value": 25, + "value": 14, } assert args["value"] == 21.5 @@ -458,6 +460,7 @@ async def test_setpoint_thermostat(hass, client, climate_danfoss_lc_13, integrat "commandClass": 67, "endpoint": 0, "property": "setpoint", + "propertyKey": 1, "propertyKeyName": "Heating", "propertyName": "setpoint", "newValue": 23, @@ -488,3 +491,19 @@ async def test_thermostat_heatit(hass, client, climate_heatit_z_trm3, integratio assert state.attributes[ATTR_TEMPERATURE] == 22.5 assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE + + +async def test_thermostat_srt321_hrt4_zw(hass, client, srt321_hrt4_zw, integration): + """Test a climate entity from a HRT4-ZW / SRT321 thermostat device. + + This device currently has no setpoint values. + """ + state = hass.states.get(CLIMATE_MAIN_HEAT_ACTIONNER) + + assert state + assert state.state == HVAC_MODE_OFF + assert state.attributes[ATTR_HVAC_MODES] == [ + HVAC_MODE_OFF, + HVAC_MODE_HEAT, + ] + assert state.attributes[ATTR_CURRENT_TEMPERATURE] is None diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index fc97f7420cf..7eea126e52e 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -184,7 +184,16 @@ async def test_manual_errors( async def test_manual_already_configured(hass): """Test that only one unique instance is allowed.""" - entry = MockConfigEntry(domain=DOMAIN, data={}, title=TITLE, unique_id=1234) + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "url": "ws://localhost:3000", + "use_addon": True, + "integration_created_addon": True, + }, + title=TITLE, + unique_id=1234, + ) entry.add_to_hass(hass) await setup.async_setup_component(hass, "persistent_notification", {}) @@ -198,12 +207,15 @@ async def test_manual_already_configured(hass): result = await hass.config_entries.flow.async_configure( result["flow_id"], { - "url": "ws://localhost:3000", + "url": "ws://1.1.1.1:3001", }, ) assert result["type"] == "abort" assert result["reason"] == "already_configured" + assert entry.data["url"] == "ws://1.1.1.1:3001" + assert entry.data["use_addon"] is False + assert entry.data["integration_created_addon"] is False @pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) @@ -507,49 +519,6 @@ async def test_not_addon(hass, supervisor): assert len(mock_setup_entry.mock_calls) == 1 -async def test_addon_already_configured(hass, supervisor): - """Test add-on already configured leads to manual step.""" - entry = MockConfigEntry( - domain=DOMAIN, data={"use_addon": True}, title=TITLE, unique_id=5678 - ) - entry.add_to_hass(hass) - - await setup.async_setup_component(hass, "persistent_notification", {}) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] == "form" - assert result["step_id"] == "manual" - - with patch( - "homeassistant.components.zwave_js.async_setup", return_value=True - ) as mock_setup, patch( - "homeassistant.components.zwave_js.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "url": "ws://localhost:3000", - }, - ) - await hass.async_block_till_done() - - assert result["type"] == "create_entry" - assert result["title"] == TITLE - assert result["data"] == { - "url": "ws://localhost:3000", - "usb_path": None, - "network_key": None, - "use_addon": False, - "integration_created_addon": False, - } - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 2 - - @pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) async def test_addon_running( hass, @@ -661,9 +630,18 @@ async def test_addon_running_already_configured( hass, supervisor, addon_running, addon_options, get_addon_discovery_info ): """Test that only one unique instance is allowed when add-on is running.""" - addon_options["device"] = "/test" - addon_options["network_key"] = "abc123" - entry = MockConfigEntry(domain=DOMAIN, data={}, title=TITLE, unique_id=1234) + addon_options["device"] = "/test_new" + addon_options["network_key"] = "def456" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "url": "ws://localhost:3000", + "usb_path": "/test", + "network_key": "abc123", + }, + title=TITLE, + unique_id=1234, + ) entry.add_to_hass(hass) await setup.async_setup_component(hass, "persistent_notification", {}) @@ -680,6 +658,9 @@ async def test_addon_running_already_configured( assert result["type"] == "abort" assert result["reason"] == "already_configured" + assert entry.data["url"] == "ws://host1:3001" + assert entry.data["usb_path"] == "/test_new" + assert entry.data["network_key"] == "def456" @pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) @@ -885,7 +866,16 @@ async def test_addon_installed_already_configured( get_addon_discovery_info, ): """Test that only one unique instance is allowed when add-on is installed.""" - entry = MockConfigEntry(domain=DOMAIN, data={}, title=TITLE, unique_id=1234) + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "url": "ws://localhost:3000", + "usb_path": "/test", + "network_key": "abc123", + }, + title=TITLE, + unique_id=1234, + ) entry.add_to_hass(hass) await setup.async_setup_component(hass, "persistent_notification", {}) @@ -904,7 +894,7 @@ async def test_addon_installed_already_configured( assert result["step_id"] == "configure_addon" result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"usb_path": "/test", "network_key": "abc123"} + result["flow_id"], {"usb_path": "/test_new", "network_key": "def456"} ) assert result["type"] == "progress" @@ -915,6 +905,9 @@ async def test_addon_installed_already_configured( assert result["type"] == "abort" assert result["reason"] == "already_configured" + assert entry.data["url"] == "ws://host1:3001" + assert entry.data["usb_path"] == "/test_new" + assert entry.data["network_key"] == "def456" @pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 6f60bbc0300..e56db58f3cc 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -160,7 +160,7 @@ async def test_unique_id_migration_dupes( # Check that new RegistryEntry is using new unique ID format entity_entry = ent_reg.async_get(AIR_TEMPERATURE_SENSOR) - new_unique_id = f"{client.driver.controller.home_id}.52-49-0-Air temperature-00-00" + new_unique_id = f"{client.driver.controller.home_id}.52-49-0-Air temperature" assert entity_entry.unique_id == new_unique_id assert ent_reg.async_get(f"{AIR_TEMPERATURE_SENSOR}_1") is None @@ -195,7 +195,7 @@ async def test_unique_id_migration_v1(hass, multisensor_6_state, client, integra # Check that new RegistryEntry is using new unique ID format entity_entry = ent_reg.async_get(AIR_TEMPERATURE_SENSOR) - new_unique_id = f"{client.driver.controller.home_id}.52-49-0-Air temperature-00-00" + new_unique_id = f"{client.driver.controller.home_id}.52-49-0-Air temperature" assert entity_entry.unique_id == new_unique_id @@ -228,7 +228,147 @@ async def test_unique_id_migration_v2(hass, multisensor_6_state, client, integra # Check that new RegistryEntry is using new unique ID format entity_entry = ent_reg.async_get(ILLUMINANCE_SENSOR) - new_unique_id = f"{client.driver.controller.home_id}.52-49-0-Illuminance-00-00" + new_unique_id = f"{client.driver.controller.home_id}.52-49-0-Illuminance" + assert entity_entry.unique_id == new_unique_id + + +async def test_unique_id_migration_v3(hass, multisensor_6_state, client, integration): + """Test unique ID is migrated from old format to new (version 3).""" + ent_reg = entity_registry.async_get(hass) + # Migrate version 2 + ILLUMINANCE_SENSOR = "sensor.multisensor_6_illuminance" + entity_name = ILLUMINANCE_SENSOR.split(".")[1] + + # Create entity RegistryEntry using old unique ID format + old_unique_id = f"{client.driver.controller.home_id}.52-49-0-Illuminance-00-00" + entity_entry = ent_reg.async_get_or_create( + "sensor", + DOMAIN, + old_unique_id, + suggested_object_id=entity_name, + config_entry=integration, + original_name=entity_name, + ) + assert entity_entry.entity_id == ILLUMINANCE_SENSOR + assert entity_entry.unique_id == old_unique_id + + # Add a ready node, unique ID should be migrated + node = Node(client, multisensor_6_state) + event = {"node": node} + + client.driver.controller.emit("node added", event) + await hass.async_block_till_done() + + # Check that new RegistryEntry is using new unique ID format + entity_entry = ent_reg.async_get(ILLUMINANCE_SENSOR) + new_unique_id = f"{client.driver.controller.home_id}.52-49-0-Illuminance" + assert entity_entry.unique_id == new_unique_id + + +async def test_unique_id_migration_property_key_v1( + hass, hank_binary_switch_state, client, integration +): + """Test unique ID with property key is migrated from old format to new (version 1).""" + ent_reg = entity_registry.async_get(hass) + + SENSOR_NAME = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed" + entity_name = SENSOR_NAME.split(".")[1] + + # Create entity RegistryEntry using old unique ID format + old_unique_id = f"{client.driver.controller.home_id}.32.32-50-00-value-W_Consumed" + entity_entry = ent_reg.async_get_or_create( + "sensor", + DOMAIN, + old_unique_id, + suggested_object_id=entity_name, + config_entry=integration, + original_name=entity_name, + ) + assert entity_entry.entity_id == SENSOR_NAME + assert entity_entry.unique_id == old_unique_id + + # Add a ready node, unique ID should be migrated + node = Node(client, hank_binary_switch_state) + event = {"node": node} + + client.driver.controller.emit("node added", event) + await hass.async_block_till_done() + + # Check that new RegistryEntry is using new unique ID format + entity_entry = ent_reg.async_get(SENSOR_NAME) + new_unique_id = f"{client.driver.controller.home_id}.32-50-0-value-66049" + assert entity_entry.unique_id == new_unique_id + + +async def test_unique_id_migration_property_key_v2( + hass, hank_binary_switch_state, client, integration +): + """Test unique ID with property key is migrated from old format to new (version 2).""" + ent_reg = entity_registry.async_get(hass) + + SENSOR_NAME = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed" + entity_name = SENSOR_NAME.split(".")[1] + + # Create entity RegistryEntry using old unique ID format + old_unique_id = ( + f"{client.driver.controller.home_id}.32.32-50-0-value-66049-W_Consumed" + ) + entity_entry = ent_reg.async_get_or_create( + "sensor", + DOMAIN, + old_unique_id, + suggested_object_id=entity_name, + config_entry=integration, + original_name=entity_name, + ) + assert entity_entry.entity_id == SENSOR_NAME + assert entity_entry.unique_id == old_unique_id + + # Add a ready node, unique ID should be migrated + node = Node(client, hank_binary_switch_state) + event = {"node": node} + + client.driver.controller.emit("node added", event) + await hass.async_block_till_done() + + # Check that new RegistryEntry is using new unique ID format + entity_entry = ent_reg.async_get(SENSOR_NAME) + new_unique_id = f"{client.driver.controller.home_id}.32-50-0-value-66049" + assert entity_entry.unique_id == new_unique_id + + +async def test_unique_id_migration_property_key_v3( + hass, hank_binary_switch_state, client, integration +): + """Test unique ID with property key is migrated from old format to new (version 3).""" + ent_reg = entity_registry.async_get(hass) + + SENSOR_NAME = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed" + entity_name = SENSOR_NAME.split(".")[1] + + # Create entity RegistryEntry using old unique ID format + old_unique_id = f"{client.driver.controller.home_id}.32-50-0-value-66049-W_Consumed" + entity_entry = ent_reg.async_get_or_create( + "sensor", + DOMAIN, + old_unique_id, + suggested_object_id=entity_name, + config_entry=integration, + original_name=entity_name, + ) + assert entity_entry.entity_id == SENSOR_NAME + assert entity_entry.unique_id == old_unique_id + + # Add a ready node, unique ID should be migrated + node = Node(client, hank_binary_switch_state) + event = {"node": node} + + client.driver.controller.emit("node added", event) + await hass.async_block_till_done() + + # Check that new RegistryEntry is using new unique ID format + entity_entry = ent_reg.async_get(SENSOR_NAME) + new_unique_id = f"{client.driver.controller.home_id}.32-50-0-value-66049" assert entity_entry.unique_id == new_unique_id @@ -262,7 +402,7 @@ async def test_unique_id_migration_notification_binary_sensor( # Check that new RegistryEntry is using new unique ID format entity_entry = ent_reg.async_get(NOTIFICATION_MOTION_BINARY_SENSOR) - new_unique_id = f"{client.driver.controller.home_id}.52-113-0-Home Security-Motion sensor status-Motion sensor status.8" + new_unique_id = f"{client.driver.controller.home_id}.52-113-0-Home Security-Motion sensor status.8" assert entity_entry.unique_id == new_unique_id @@ -445,11 +585,13 @@ async def test_addon_info_failure( @pytest.mark.parametrize( - "addon_version, update_available, update_calls, update_addon_side_effect", + "addon_version, update_available, update_calls, snapshot_calls, " + "update_addon_side_effect, create_shapshot_side_effect", [ - ("1.0", True, 1, None), - ("1.0", False, 0, None), - ("1.0", True, 1, HassioAPIError("Boom")), + ("1.0", True, 1, 1, None, None), + ("1.0", False, 0, 0, None, None), + ("1.0", True, 1, 1, HassioAPIError("Boom"), None), + ("1.0", True, 0, 1, None, HassioAPIError("Boom")), ], ) async def test_update_addon( @@ -464,11 +606,14 @@ async def test_update_addon( addon_version, update_available, update_calls, + snapshot_calls, update_addon_side_effect, + create_shapshot_side_effect, ): """Test update the Z-Wave JS add-on during entry setup.""" addon_info.return_value["version"] = addon_version addon_info.return_value["update_available"] = update_available + create_shapshot.side_effect = create_shapshot_side_effect update_addon.side_effect = update_addon_side_effect client.connect.side_effect = InvalidServerVersion("Invalid version") device = "/test" @@ -490,12 +635,7 @@ async def test_update_addon( await hass.async_block_till_done() assert entry.state == ENTRY_STATE_SETUP_RETRY - assert create_shapshot.call_count == 1 - assert create_shapshot.call_args == call( - hass, - {"name": f"addon_core_zwave_js_{addon_version}", "addons": ["core_zwave_js"]}, - partial=True, - ) + assert create_shapshot.call_count == snapshot_calls assert update_addon.call_count == update_calls diff --git a/tests/fixtures/zwave_js/climate_danfoss_lc_13_state.json b/tests/fixtures/zwave_js/climate_danfoss_lc_13_state.json index 90410998597..8574674714f 100644 --- a/tests/fixtures/zwave_js/climate_danfoss_lc_13_state.json +++ b/tests/fixtures/zwave_js/climate_danfoss_lc_13_state.json @@ -4,11 +4,25 @@ "status": 1, "ready": true, "deviceClass": { - "basic": {"key": 4, "label":"Routing Slave"}, - "generic": {"key": 8, "label":"Thermostat"}, - "specific": {"key": 4, "label":"Setpoint Thermostat"}, - "mandatorySupportedCCs": [], - "mandatoryControlCCs": [] + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 8, + "label": "Thermostat" + }, + "specific": { + "key": 4, + "label": "Setpoint Thermostat" + }, + "mandatorySupportedCCs": [ + 114, + 143, + 67, + 134 + ], + "mandatoryControlledCCs": [] }, "isListening": false, "isFrequentListening": false, @@ -22,6 +36,7 @@ "productType": 5, "firmwareVersion": "1.1", "deviceConfig": { + "filename": "/usr/src/app/node_modules/@zwave-js/config/config/devices/0x0002/lc-13.json", "manufacturerId": 2, "manufacturer": "Danfoss", "label": "LC-13", @@ -66,19 +81,76 @@ 14 ], "interviewAttempts": 1, + "interviewStage": 7, + "commandClasses": [ + { + "id": 67, + "name": "Thermostat Setpoint", + "version": 2, + "isSecure": false + }, + { + "id": 70, + "name": "Climate Control Schedule", + "version": 1, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 1, + "isSecure": false + }, + { + "id": 117, + "name": "Protection", + "version": 2, + "isSecure": false + }, + { + "id": 128, + "name": "Battery", + "version": 1, + "isSecure": false + }, + { + "id": 129, + "name": "Clock", + "version": 1, + "isSecure": false + }, + { + "id": 132, + "name": "Wake Up", + "version": 1, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 1, + "isSecure": false + }, + { + "id": 143, + "name": "Multi Command", + "version": 1, + "isSecure": false + } + ], "endpoints": [ { "nodeId": 5, "index": 0 } ], - "commandClasses": [], "values": [ { "endpoint": 0, "commandClass": 67, "commandClassName": "Thermostat Setpoint", "property": "setpoint", + "propertyKey": 1, "propertyName": "setpoint", "propertyKeyName": "Heating", "ccVersion": 2, @@ -91,7 +163,7 @@ "setpointType": 1 } }, - "value": 25 + "value": 14 }, { "endpoint": 0, @@ -262,7 +334,7 @@ "unit": "%", "label": "Battery level" }, - "value": 53 + "value": 49 }, { "endpoint": 0, @@ -361,4 +433,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/tests/fixtures/zwave_js/climate_heatit_z_trm3_state.json b/tests/fixtures/zwave_js/climate_heatit_z_trm3_state.json index 0dc040c6cb2..b26b69be9ad 100644 --- a/tests/fixtures/zwave_js/climate_heatit_z_trm3_state.json +++ b/tests/fixtures/zwave_js/climate_heatit_z_trm3_state.json @@ -837,6 +837,7 @@ "commandClassName": "Thermostat Setpoint", "property": "setpoint", "propertyName": "setpoint", + "propertyKey": 1, "propertyKeyName": "Heating", "ccVersion": 3, "metadata": { diff --git a/tests/fixtures/zwave_js/srt321_hrt4_zw_state.json b/tests/fixtures/zwave_js/srt321_hrt4_zw_state.json new file mode 100644 index 00000000000..a2fdaa99561 --- /dev/null +++ b/tests/fixtures/zwave_js/srt321_hrt4_zw_state.json @@ -0,0 +1,262 @@ +{ + "nodeId": 20, + "index": 0, + "status": 4, + "ready": true, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 8, + "label": "Thermostat" + }, + "specific": { + "key": 0, + "label": "Unused" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "isListening": true, + "isFrequentListening": false, + "isRouting": true, + "maxBaudRate": 40000, + "isSecure": false, + "version": 3, + "isBeaming": true, + "manufacturerId": 89, + "productId": 1, + "productType": 3, + "firmwareVersion": "2.0", + "name": "main_heat_actionner", + "location": "kitchen", + "deviceConfig": { + "filename": "/opt/node_modules/@zwave-js/config/config/devices/0x0059/asr-zw.json", + "manufacturerId": 89, + "manufacturer": "Secure Meters (UK) Ltd.", + "label": "SRT322", + "description": "Thermostat Receiver", + "devices": [ + { + "productType": "0x0003", + "productId": "0x0001" + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + } + }, + "label": "SRT322", + "neighbors": [ + 1, + 5, + 10, + 12, + 13, + 14, + 15, + 18, + 21 + ], + "interviewAttempts": 1, + "interviewStage": 7, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + }, + { + "id": 64, + "name": "Thermostat Mode", + "version": 1, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 1, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 1, + "isSecure": false + } + ], + "endpoints": [ + { + "nodeId": 20, + "index": 0 + } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value" + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value" + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 64, + "commandClassName": "Thermostat Mode", + "property": "mode", + "propertyName": "mode", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "min": 0, + "max": 255, + "states": { + "0": "Off", + "1": "Heat" + }, + "label": "Thermostat mode" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 64, + "commandClassName": "Thermostat Mode", + "property": "manufacturerData", + "propertyName": "manufacturerData", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": true + } + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 65535, + "label": "Manufacturer ID" + }, + "value": 89 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 65535, + "label": "Product type" + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 65535, + "label": "Product ID" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Library type" + }, + "value": 6 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version" + }, + "value": "2.78" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions" + }, + "value": [ + "2.0" + ] + } + ] + } diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 5074b6e70c4..a041dc7fc7f 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -1,5 +1,4 @@ """Test the condition helper.""" -from logging import WARNING from unittest.mock import patch import pytest @@ -363,27 +362,6 @@ async def test_time_using_input_datetime(hass): condition.time(hass, before="input_datetime.not_existing") -async def test_if_numeric_state_raises_on_unavailable(hass, caplog): - """Test numeric_state raises on unavailable/unknown state.""" - test = await condition.async_from_config( - hass, - {"condition": "numeric_state", "entity_id": "sensor.temperature", "below": 42}, - ) - - caplog.clear() - caplog.set_level(WARNING) - - hass.states.async_set("sensor.temperature", "unavailable") - with pytest.raises(ConditionError): - test(hass) - assert len(caplog.record_tuples) == 0 - - hass.states.async_set("sensor.temperature", "unknown") - with pytest.raises(ConditionError): - test(hass) - assert len(caplog.record_tuples) == 0 - - async def test_state_raises(hass): """Test that state raises ConditionError on errors.""" # No entity @@ -631,6 +609,26 @@ async def test_state_using_input_entities(hass): assert test(hass) +async def test_numeric_state_known_non_matching(hass): + """Test that numeric_state doesn't match on known non-matching states.""" + hass.states.async_set("sensor.temperature", "unavailable") + test = await condition.async_from_config( + hass, + { + "condition": "numeric_state", + "entity_id": "sensor.temperature", + "above": 0, + }, + ) + + # Unavailable state + assert not test(hass) + + # Unknown state + hass.states.async_set("sensor.temperature", "unknown") + assert not test(hass) + + async def test_numeric_state_raises(hass): """Test that numeric_state raises ConditionError on errors.""" # Unknown entities @@ -677,20 +675,6 @@ async def test_numeric_state_raises(hass): hass.states.async_set("sensor.temperature", 50) test(hass) - # Unavailable state - with pytest.raises(ConditionError, match="state of .* is unavailable"): - test = await condition.async_from_config( - hass, - { - "condition": "numeric_state", - "entity_id": "sensor.temperature", - "above": 0, - }, - ) - - hass.states.async_set("sensor.temperature", "unavailable") - test(hass) - # Bad number with pytest.raises(ConditionError, match="cannot be processed as a number"): test = await condition.async_from_config( @@ -852,6 +836,12 @@ async def test_numeric_state_using_input_number(hass): hass.states.async_set("sensor.temperature", 100) assert not test(hass) + hass.states.async_set("input_number.high", "unknown") + assert not test(hass) + + hass.states.async_set("input_number.high", "unavailable") + assert not test(hass) + await hass.services.async_call( "input_number", "set_value", @@ -863,6 +853,12 @@ async def test_numeric_state_using_input_number(hass): ) assert test(hass) + hass.states.async_set("input_number.low", "unknown") + assert not test(hass) + + hass.states.async_set("input_number.low", "unavailable") + assert not test(hass) + with pytest.raises(ConditionError): condition.async_numeric_state( hass, entity="sensor.temperature", below="input_number.not_exist"