Merge pull request #47422 from home-assistant/rc

This commit is contained in:
Paulus Schoutsen 2021-03-04 17:17:29 -08:00 committed by GitHub
commit 91ac4554a2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 919 additions and 434 deletions

View File

@ -39,6 +39,7 @@ from .const import (
DATA_COORDINATOR,
DOMAIN,
INTEGRATION_TYPE_GEOGRAPHY_COORDS,
INTEGRATION_TYPE_GEOGRAPHY_NAME,
INTEGRATION_TYPE_NODE_PRO,
LOGGER,
)
@ -142,12 +143,21 @@ def _standardize_geography_config_entry(hass, config_entry):
if not config_entry.options:
# If the config entry doesn't already have any options set, set defaults:
entry_updates["options"] = {CONF_SHOW_ON_MAP: True}
if CONF_INTEGRATION_TYPE not in config_entry.data:
# If the config entry data doesn't contain the integration type, add it:
entry_updates["data"] = {
**config_entry.data,
CONF_INTEGRATION_TYPE: INTEGRATION_TYPE_GEOGRAPHY_COORDS,
}
if config_entry.data.get(CONF_INTEGRATION_TYPE) not in [
INTEGRATION_TYPE_GEOGRAPHY_COORDS,
INTEGRATION_TYPE_GEOGRAPHY_NAME,
]:
# If the config entry data doesn't contain an integration type that we know
# about, infer it from the data we have:
entry_updates["data"] = {**config_entry.data}
if CONF_CITY in config_entry.data:
entry_updates["data"][
CONF_INTEGRATION_TYPE
] = INTEGRATION_TYPE_GEOGRAPHY_NAME
else:
entry_updates["data"][
CONF_INTEGRATION_TYPE
] = INTEGRATION_TYPE_GEOGRAPHY_COORDS
if not entry_updates:
return

View File

@ -22,7 +22,6 @@ from homeassistant.const import (
LENGTH_MILES,
PRESSURE_HPA,
PRESSURE_INHG,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
)
from homeassistant.helpers.entity import Entity
@ -31,7 +30,6 @@ from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.util import dt as dt_util
from homeassistant.util.distance import convert as distance_convert
from homeassistant.util.pressure import convert as pressure_convert
from homeassistant.util.temperature import convert as temp_convert
from . import ClimaCellDataUpdateCoordinator, ClimaCellEntity
from .const import (
@ -102,10 +100,6 @@ def _forecast_dict(
precipitation = (
distance_convert(precipitation / 12, LENGTH_FEET, LENGTH_METERS) * 1000
)
if temp:
temp = temp_convert(temp, TEMP_FAHRENHEIT, TEMP_CELSIUS)
if temp_low:
temp_low = temp_convert(temp_low, TEMP_FAHRENHEIT, TEMP_CELSIUS)
if wind_speed:
wind_speed = distance_convert(wind_speed, LENGTH_MILES, LENGTH_KILOMETERS)
@ -260,6 +254,7 @@ class ClimaCellWeatherEntity(ClimaCellEntity, WeatherEntity):
if self.forecast_type == DAILY:
use_datetime = False
forecast_dt = dt_util.start_of_local_day(forecast_dt)
precipitation = self._get_cc_value(
forecast, CC_ATTR_PRECIPITATION_DAILY
)

View File

@ -77,6 +77,7 @@ _NOT_SPEED_INTERVAL = "interval"
_NOT_SPEED_IDLE = "idle"
_NOT_SPEED_FAVORITE = "favorite"
_NOT_SPEED_SLEEP = "sleep"
_NOT_SPEED_SILENT = "silent"
_NOT_SPEEDS_FILTER = {
_NOT_SPEED_OFF,
@ -85,6 +86,7 @@ _NOT_SPEEDS_FILTER = {
_NOT_SPEED_SMART,
_NOT_SPEED_INTERVAL,
_NOT_SPEED_IDLE,
_NOT_SPEED_SILENT,
_NOT_SPEED_SLEEP,
_NOT_SPEED_FAVORITE,
}
@ -652,7 +654,7 @@ def speed_list_without_preset_modes(speed_list: List):
output: ["1", "2", "3", "4", "5", "6", "7"]
input: ["Auto", "Silent", "Favorite", "Idle", "Medium", "High", "Strong"]
output: ["Silent", "Medium", "High", "Strong"]
output: ["Medium", "High", "Strong"]
"""
return [speed for speed in speed_list if speed.lower() not in _NOT_SPEEDS_FILTER]
@ -674,7 +676,7 @@ def preset_modes_from_speed_list(speed_list: List):
output: ["smart"]
input: ["Auto", "Silent", "Favorite", "Idle", "Medium", "High", "Strong"]
output: ["Auto", "Favorite", "Idle"]
output: ["Auto", "Silent", "Favorite", "Idle"]
"""
return [

View File

@ -3,7 +3,7 @@
"name": "Home Assistant Frontend",
"documentation": "https://www.home-assistant.io/integrations/frontend",
"requirements": [
"home-assistant-frontend==20210302.3"
"home-assistant-frontend==20210302.4"
],
"dependencies": [
"api",

View File

@ -35,6 +35,7 @@ from homeassistant.const import (
STATE_UNKNOWN,
)
from homeassistant.core import DOMAIN as HA_DOMAIN, CoreState, callback
from homeassistant.exceptions import ConditionError
from homeassistant.helpers import condition
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.event import (
@ -439,12 +440,16 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
current_state = STATE_ON
else:
current_state = HVAC_MODE_OFF
try:
long_enough = condition.state(
self.hass,
self.heater_entity_id,
current_state,
self.min_cycle_duration,
)
except ConditionError:
long_enough = False
if not long_enough:
return

View File

@ -203,21 +203,24 @@ async def async_setup(hass, config):
# TCP port when host configured, otherwise serial port
port = config[DOMAIN][CONF_PORT]
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), "
"default value will be used. "
"it will be disabled. "
"Recommended values: 60-3600 (seconds)",
keepalive_idle_timer,
)
keepalive_idle_timer = DEFAULT_TCP_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), "
"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,

View File

@ -1,4 +1,5 @@
"""Support for WeMo device discovery."""
import asyncio
import logging
import pywemo
@ -15,14 +16,9 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_call_later
from homeassistant.util.async_ import gather_with_concurrency
from .const import DOMAIN
# Max number of devices to initialize at once. This limit is in place to
# avoid tying up too many executor threads with WeMo device setup.
MAX_CONCURRENCY = 3
# Mapping from Wemo model_name to domain.
WEMO_MODEL_DISPATCH = {
"Bridge": LIGHT_DOMAIN,
@ -118,12 +114,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
static_conf = config.get(CONF_STATIC, [])
if static_conf:
_LOGGER.debug("Adding statically configured WeMo devices...")
for device in await gather_with_concurrency(
MAX_CONCURRENCY,
for device in await asyncio.gather(
*[
hass.async_add_executor_job(validate_static_config, host, port)
for host, port in static_conf
],
]
):
if device:
wemo_dispatcher.async_add_unique_device(hass, device)
@ -192,44 +187,15 @@ class WemoDiscovery:
self._wemo_dispatcher = wemo_dispatcher
self._stop = None
self._scan_delay = 0
self._upnp_entries = set()
async def async_add_from_upnp_entry(self, entry: pywemo.ssdp.UPNPEntry) -> None:
"""Create a WeMoDevice from an UPNPEntry and add it to the dispatcher.
Uses the self._upnp_entries set to avoid interrogating the same device
multiple times.
"""
if entry in self._upnp_entries:
return
try:
device = await self._hass.async_add_executor_job(
pywemo.discovery.device_from_uuid_and_location,
entry.udn,
entry.location,
)
except pywemo.PyWeMoException as err:
_LOGGER.error("Unable to setup WeMo %r (%s)", entry, err)
else:
self._wemo_dispatcher.async_add_unique_device(self._hass, device)
self._upnp_entries.add(entry)
async def async_discover_and_schedule(self, *_) -> None:
"""Periodically scan the network looking for WeMo devices."""
_LOGGER.debug("Scanning network for WeMo devices...")
try:
# pywemo.ssdp.scan is a light-weight UDP UPnP scan for WeMo devices.
entries = await self._hass.async_add_executor_job(pywemo.ssdp.scan)
# async_add_from_upnp_entry causes multiple HTTP requests to be sent
# to the WeMo device for the initial setup of the WeMoDevice
# instance. This may take some time to complete. The per-device
# setup work is done in parallel to speed up initial setup for the
# component.
await gather_with_concurrency(
MAX_CONCURRENCY,
*[self.async_add_from_upnp_entry(entry) for entry in entries],
)
for device in await self._hass.async_add_executor_job(
pywemo.discover_devices
):
self._wemo_dispatcher.async_add_unique_device(self._hass, device)
finally:
# Run discovery more frequently after hass has just started.
self._scan_delay = min(

View File

@ -23,7 +23,7 @@ from .gateway import ConnectXiaomiGateway
_LOGGER = logging.getLogger(__name__)
GATEWAY_PLATFORMS = ["alarm_control_panel", "sensor", "light"]
GATEWAY_PLATFORMS = ["alarm_control_panel", "sensor", "switch", "light"]
SWITCH_PLATFORMS = ["switch"]
VACUUM_PLATFORMS = ["vacuum"]

View File

@ -21,9 +21,8 @@ MODELS_SWITCH = [
"chuangmi.plug.v2",
"chuangmi.plug.hmi205",
"chuangmi.plug.hmi206",
"lumi.acpartner.v3",
]
MODELS_VACUUM = ["roborock.vacuum"]
MODELS_VACUUM = ["roborock.vacuum", "rockrobo.vacuum"]
MODELS_ALL_DEVICES = MODELS_SWITCH + MODELS_VACUUM
MODELS_ALL = MODELS_ALL_DEVICES + MODELS_GATEWAY

View File

@ -22,7 +22,6 @@ from homeassistant.const import (
DEVICE_CLASS_ILLUMINANCE,
DEVICE_CLASS_PRESSURE,
DEVICE_CLASS_TEMPERATURE,
LIGHT_LUX,
PERCENTAGE,
PRESSURE_HPA,
TEMP_CELSIUS,
@ -38,6 +37,7 @@ _LOGGER = logging.getLogger(__name__)
DEFAULT_NAME = "Xiaomi Miio Sensor"
DATA_KEY = "sensor.xiaomi_miio"
UNIT_LUMEN = "lm"
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
@ -302,7 +302,7 @@ class XiaomiGatewayIlluminanceSensor(Entity):
@property
def unit_of_measurement(self):
"""Return the unit of measurement of this entity."""
return LIGHT_LUX
return UNIT_LUMEN
@property
def device_class(self):

View File

@ -22,6 +22,7 @@ import homeassistant.helpers.config_validation as cv
from .const import (
CONF_DEVICE,
CONF_FLOW_TYPE,
CONF_GATEWAY,
CONF_MODEL,
DOMAIN,
SERVICE_SET_POWER_MODE,
@ -129,16 +130,19 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the switch from a config entry."""
entities = []
if config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE:
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
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] = {}
_LOGGER.debug("Initializing with host %s (token %s...)", host, token[:5])
if model in ["chuangmi.plug.v1", "chuangmi.plug.v3", "chuangmi.plug.hmi208"]:

View File

@ -48,7 +48,8 @@ from .const import (
ZWAVE_JS_EVENT,
)
from .discovery import async_discover_values
from .helpers import get_device_id, get_old_value_id, get_unique_id
from .helpers import get_device_id
from .migrate import async_migrate_discovered_value
from .services import ZWaveServices
CONNECT_TIMEOUT = 10
@ -98,31 +99,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
dev_reg = await device_registry.async_get_registry(hass)
ent_reg = entity_registry.async_get(hass)
@callback
def migrate_entity(platform: str, old_unique_id: str, new_unique_id: str) -> None:
"""Check if entity with old unique ID exists, and if so migrate it to new ID."""
if entity_id := ent_reg.async_get_entity_id(platform, DOMAIN, old_unique_id):
LOGGER.debug(
"Migrating entity %s from old unique ID '%s' to new unique ID '%s'",
entity_id,
old_unique_id,
new_unique_id,
)
try:
ent_reg.async_update_entity(
entity_id,
new_unique_id=new_unique_id,
)
except ValueError:
LOGGER.debug(
(
"Entity %s can't be migrated because the unique ID is taken. "
"Cleaning it up since it is likely no longer valid."
),
entity_id,
)
ent_reg.async_remove(entity_id)
@callback
def async_on_node_ready(node: ZwaveNode) -> None:
"""Handle node ready event."""
@ -136,49 +112,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
LOGGER.debug("Discovered entity: %s", disc_info)
# This migration logic was added in 2021.3 to handle a breaking change to
# the value_id format. Some time in the future, this code block
# (as well as get_old_value_id helper and migrate_entity closure) can be
# removed.
value_ids = [
# 2021.2.* format
get_old_value_id(disc_info.primary_value),
# 2021.3.0b0 format
disc_info.primary_value.value_id,
]
new_unique_id = get_unique_id(
client.driver.controller.home_id,
disc_info.primary_value.value_id,
)
for value_id in value_ids:
old_unique_id = get_unique_id(
client.driver.controller.home_id,
f"{disc_info.primary_value.node.node_id}.{value_id}",
)
# Most entities have the same ID format, but notification binary sensors
# have a state key in their ID so we need to handle them differently
if (
disc_info.platform == "binary_sensor"
and disc_info.platform_hint == "notification"
):
for state_key in disc_info.primary_value.metadata.states:
# ignore idle key (0)
if state_key == "0":
continue
migrate_entity(
disc_info.platform,
f"{old_unique_id}.{state_key}",
f"{new_unique_id}.{state_key}",
)
# Once we've iterated through all state keys, we can move on to the
# next item
continue
migrate_entity(disc_info.platform, old_unique_id, new_unique_id)
# the value_id format. Some time in the future, this call (as well as the
# helper functions) can be removed.
async_migrate_discovered_value(ent_reg, client, disc_info)
async_dispatcher_send(
hass, f"{DOMAIN}_{entry.entry_id}_add_{disc_info.platform}", disc_info
)
@ -483,11 +419,15 @@ async def async_ensure_addon_running(hass: HomeAssistant, entry: ConfigEntry) ->
network_key: str = entry.data[CONF_NETWORK_KEY]
if not addon_is_installed:
addon_manager.async_schedule_install_addon(usb_path, network_key)
addon_manager.async_schedule_install_setup_addon(
usb_path, network_key, catch_error=True
)
raise ConfigEntryNotReady
if not addon_is_running:
addon_manager.async_schedule_setup_addon(usb_path, network_key)
addon_manager.async_schedule_setup_addon(
usb_path, network_key, catch_error=True
)
raise ConfigEntryNotReady
@ -497,4 +437,4 @@ def async_ensure_addon_updated(hass: HomeAssistant) -> None:
addon_manager: AddonManager = get_addon_manager(hass)
if addon_manager.task_in_progress():
raise ConfigEntryNotReady
addon_manager.async_schedule_update_addon()
addon_manager.async_schedule_update_addon(catch_error=True)

View File

@ -67,8 +67,8 @@ class AddonManager:
"""Set up the add-on manager."""
self._hass = hass
self._install_task: Optional[asyncio.Task] = None
self._start_task: Optional[asyncio.Task] = None
self._update_task: Optional[asyncio.Task] = None
self._setup_task: Optional[asyncio.Task] = None
def task_in_progress(self) -> bool:
"""Return True if any of the add-on tasks are in progress."""
@ -76,7 +76,7 @@ class AddonManager:
task and not task.done()
for task in (
self._install_task,
self._setup_task,
self._start_task,
self._update_task,
)
)
@ -125,8 +125,21 @@ class AddonManager:
await async_install_addon(self._hass, ADDON_SLUG)
@callback
def async_schedule_install_addon(
self, usb_path: str, network_key: str
def async_schedule_install_addon(self, catch_error: bool = False) -> asyncio.Task:
"""Schedule a task that installs the Z-Wave JS add-on.
Only schedule a new install task if the there's no running task.
"""
if not self._install_task or self._install_task.done():
LOGGER.info("Z-Wave JS add-on is not installed. Installing add-on")
self._install_task = self._async_schedule_addon_operation(
self.async_install_addon, catch_error=catch_error
)
return self._install_task
@callback
def async_schedule_install_setup_addon(
self, usb_path: str, network_key: str, catch_error: bool = False
) -> asyncio.Task:
"""Schedule a task that installs and sets up the Z-Wave JS add-on.
@ -136,7 +149,9 @@ class AddonManager:
LOGGER.info("Z-Wave JS add-on is not installed. Installing add-on")
self._install_task = self._async_schedule_addon_operation(
self.async_install_addon,
partial(self.async_setup_addon, usb_path, network_key),
partial(self.async_configure_addon, usb_path, network_key),
self.async_start_addon,
catch_error=catch_error,
)
return self._install_task
@ -158,10 +173,11 @@ class AddonManager:
if not update_available:
return
await self.async_create_snapshot()
await async_update_addon(self._hass, ADDON_SLUG)
@callback
def async_schedule_update_addon(self) -> asyncio.Task:
def async_schedule_update_addon(self, catch_error: bool = False) -> asyncio.Task:
"""Schedule a task that updates and sets up the Z-Wave JS add-on.
Only schedule a new update task if the there's no running task.
@ -169,7 +185,8 @@ class AddonManager:
if not self._update_task or self._update_task.done():
LOGGER.info("Trying to update the Z-Wave JS add-on")
self._update_task = self._async_schedule_addon_operation(
self.async_create_snapshot, self.async_update_addon
self.async_update_addon,
catch_error=catch_error,
)
return self._update_task
@ -178,12 +195,25 @@ class AddonManager:
"""Start the Z-Wave JS add-on."""
await async_start_addon(self._hass, ADDON_SLUG)
@callback
def async_schedule_start_addon(self, catch_error: bool = False) -> asyncio.Task:
"""Schedule a task that starts the Z-Wave JS add-on.
Only schedule a new start task if the there's no running task.
"""
if not self._start_task or self._start_task.done():
LOGGER.info("Z-Wave JS add-on is not running. Starting add-on")
self._start_task = self._async_schedule_addon_operation(
self.async_start_addon, catch_error=catch_error
)
return self._start_task
@api_error("Failed to stop the Z-Wave JS add-on")
async def async_stop_addon(self) -> None:
"""Stop the Z-Wave JS add-on."""
await async_stop_addon(self._hass, ADDON_SLUG)
async def async_setup_addon(self, usb_path: str, network_key: str) -> None:
async def async_configure_addon(self, usb_path: str, network_key: str) -> None:
"""Configure and start Z-Wave JS add-on."""
addon_options = await self.async_get_addon_options()
@ -195,22 +225,22 @@ class AddonManager:
if new_addon_options != addon_options:
await self.async_set_addon_options(new_addon_options)
await self.async_start_addon()
@callback
def async_schedule_setup_addon(
self, usb_path: str, network_key: str
self, usb_path: str, network_key: str, catch_error: bool = False
) -> asyncio.Task:
"""Schedule a task that configures and starts the Z-Wave JS add-on.
Only schedule a new setup task if the there's no running task.
"""
if not self._setup_task or self._setup_task.done():
if not self._start_task or self._start_task.done():
LOGGER.info("Z-Wave JS add-on is not running. Starting add-on")
self._setup_task = self._async_schedule_addon_operation(
partial(self.async_setup_addon, usb_path, network_key)
self._start_task = self._async_schedule_addon_operation(
partial(self.async_configure_addon, usb_path, network_key),
self.async_start_addon,
catch_error=catch_error,
)
return self._setup_task
return self._start_task
@api_error("Failed to create a snapshot of the Z-Wave JS add-on.")
async def async_create_snapshot(self) -> None:
@ -227,7 +257,9 @@ class AddonManager:
)
@callback
def _async_schedule_addon_operation(self, *funcs: Callable) -> asyncio.Task:
def _async_schedule_addon_operation(
self, *funcs: Callable, catch_error: bool = False
) -> asyncio.Task:
"""Schedule an add-on task."""
async def addon_operation() -> None:
@ -236,6 +268,8 @@ class AddonManager:
try:
await func()
except AddonError as err:
if not catch_error:
raise
LOGGER.error(err)
break

View File

@ -118,25 +118,17 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity):
super().__init__(config_entry, client, info)
self._hvac_modes: Dict[str, Optional[int]] = {}
self._hvac_presets: Dict[str, Optional[int]] = {}
self._unit_value: ZwaveValue = None
self._unit_value: Optional[ZwaveValue] = None
self._current_mode = self.get_zwave_value(
THERMOSTAT_MODE_PROPERTY, command_class=CommandClass.THERMOSTAT_MODE
)
self._setpoint_values: Dict[ThermostatSetpointType, ZwaveValue] = {}
for enum in ThermostatSetpointType:
# Some devices don't include a property key so we need to check for value
# ID's, both with and without the property key
self._setpoint_values[enum] = self.get_zwave_value(
THERMOSTAT_SETPOINT_PROPERTY,
command_class=CommandClass.THERMOSTAT_SETPOINT,
value_property_key=enum.value.key,
value_property_key_name=enum.value.name,
add_to_watched_value_ids=True,
) or self.get_zwave_value(
THERMOSTAT_SETPOINT_PROPERTY,
command_class=CommandClass.THERMOSTAT_SETPOINT,
value_property_key_name=enum.value.name,
add_to_watched_value_ids=True,
)
# Use the first found setpoint value to always determine the temperature unit
@ -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

View File

@ -117,7 +117,15 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(
version_info.home_id, raise_on_progress=False
)
self._abort_if_unique_id_configured(user_input)
# Make sure we disable any add-on handling
# if the controller is reconfigured in a manual step.
self._abort_if_unique_id_configured(
updates={
**user_input,
CONF_USE_ADDON: False,
CONF_INTEGRATION_CREATED_ADDON: False,
}
)
self.ws_address = user_input[CONF_URL]
return self._async_create_entry_from_vars()
@ -172,11 +180,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self, user_input: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""Handle logic when on Supervisor host."""
# Only one entry with Supervisor add-on support is allowed.
for entry in self.hass.config_entries.async_entries(DOMAIN):
if entry.data.get(CONF_USE_ADDON):
return await self.async_step_manual()
if user_input is None:
return self.async_show_form(
step_id="on_supervisor", data_schema=ON_SUPERVISOR_SCHEMA
@ -289,7 +292,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
assert self.hass
addon_manager: AddonManager = get_addon_manager(self.hass)
try:
await addon_manager.async_start_addon()
await addon_manager.async_schedule_start_addon()
# Sleep some seconds to let the add-on start properly before connecting.
for _ in range(ADDON_SETUP_TIMEOUT_ROUNDS):
await asyncio.sleep(ADDON_SETUP_TIMEOUT)
@ -338,7 +341,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
version_info.home_id, raise_on_progress=False
)
self._abort_if_unique_id_configured()
self._abort_if_unique_id_configured(
updates={
CONF_URL: self.ws_address,
CONF_USB_PATH: self.usb_path,
CONF_NETWORK_KEY: self.network_key,
}
)
return self._async_create_entry_from_vars()
async def _async_get_addon_info(self) -> dict:
@ -381,7 +390,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Install the Z-Wave JS add-on."""
addon_manager: AddonManager = get_addon_manager(self.hass)
try:
await addon_manager.async_install_addon()
await addon_manager.async_schedule_install_addon()
finally:
# Continue the flow after show progress when the task is done.
self.hass.async_create_task(

View File

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

View File

@ -3,7 +3,6 @@ from typing import List, Tuple, cast
from zwave_js_server.client import Client as ZwaveClient
from zwave_js_server.model.node import Node as ZwaveNode
from zwave_js_server.model.value import Value as ZwaveValue
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
@ -13,16 +12,6 @@ from homeassistant.helpers.entity_registry import async_get as async_get_ent_reg
from .const import DATA_CLIENT, DOMAIN
@callback
def get_old_value_id(value: ZwaveValue) -> str:
"""Get old value ID so we can migrate entity unique ID."""
command_class = value.command_class
endpoint = value.endpoint or "00"
property_ = value.property_
property_key_name = value.property_key_name or "00"
return f"{value.node.node_id}-{command_class}-{endpoint}-{property_}-{property_key_name}"
@callback
def get_unique_id(home_id: str, value_id: str) -> str:
"""Get unique ID from home ID and value ID."""

View File

@ -228,7 +228,6 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
"targetColor",
CommandClass.SWITCH_COLOR,
value_property_key=None,
value_property_key_name=None,
)
if combined_color_val and isinstance(combined_color_val.value, dict):
colors_dict = {}
@ -252,7 +251,6 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
"targetColor",
CommandClass.SWITCH_COLOR,
value_property_key=property_key.key,
value_property_key_name=property_key.name,
)
if target_zwave_value is None:
# guard for unsupported color
@ -318,31 +316,26 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
"currentColor",
CommandClass.SWITCH_COLOR,
value_property_key=ColorComponent.RED.value.key,
value_property_key_name=ColorComponent.RED.value.name,
)
green_val = self.get_zwave_value(
"currentColor",
CommandClass.SWITCH_COLOR,
value_property_key=ColorComponent.GREEN.value.key,
value_property_key_name=ColorComponent.GREEN.value.name,
)
blue_val = self.get_zwave_value(
"currentColor",
CommandClass.SWITCH_COLOR,
value_property_key=ColorComponent.BLUE.value.key,
value_property_key_name=ColorComponent.BLUE.value.name,
)
ww_val = self.get_zwave_value(
"currentColor",
CommandClass.SWITCH_COLOR,
value_property_key=ColorComponent.WARM_WHITE.value.key,
value_property_key_name=ColorComponent.WARM_WHITE.value.name,
)
cw_val = self.get_zwave_value(
"currentColor",
CommandClass.SWITCH_COLOR,
value_property_key=ColorComponent.COLD_WHITE.value.key,
value_property_key_name=ColorComponent.COLD_WHITE.value.name,
)
# prefer the (new) combined color property
# https://github.com/zwave-js/node-zwave-js/pull/1782
@ -350,7 +343,6 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
"currentColor",
CommandClass.SWITCH_COLOR,
value_property_key=None,
value_property_key_name=None,
)
if combined_color_val and isinstance(combined_color_val.value, dict):
multi_color = combined_color_val.value

View File

@ -3,7 +3,7 @@
"name": "Z-Wave JS",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/zwave_js",
"requirements": ["zwave-js-server-python==0.20.1"],
"requirements": ["zwave-js-server-python==0.21.0"],
"codeowners": ["@home-assistant/z-wave"],
"dependencies": ["http", "websocket_api"]
}

View File

@ -0,0 +1,113 @@
"""Functions used to migrate unique IDs for Z-Wave JS entities."""
import logging
from typing import List
from zwave_js_server.client import Client as ZwaveClient
from zwave_js_server.model.value import Value as ZwaveValue
from homeassistant.core import callback
from homeassistant.helpers.entity_registry import EntityRegistry
from .const import DOMAIN
from .discovery import ZwaveDiscoveryInfo
from .helpers import get_unique_id
_LOGGER = logging.getLogger(__name__)
@callback
def async_migrate_entity(
ent_reg: EntityRegistry, platform: str, old_unique_id: str, new_unique_id: str
) -> None:
"""Check if entity with old unique ID exists, and if so migrate it to new ID."""
if entity_id := ent_reg.async_get_entity_id(platform, DOMAIN, old_unique_id):
_LOGGER.debug(
"Migrating entity %s from old unique ID '%s' to new unique ID '%s'",
entity_id,
old_unique_id,
new_unique_id,
)
try:
ent_reg.async_update_entity(
entity_id,
new_unique_id=new_unique_id,
)
except ValueError:
_LOGGER.debug(
(
"Entity %s can't be migrated because the unique ID is taken. "
"Cleaning it up since it is likely no longer valid."
),
entity_id,
)
ent_reg.async_remove(entity_id)
@callback
def async_migrate_discovered_value(
ent_reg: EntityRegistry, client: ZwaveClient, disc_info: ZwaveDiscoveryInfo
) -> None:
"""Migrate unique ID for entity/entities tied to discovered value."""
new_unique_id = get_unique_id(
client.driver.controller.home_id,
disc_info.primary_value.value_id,
)
# 2021.2.*, 2021.3.0b0, and 2021.3.0 formats
for value_id in get_old_value_ids(disc_info.primary_value):
old_unique_id = get_unique_id(
client.driver.controller.home_id,
value_id,
)
# Most entities have the same ID format, but notification binary sensors
# have a state key in their ID so we need to handle them differently
if (
disc_info.platform == "binary_sensor"
and disc_info.platform_hint == "notification"
):
for state_key in disc_info.primary_value.metadata.states:
# ignore idle key (0)
if state_key == "0":
continue
async_migrate_entity(
ent_reg,
disc_info.platform,
f"{old_unique_id}.{state_key}",
f"{new_unique_id}.{state_key}",
)
# Once we've iterated through all state keys, we can move on to the
# next item
continue
async_migrate_entity(ent_reg, disc_info.platform, old_unique_id, new_unique_id)
@callback
def get_old_value_ids(value: ZwaveValue) -> List[str]:
"""Get old value IDs so we can migrate entity unique ID."""
value_ids = []
# Pre 2021.3.0 value ID
command_class = value.command_class
endpoint = value.endpoint or "00"
property_ = value.property_
property_key_name = value.property_key_name or "00"
value_ids.append(
f"{value.node.node_id}.{value.node.node_id}-{command_class}-{endpoint}-"
f"{property_}-{property_key_name}"
)
endpoint = "00" if value.endpoint is None else value.endpoint
property_key = "00" if value.property_key is None else value.property_key
property_key_name = value.property_key_name or "00"
value_id = (
f"{value.node.node_id}-{command_class}-{endpoint}-"
f"{property_}-{property_key}-{property_key_name}"
)
# 2021.3.0b0 and 2021.3.0 value IDs
value_ids.extend([f"{value.node.node_id}.{value_id}", value_id])
return value_ids

View File

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

View File

@ -265,10 +265,9 @@ def async_numeric_state(
"numeric_state", f"template error: {ex}"
) from ex
# Known states that never match the numeric condition
if value in (STATE_UNAVAILABLE, STATE_UNKNOWN):
raise ConditionErrorMessage(
"numeric_state", f"state of {entity_id} is unavailable"
)
return False
try:
fvalue = float(value)
@ -281,13 +280,15 @@ def async_numeric_state(
if below is not None:
if isinstance(below, str):
below_entity = hass.states.get(below)
if not below_entity or below_entity.state in (
if not below_entity:
raise ConditionErrorMessage(
"numeric_state", f"unknown 'below' entity {below}"
)
if below_entity.state in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
):
raise ConditionErrorMessage(
"numeric_state", f"the 'below' entity {below} is unavailable"
)
return False
try:
if fvalue >= float(below_entity.state):
return False
@ -302,13 +303,15 @@ def async_numeric_state(
if above is not None:
if isinstance(above, str):
above_entity = hass.states.get(above)
if not above_entity or above_entity.state in (
if not above_entity:
raise ConditionErrorMessage(
"numeric_state", f"unknown 'above' entity {above}"
)
if above_entity.state in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
):
raise ConditionErrorMessage(
"numeric_state", f"the 'above' entity {above} is unavailable"
)
return False
try:
if fvalue <= float(above_entity.state):
return False

View File

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

View File

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

View File

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

View File

@ -10,12 +10,7 @@ import homeassistant.components.automation as automation
from homeassistant.components.homeassistant.triggers import (
numeric_state as numeric_state_trigger,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
ENTITY_MATCH_ALL,
SERVICE_TURN_OFF,
STATE_UNAVAILABLE,
)
from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, SERVICE_TURN_OFF
from homeassistant.core import Context
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
@ -347,52 +342,6 @@ async def test_if_fires_on_entity_unavailable_at_startup(hass, calls):
assert len(calls) == 0
async def test_if_not_fires_on_entity_unavailable(hass, calls):
"""Test the firing with entity changing to unavailable."""
# set initial state
hass.states.async_set("test.entity", 9)
await hass.async_block_till_done()
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
"platform": "numeric_state",
"entity_id": "test.entity",
"above": 10,
},
"action": {"service": "test.automation"},
}
},
)
# 11 is above 10
hass.states.async_set("test.entity", 11)
await hass.async_block_till_done()
assert len(calls) == 1
# Going to unavailable and back should not fire
hass.states.async_set("test.entity", STATE_UNAVAILABLE)
await hass.async_block_till_done()
assert len(calls) == 1
hass.states.async_set("test.entity", 11)
await hass.async_block_till_done()
assert len(calls) == 1
# Crossing threshold via unavailable should fire
hass.states.async_set("test.entity", 9)
await hass.async_block_till_done()
assert len(calls) == 1
hass.states.async_set("test.entity", STATE_UNAVAILABLE)
await hass.async_block_till_done()
assert len(calls) == 1
hass.states.async_set("test.entity", 11)
await hass.async_block_till_done()
assert len(calls) == 2
@pytest.mark.parametrize("above", (10, "input_number.value_10"))
async def test_if_fires_on_entity_change_below_to_above(hass, calls, above):
"""Test the firing with changed entity."""
@ -1522,7 +1471,7 @@ async def test_if_fires_on_change_with_for_template_3(hass, calls, above, below)
assert len(calls) == 1
async def test_if_not_fires_on_error_with_for_template(hass, caplog, calls):
async def test_if_not_fires_on_error_with_for_template(hass, calls):
"""Test for not firing on error with for template."""
hass.states.async_set("test.entity", 0)
await hass.async_block_till_done()
@ -1547,17 +1496,11 @@ async def test_if_not_fires_on_error_with_for_template(hass, caplog, calls):
await hass.async_block_till_done()
assert len(calls) == 0
caplog.clear()
caplog.set_level(logging.WARNING)
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=3))
hass.states.async_set("test.entity", "unavailable")
await hass.async_block_till_done()
assert len(calls) == 0
assert len(caplog.record_tuples) == 1
assert caplog.record_tuples[0][1] == logging.WARNING
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=3))
hass.states.async_set("test.entity", 101)
await hass.async_block_till_done()

View File

@ -100,41 +100,28 @@ async def test_static_config_with_invalid_host(hass):
async def test_discovery(hass, pywemo_registry):
"""Verify that discovery dispatches devices to the platform for setup."""
def create_device(uuid, location):
def create_device(counter):
"""Create a unique mock Motion detector device for each counter value."""
device = create_autospec(pywemo.Motion, instance=True)
device.host = location
device.port = MOCK_PORT
device.name = f"{MOCK_NAME}_{uuid}"
device.serialnumber = f"{MOCK_SERIAL_NUMBER}_{uuid}"
device.host = f"{MOCK_HOST}_{counter}"
device.port = MOCK_PORT + counter
device.name = f"{MOCK_NAME}_{counter}"
device.serialnumber = f"{MOCK_SERIAL_NUMBER}_{counter}"
device.model_name = "Motion"
device.get_state.return_value = 0 # Default to Off
return device
def create_upnp_entry(counter):
return pywemo.ssdp.UPNPEntry.from_response(
"\r\n".join(
[
"",
f"LOCATION: http://192.168.1.100:{counter}/setup.xml",
f"USN: uuid:Socket-1_0-SERIAL{counter}::upnp:rootdevice",
"",
]
)
)
upnp_entries = [create_upnp_entry(0), create_upnp_entry(1)]
pywemo_devices = [create_device(0), create_device(1)]
# Setup the component and start discovery.
with patch(
"pywemo.discovery.device_from_uuid_and_location", side_effect=create_device
), patch("pywemo.ssdp.scan", return_value=upnp_entries) as mock_scan:
"pywemo.discover_devices", return_value=pywemo_devices
) as mock_discovery:
assert await async_setup_component(
hass, DOMAIN, {DOMAIN: {CONF_DISCOVERY: True}}
)
await pywemo_registry.semaphore.acquire() # Returns after platform setup.
mock_scan.assert_called()
# Add two of the same entries to test deduplication.
upnp_entries.extend([create_upnp_entry(2), create_upnp_entry(2)])
mock_discovery.assert_called()
pywemo_devices.append(create_device(2))
# Test that discovery runs periodically and the async_dispatcher_send code works.
async_fire_time_changed(

View File

@ -16,6 +16,7 @@ PROPERTY_DOOR_STATUS_BINARY_SENSOR = (
CLIMATE_RADIO_THERMOSTAT_ENTITY = "climate.z_wave_thermostat"
CLIMATE_DANFOSS_LC13_ENTITY = "climate.living_connect_z_thermostat"
CLIMATE_FLOOR_THERMOSTAT_ENTITY = "climate.floor_thermostat"
CLIMATE_MAIN_HEAT_ACTIONNER = "climate.main_heat_actionner"
BULB_6_MULTI_COLOR_LIGHT_ENTITY = "light.bulb_6_multi_color"
EATON_RF9640_ENTITY = "light.allloaddimmer"
AEON_SMART_SWITCH_LIGHT_ENTITY = "light.smart_switch_6"

View File

@ -240,6 +240,12 @@ def nortek_thermostat_state_fixture():
return json.loads(load_fixture("zwave_js/nortek_thermostat_state.json"))
@pytest.fixture(name="srt321_hrt4_zw_state", scope="session")
def srt321_hrt4_zw_state_fixture():
"""Load the climate HRT4-ZW / SRT321 / SRT322 thermostat node state fixture data."""
return json.loads(load_fixture("zwave_js/srt321_hrt4_zw_state.json"))
@pytest.fixture(name="chain_actuator_zws12_state", scope="session")
def window_cover_state_fixture():
"""Load the window cover node state fixture data."""
@ -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."""

View File

@ -70,7 +70,7 @@ async def test_websocket_api(hass, integration, multisensor_6, hass_ws_client):
result = msg["result"]
assert len(result) == 61
key = "52-112-0-2-00-00"
key = "52-112-0-2"
assert result[key]["property"] == 2
assert result[key]["metadata"]["type"] == "number"
assert result[key]["configuration_value_type"] == "enumerated"

View File

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

View File

@ -184,7 +184,16 @@ async def test_manual_errors(
async def test_manual_already_configured(hass):
"""Test that only one unique instance is allowed."""
entry = MockConfigEntry(domain=DOMAIN, data={}, title=TITLE, unique_id=1234)
entry = MockConfigEntry(
domain=DOMAIN,
data={
"url": "ws://localhost:3000",
"use_addon": True,
"integration_created_addon": True,
},
title=TITLE,
unique_id=1234,
)
entry.add_to_hass(hass)
await setup.async_setup_component(hass, "persistent_notification", {})
@ -198,12 +207,15 @@ async def test_manual_already_configured(hass):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"url": "ws://localhost:3000",
"url": "ws://1.1.1.1:3001",
},
)
assert result["type"] == "abort"
assert result["reason"] == "already_configured"
assert entry.data["url"] == "ws://1.1.1.1:3001"
assert entry.data["use_addon"] is False
assert entry.data["integration_created_addon"] is False
@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}])
@ -507,49 +519,6 @@ async def test_not_addon(hass, supervisor):
assert len(mock_setup_entry.mock_calls) == 1
async def test_addon_already_configured(hass, supervisor):
"""Test add-on already configured leads to manual step."""
entry = MockConfigEntry(
domain=DOMAIN, data={"use_addon": True}, title=TITLE, unique_id=5678
)
entry.add_to_hass(hass)
await setup.async_setup_component(hass, "persistent_notification", {})
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == "form"
assert result["step_id"] == "manual"
with patch(
"homeassistant.components.zwave_js.async_setup", return_value=True
) as mock_setup, patch(
"homeassistant.components.zwave_js.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"url": "ws://localhost:3000",
},
)
await hass.async_block_till_done()
assert result["type"] == "create_entry"
assert result["title"] == TITLE
assert result["data"] == {
"url": "ws://localhost:3000",
"usb_path": None,
"network_key": None,
"use_addon": False,
"integration_created_addon": False,
}
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 2
@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}])
async def test_addon_running(
hass,
@ -661,9 +630,18 @@ async def test_addon_running_already_configured(
hass, supervisor, addon_running, addon_options, get_addon_discovery_info
):
"""Test that only one unique instance is allowed when add-on is running."""
addon_options["device"] = "/test"
addon_options["network_key"] = "abc123"
entry = MockConfigEntry(domain=DOMAIN, data={}, title=TITLE, unique_id=1234)
addon_options["device"] = "/test_new"
addon_options["network_key"] = "def456"
entry = MockConfigEntry(
domain=DOMAIN,
data={
"url": "ws://localhost:3000",
"usb_path": "/test",
"network_key": "abc123",
},
title=TITLE,
unique_id=1234,
)
entry.add_to_hass(hass)
await setup.async_setup_component(hass, "persistent_notification", {})
@ -680,6 +658,9 @@ async def test_addon_running_already_configured(
assert result["type"] == "abort"
assert result["reason"] == "already_configured"
assert entry.data["url"] == "ws://host1:3001"
assert entry.data["usb_path"] == "/test_new"
assert entry.data["network_key"] == "def456"
@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}])
@ -885,7 +866,16 @@ async def test_addon_installed_already_configured(
get_addon_discovery_info,
):
"""Test that only one unique instance is allowed when add-on is installed."""
entry = MockConfigEntry(domain=DOMAIN, data={}, title=TITLE, unique_id=1234)
entry = MockConfigEntry(
domain=DOMAIN,
data={
"url": "ws://localhost:3000",
"usb_path": "/test",
"network_key": "abc123",
},
title=TITLE,
unique_id=1234,
)
entry.add_to_hass(hass)
await setup.async_setup_component(hass, "persistent_notification", {})
@ -904,7 +894,7 @@ async def test_addon_installed_already_configured(
assert result["step_id"] == "configure_addon"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"usb_path": "/test", "network_key": "abc123"}
result["flow_id"], {"usb_path": "/test_new", "network_key": "def456"}
)
assert result["type"] == "progress"
@ -915,6 +905,9 @@ async def test_addon_installed_already_configured(
assert result["type"] == "abort"
assert result["reason"] == "already_configured"
assert entry.data["url"] == "ws://host1:3001"
assert entry.data["usb_path"] == "/test_new"
assert entry.data["network_key"] == "def456"
@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}])

View File

@ -160,7 +160,7 @@ async def test_unique_id_migration_dupes(
# Check that new RegistryEntry is using new unique ID format
entity_entry = ent_reg.async_get(AIR_TEMPERATURE_SENSOR)
new_unique_id = f"{client.driver.controller.home_id}.52-49-0-Air temperature-00-00"
new_unique_id = f"{client.driver.controller.home_id}.52-49-0-Air temperature"
assert entity_entry.unique_id == new_unique_id
assert ent_reg.async_get(f"{AIR_TEMPERATURE_SENSOR}_1") is None
@ -195,7 +195,7 @@ async def test_unique_id_migration_v1(hass, multisensor_6_state, client, integra
# Check that new RegistryEntry is using new unique ID format
entity_entry = ent_reg.async_get(AIR_TEMPERATURE_SENSOR)
new_unique_id = f"{client.driver.controller.home_id}.52-49-0-Air temperature-00-00"
new_unique_id = f"{client.driver.controller.home_id}.52-49-0-Air temperature"
assert entity_entry.unique_id == new_unique_id
@ -228,7 +228,147 @@ async def test_unique_id_migration_v2(hass, multisensor_6_state, client, integra
# Check that new RegistryEntry is using new unique ID format
entity_entry = ent_reg.async_get(ILLUMINANCE_SENSOR)
new_unique_id = f"{client.driver.controller.home_id}.52-49-0-Illuminance-00-00"
new_unique_id = f"{client.driver.controller.home_id}.52-49-0-Illuminance"
assert entity_entry.unique_id == new_unique_id
async def test_unique_id_migration_v3(hass, multisensor_6_state, client, integration):
"""Test unique ID is migrated from old format to new (version 3)."""
ent_reg = entity_registry.async_get(hass)
# Migrate version 2
ILLUMINANCE_SENSOR = "sensor.multisensor_6_illuminance"
entity_name = ILLUMINANCE_SENSOR.split(".")[1]
# Create entity RegistryEntry using old unique ID format
old_unique_id = f"{client.driver.controller.home_id}.52-49-0-Illuminance-00-00"
entity_entry = ent_reg.async_get_or_create(
"sensor",
DOMAIN,
old_unique_id,
suggested_object_id=entity_name,
config_entry=integration,
original_name=entity_name,
)
assert entity_entry.entity_id == ILLUMINANCE_SENSOR
assert entity_entry.unique_id == old_unique_id
# Add a ready node, unique ID should be migrated
node = Node(client, multisensor_6_state)
event = {"node": node}
client.driver.controller.emit("node added", event)
await hass.async_block_till_done()
# Check that new RegistryEntry is using new unique ID format
entity_entry = ent_reg.async_get(ILLUMINANCE_SENSOR)
new_unique_id = f"{client.driver.controller.home_id}.52-49-0-Illuminance"
assert entity_entry.unique_id == new_unique_id
async def test_unique_id_migration_property_key_v1(
hass, hank_binary_switch_state, client, integration
):
"""Test unique ID with property key is migrated from old format to new (version 1)."""
ent_reg = entity_registry.async_get(hass)
SENSOR_NAME = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed"
entity_name = SENSOR_NAME.split(".")[1]
# Create entity RegistryEntry using old unique ID format
old_unique_id = f"{client.driver.controller.home_id}.32.32-50-00-value-W_Consumed"
entity_entry = ent_reg.async_get_or_create(
"sensor",
DOMAIN,
old_unique_id,
suggested_object_id=entity_name,
config_entry=integration,
original_name=entity_name,
)
assert entity_entry.entity_id == SENSOR_NAME
assert entity_entry.unique_id == old_unique_id
# Add a ready node, unique ID should be migrated
node = Node(client, hank_binary_switch_state)
event = {"node": node}
client.driver.controller.emit("node added", event)
await hass.async_block_till_done()
# Check that new RegistryEntry is using new unique ID format
entity_entry = ent_reg.async_get(SENSOR_NAME)
new_unique_id = f"{client.driver.controller.home_id}.32-50-0-value-66049"
assert entity_entry.unique_id == new_unique_id
async def test_unique_id_migration_property_key_v2(
hass, hank_binary_switch_state, client, integration
):
"""Test unique ID with property key is migrated from old format to new (version 2)."""
ent_reg = entity_registry.async_get(hass)
SENSOR_NAME = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed"
entity_name = SENSOR_NAME.split(".")[1]
# Create entity RegistryEntry using old unique ID format
old_unique_id = (
f"{client.driver.controller.home_id}.32.32-50-0-value-66049-W_Consumed"
)
entity_entry = ent_reg.async_get_or_create(
"sensor",
DOMAIN,
old_unique_id,
suggested_object_id=entity_name,
config_entry=integration,
original_name=entity_name,
)
assert entity_entry.entity_id == SENSOR_NAME
assert entity_entry.unique_id == old_unique_id
# Add a ready node, unique ID should be migrated
node = Node(client, hank_binary_switch_state)
event = {"node": node}
client.driver.controller.emit("node added", event)
await hass.async_block_till_done()
# Check that new RegistryEntry is using new unique ID format
entity_entry = ent_reg.async_get(SENSOR_NAME)
new_unique_id = f"{client.driver.controller.home_id}.32-50-0-value-66049"
assert entity_entry.unique_id == new_unique_id
async def test_unique_id_migration_property_key_v3(
hass, hank_binary_switch_state, client, integration
):
"""Test unique ID with property key is migrated from old format to new (version 3)."""
ent_reg = entity_registry.async_get(hass)
SENSOR_NAME = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed"
entity_name = SENSOR_NAME.split(".")[1]
# Create entity RegistryEntry using old unique ID format
old_unique_id = f"{client.driver.controller.home_id}.32-50-0-value-66049-W_Consumed"
entity_entry = ent_reg.async_get_or_create(
"sensor",
DOMAIN,
old_unique_id,
suggested_object_id=entity_name,
config_entry=integration,
original_name=entity_name,
)
assert entity_entry.entity_id == SENSOR_NAME
assert entity_entry.unique_id == old_unique_id
# Add a ready node, unique ID should be migrated
node = Node(client, hank_binary_switch_state)
event = {"node": node}
client.driver.controller.emit("node added", event)
await hass.async_block_till_done()
# Check that new RegistryEntry is using new unique ID format
entity_entry = ent_reg.async_get(SENSOR_NAME)
new_unique_id = f"{client.driver.controller.home_id}.32-50-0-value-66049"
assert entity_entry.unique_id == new_unique_id
@ -262,7 +402,7 @@ async def test_unique_id_migration_notification_binary_sensor(
# Check that new RegistryEntry is using new unique ID format
entity_entry = ent_reg.async_get(NOTIFICATION_MOTION_BINARY_SENSOR)
new_unique_id = f"{client.driver.controller.home_id}.52-113-0-Home Security-Motion sensor status-Motion sensor status.8"
new_unique_id = f"{client.driver.controller.home_id}.52-113-0-Home Security-Motion sensor status.8"
assert entity_entry.unique_id == new_unique_id
@ -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

View File

@ -4,11 +4,25 @@
"status": 1,
"ready": true,
"deviceClass": {
"basic": {"key": 4, "label":"Routing Slave"},
"generic": {"key": 8, "label":"Thermostat"},
"specific": {"key": 4, "label":"Setpoint Thermostat"},
"mandatorySupportedCCs": [],
"mandatoryControlCCs": []
"basic": {
"key": 4,
"label": "Routing Slave"
},
"generic": {
"key": 8,
"label": "Thermostat"
},
"specific": {
"key": 4,
"label": "Setpoint Thermostat"
},
"mandatorySupportedCCs": [
114,
143,
67,
134
],
"mandatoryControlledCCs": []
},
"isListening": false,
"isFrequentListening": false,
@ -22,6 +36,7 @@
"productType": 5,
"firmwareVersion": "1.1",
"deviceConfig": {
"filename": "/usr/src/app/node_modules/@zwave-js/config/config/devices/0x0002/lc-13.json",
"manufacturerId": 2,
"manufacturer": "Danfoss",
"label": "LC-13",
@ -66,19 +81,76 @@
14
],
"interviewAttempts": 1,
"interviewStage": 7,
"commandClasses": [
{
"id": 67,
"name": "Thermostat Setpoint",
"version": 2,
"isSecure": false
},
{
"id": 70,
"name": "Climate Control Schedule",
"version": 1,
"isSecure": false
},
{
"id": 114,
"name": "Manufacturer Specific",
"version": 1,
"isSecure": false
},
{
"id": 117,
"name": "Protection",
"version": 2,
"isSecure": false
},
{
"id": 128,
"name": "Battery",
"version": 1,
"isSecure": false
},
{
"id": 129,
"name": "Clock",
"version": 1,
"isSecure": false
},
{
"id": 132,
"name": "Wake Up",
"version": 1,
"isSecure": false
},
{
"id": 134,
"name": "Version",
"version": 1,
"isSecure": false
},
{
"id": 143,
"name": "Multi Command",
"version": 1,
"isSecure": false
}
],
"endpoints": [
{
"nodeId": 5,
"index": 0
}
],
"commandClasses": [],
"values": [
{
"endpoint": 0,
"commandClass": 67,
"commandClassName": "Thermostat Setpoint",
"property": "setpoint",
"propertyKey": 1,
"propertyName": "setpoint",
"propertyKeyName": "Heating",
"ccVersion": 2,
@ -91,7 +163,7 @@
"setpointType": 1
}
},
"value": 25
"value": 14
},
{
"endpoint": 0,
@ -262,7 +334,7 @@
"unit": "%",
"label": "Battery level"
},
"value": 53
"value": 49
},
{
"endpoint": 0,

View File

@ -837,6 +837,7 @@
"commandClassName": "Thermostat Setpoint",
"property": "setpoint",
"propertyName": "setpoint",
"propertyKey": 1,
"propertyKeyName": "Heating",
"ccVersion": 3,
"metadata": {

View File

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

View File

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