diff --git a/homeassistant/components/ozw/config_flow.py b/homeassistant/components/ozw/config_flow.py index cc07d738488..c1dbbe2e093 100644 --- a/homeassistant/components/ozw/config_flow.py +++ b/homeassistant/components/ozw/config_flow.py @@ -35,15 +35,6 @@ class DomainConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.integration_created_addon = False self.install_task = None - async def async_step_import(self, data): - """Handle imported data. - - This step will be used when importing data during zwave to ozw migration. - """ - self.network_key = data.get(CONF_NETWORK_KEY) - self.usb_path = data.get(CONF_USB_PATH) - return await self.async_step_user() - async def async_step_user(self, user_input=None): """Handle the initial step.""" if self._async_current_entries(): diff --git a/homeassistant/components/ozw/migration.py b/homeassistant/components/ozw/migration.py deleted file mode 100644 index 86df69bc955..00000000000 --- a/homeassistant/components/ozw/migration.py +++ /dev/null @@ -1,171 +0,0 @@ -"""Provide tools for migrating from the zwave integration.""" -from homeassistant.helpers.device_registry import ( - async_get_registry as async_get_device_registry, -) -from homeassistant.helpers.entity_registry import ( - async_entries_for_config_entry, - async_get_registry as async_get_entity_registry, -) - -from .const import DOMAIN, MIGRATED, NODES_VALUES -from .entity import create_device_id, create_value_id - -# The following dicts map labels between OpenZWave 1.4 and 1.6. -METER_CC_LABELS = { - "Energy": "Electric - kWh", - "Power": "Electric - W", - "Count": "Electric - Pulses", - "Voltage": "Electric - V", - "Current": "Electric - A", - "Power Factor": "Electric - PF", -} - -NOTIFICATION_CC_LABELS = { - "General": "Start", - "Smoke": "Smoke Alarm", - "Carbon Monoxide": "Carbon Monoxide", - "Carbon Dioxide": "Carbon Dioxide", - "Heat": "Heat", - "Flood": "Water", - "Access Control": "Access Control", - "Burglar": "Home Security", - "Power Management": "Power Management", - "System": "System", - "Emergency": "Emergency", - "Clock": "Clock", - "Appliance": "Appliance", - "HomeHealth": "Home Health", -} - -CC_ID_LABELS = { - 50: METER_CC_LABELS, - 113: NOTIFICATION_CC_LABELS, -} - - -async def async_get_migration_data(hass): - """Return dict with ozw side migration info.""" - data = {} - nodes_values = hass.data[DOMAIN][NODES_VALUES] - ozw_config_entries = hass.config_entries.async_entries(DOMAIN) - config_entry = ozw_config_entries[0] # ozw only has a single config entry - ent_reg = await async_get_entity_registry(hass) - entity_entries = async_entries_for_config_entry(ent_reg, config_entry.entry_id) - unique_entries = {entry.unique_id: entry for entry in entity_entries} - dev_reg = await async_get_device_registry(hass) - - for node_id, node_values in nodes_values.items(): - for entity_values in node_values: - unique_id = create_value_id(entity_values.primary) - if unique_id not in unique_entries: - continue - node = entity_values.primary.node - device_identifier = ( - DOMAIN, - create_device_id(node, entity_values.primary.instance), - ) - device_entry = dev_reg.async_get_device({device_identifier}, set()) - data[unique_id] = { - "node_id": node_id, - "node_instance": entity_values.primary.instance, - "device_id": device_entry.id, - "command_class": entity_values.primary.command_class.value, - "command_class_label": entity_values.primary.label, - "value_index": entity_values.primary.index, - "unique_id": unique_id, - "entity_entry": unique_entries[unique_id], - } - - return data - - -def map_node_values(zwave_data, ozw_data): - """Map zwave node values onto ozw node values.""" - migration_map = {"device_entries": {}, "entity_entries": {}} - - for zwave_entry in zwave_data.values(): - node_id = zwave_entry["node_id"] - node_instance = zwave_entry["node_instance"] - cc_id = zwave_entry["command_class"] - zwave_cc_label = zwave_entry["command_class_label"] - - if cc_id in CC_ID_LABELS: - labels = CC_ID_LABELS[cc_id] - ozw_cc_label = labels.get(zwave_cc_label, zwave_cc_label) - - ozw_entry = next( - ( - entry - for entry in ozw_data.values() - if entry["node_id"] == node_id - and entry["node_instance"] == node_instance - and entry["command_class"] == cc_id - and entry["command_class_label"] == ozw_cc_label - ), - None, - ) - else: - value_index = zwave_entry["value_index"] - - ozw_entry = next( - ( - entry - for entry in ozw_data.values() - if entry["node_id"] == node_id - and entry["node_instance"] == node_instance - and entry["command_class"] == cc_id - and entry["value_index"] == value_index - ), - None, - ) - - if ozw_entry is None: - continue - - # Save the zwave_entry under the ozw entity_id to create the map. - # Check that the mapped entities have the same domain. - if zwave_entry["entity_entry"].domain == ozw_entry["entity_entry"].domain: - migration_map["entity_entries"][ - ozw_entry["entity_entry"].entity_id - ] = zwave_entry - migration_map["device_entries"][ozw_entry["device_id"]] = zwave_entry[ - "device_id" - ] - - return migration_map - - -async def async_migrate(hass, migration_map): - """Perform zwave to ozw migration.""" - dev_reg = await async_get_device_registry(hass) - for ozw_device_id, zwave_device_id in migration_map["device_entries"].items(): - zwave_device_entry = dev_reg.async_get(zwave_device_id) - dev_reg.async_update_device( - ozw_device_id, - area_id=zwave_device_entry.area_id, - name_by_user=zwave_device_entry.name_by_user, - ) - - ent_reg = await async_get_entity_registry(hass) - for zwave_entry in migration_map["entity_entries"].values(): - zwave_entity_id = zwave_entry["entity_entry"].entity_id - ent_reg.async_remove(zwave_entity_id) - - for ozw_entity_id, zwave_entry in migration_map["entity_entries"].items(): - entity_entry = zwave_entry["entity_entry"] - ent_reg.async_update_entity( - ozw_entity_id, - new_entity_id=entity_entry.entity_id, - name=entity_entry.name, - icon=entity_entry.icon, - ) - - zwave_config_entry = hass.config_entries.async_entries("zwave")[0] - await hass.config_entries.async_remove(zwave_config_entry.entry_id) - - ozw_config_entry = hass.config_entries.async_entries("ozw")[0] - updates = { - **ozw_config_entry.data, - MIGRATED: True, - } - hass.config_entries.async_update_entry(ozw_config_entry, data=updates) diff --git a/homeassistant/components/ozw/websocket_api.py b/homeassistant/components/ozw/websocket_api.py index 4b96c577bf2..bb55a686db8 100644 --- a/homeassistant/components/ozw/websocket_api.py +++ b/homeassistant/components/ozw/websocket_api.py @@ -25,7 +25,6 @@ from homeassistant.helpers import config_validation as cv from .const import ATTR_CONFIG_PARAMETER, ATTR_CONFIG_VALUE, DOMAIN, MANAGER from .lock import ATTR_USERCODE -from .migration import async_get_migration_data, async_migrate, map_node_values _LOGGER = logging.getLogger(__name__) @@ -58,7 +57,6 @@ ATTR_NEIGHBORS = "neighbors" @callback def async_register_api(hass): """Register all of our api endpoints.""" - websocket_api.async_register_command(hass, websocket_migrate_zwave) websocket_api.async_register_command(hass, websocket_get_instances) websocket_api.async_register_command(hass, websocket_get_nodes) websocket_api.async_register_command(hass, websocket_network_status) @@ -168,63 +166,6 @@ def _get_config_params(node, *args): return config_params -@websocket_api.require_admin -@websocket_api.async_response -@websocket_api.websocket_command( - { - vol.Required(TYPE): "ozw/migrate_zwave", - vol.Optional(DRY_RUN, default=True): bool, - } -) -async def websocket_migrate_zwave(hass, connection, msg): - """Migrate the zwave integration device and entity data to ozw integration.""" - if "zwave" not in hass.config.components: - _LOGGER.error("Can not migrate, zwave integration is not loaded") - connection.send_message( - websocket_api.error_message( - msg["id"], "zwave_not_loaded", "Integration zwave is not loaded" - ) - ) - return - - zwave = hass.components.zwave - zwave_data = await zwave.async_get_ozw_migration_data(hass) - _LOGGER.debug("Migration zwave data: %s", zwave_data) - - ozw_data = await async_get_migration_data(hass) - _LOGGER.debug("Migration ozw data: %s", ozw_data) - - can_migrate = map_node_values(zwave_data, ozw_data) - - zwave_entity_ids = [ - entry["entity_entry"].entity_id for entry in zwave_data.values() - ] - ozw_entity_ids = [entry["entity_entry"].entity_id for entry in ozw_data.values()] - migration_device_map = { - zwave_device_id: ozw_device_id - for ozw_device_id, zwave_device_id in can_migrate["device_entries"].items() - } - migration_entity_map = { - zwave_entry["entity_entry"].entity_id: ozw_entity_id - for ozw_entity_id, zwave_entry in can_migrate["entity_entries"].items() - } - _LOGGER.debug("Migration entity map: %s", migration_entity_map) - - if not msg[DRY_RUN]: - await async_migrate(hass, can_migrate) - - connection.send_result( - msg[ID], - { - "migration_device_map": migration_device_map, - "zwave_entity_ids": zwave_entity_ids, - "ozw_entity_ids": ozw_entity_ids, - "migration_entity_map": migration_entity_map, - "migrated": not msg[DRY_RUN], - }, - ) - - @websocket_api.websocket_command({vol.Required(TYPE): "ozw/get_instances"}) def websocket_get_instances(hass, connection, msg): """Get a list of OZW instances.""" diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index 8a5705ae7bb..e14352a92a3 100644 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -29,7 +29,6 @@ from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_component import DEFAULT_SCAN_INTERVAL from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.entity_registry import ( - async_entries_for_config_entry, async_get_registry as async_get_entity_registry, ) from homeassistant.helpers.entity_values import EntityValues @@ -56,11 +55,18 @@ from .const import ( DOMAIN, ) from .discovery_schemas import DISCOVERY_SCHEMAS +from .migration import ( # noqa: F401 pylint: disable=unused-import + async_add_migration_entity_value, + async_get_migration_data, + async_is_ozw_migrated, + async_is_zwave_js_migrated, +) from .node_entity import ZWaveBaseEntity, ZWaveNodeEntity from .util import ( check_has_unique_id, check_node_schema, check_value_schema, + compute_value_unique_id, is_node_parsed, node_device_id_and_name, node_name, @@ -253,64 +259,6 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_get_ozw_migration_data(hass): - """Return dict with info for migration to ozw integration.""" - data_to_migrate = {} - - zwave_config_entries = hass.config_entries.async_entries(DOMAIN) - if not zwave_config_entries: - _LOGGER.error("Config entry not set up") - return data_to_migrate - - if hass.data.get(DATA_ZWAVE_CONFIG_YAML_PRESENT): - _LOGGER.warning( - "Remove %s from configuration.yaml " - "to avoid setting up this integration on restart " - "after completing migration to ozw", - DOMAIN, - ) - - config_entry = zwave_config_entries[0] # zwave only has a single config entry - ent_reg = await async_get_entity_registry(hass) - entity_entries = async_entries_for_config_entry(ent_reg, config_entry.entry_id) - unique_entries = {entry.unique_id: entry for entry in entity_entries} - dev_reg = await async_get_device_registry(hass) - - for entity_values in hass.data[DATA_ENTITY_VALUES]: - node = entity_values.primary.node - unique_id = compute_value_unique_id(node, entity_values.primary) - if unique_id not in unique_entries: - continue - device_identifier, _ = node_device_id_and_name( - node, entity_values.primary.instance - ) - device_entry = dev_reg.async_get_device({device_identifier}, set()) - data_to_migrate[unique_id] = { - "node_id": node.node_id, - "node_instance": entity_values.primary.instance, - "device_id": device_entry.id, - "command_class": entity_values.primary.command_class, - "command_class_label": entity_values.primary.label, - "value_index": entity_values.primary.index, - "unique_id": unique_id, - "entity_entry": unique_entries[unique_id], - } - - return data_to_migrate - - -@callback -def async_is_ozw_migrated(hass): - """Return True if migration to ozw is done.""" - ozw_config_entries = hass.config_entries.async_entries("ozw") - if not ozw_config_entries: - return False - - ozw_config_entry = ozw_config_entries[0] # only one ozw entry is allowed - migrated = bool(ozw_config_entry.data.get("migrated")) - return migrated - - def _obj_to_dict(obj): """Convert an object into a hash for debug.""" return { @@ -404,9 +352,22 @@ async def async_setup_entry(hass, config_entry): # noqa: C901 # pylint: enable=import-error from pydispatch import dispatcher - if async_is_ozw_migrated(hass): + if async_is_ozw_migrated(hass) or async_is_zwave_js_migrated(hass): + + if hass.data.get(DATA_ZWAVE_CONFIG_YAML_PRESENT): + config_yaml_message = ( + ", and remove %s from configuration.yaml " + "to avoid setting up this integration on restart ", + DOMAIN, + ) + else: + config_yaml_message = "" + _LOGGER.error( - "Migration to ozw has been done. Please remove the zwave integration" + "Migration away from legacy Z-Wave has been done. " + "Please remove the %s integration%s", + DOMAIN, + config_yaml_message, ) return False @@ -1307,6 +1268,9 @@ class ZWaveDeviceEntity(ZWaveBaseEntity): self.refresh_from_network, ) + # Add legacy Z-Wave migration data. + await async_add_migration_entity_value(self.hass, self.entity_id, self.values) + def _update_attributes(self): """Update the node attributes. May only be used inside callback.""" self.node_id = self.node.node_id @@ -1386,8 +1350,3 @@ class ZWaveDeviceEntity(ZWaveBaseEntity): ) or self.node.is_ready: return compute_value_unique_id(self.node, self.values.primary) return None - - -def compute_value_unique_id(node, value): - """Compute unique_id a value would get if it were to get one.""" - return f"{node.node_id}-{value.object_id}" diff --git a/homeassistant/components/zwave/manifest.json b/homeassistant/components/zwave/manifest.json index f65dbb557db..bf3a9abe77e 100644 --- a/homeassistant/components/zwave/manifest.json +++ b/homeassistant/components/zwave/manifest.json @@ -4,7 +4,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave", "requirements": ["homeassistant-pyozw==0.1.10", "pydispatcher==2.0.5"], - "after_dependencies": ["ozw"], "codeowners": ["@home-assistant/z-wave"], "iot_class": "local_push" } diff --git a/homeassistant/components/zwave/migration.py b/homeassistant/components/zwave/migration.py new file mode 100644 index 00000000000..0b151d18e4b --- /dev/null +++ b/homeassistant/components/zwave/migration.py @@ -0,0 +1,167 @@ +"""Handle migration from legacy Z-Wave to OpenZWave and Z-Wave JS.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, TypedDict, cast + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import async_get as async_get_device_registry +from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry +from homeassistant.helpers.singleton import singleton +from homeassistant.helpers.storage import Store + +from .const import DOMAIN +from .util import node_device_id_and_name + +if TYPE_CHECKING: + from . import ZWaveDeviceEntityValues + +LEGACY_ZWAVE_MIGRATION = f"{DOMAIN}_legacy_zwave_migration" +STORAGE_WRITE_DELAY = 30 +STORAGE_KEY = f"{DOMAIN}.legacy_zwave_migration" +STORAGE_VERSION = 1 + + +class ZWaveMigrationData(TypedDict): + """Represent the Z-Wave migration data dict.""" + + node_id: int + node_instance: int + command_class: int + command_class_label: str + value_index: int + device_id: str + domain: str + entity_id: str + unique_id: str + unit_of_measurement: str | None + + +@callback +def async_is_ozw_migrated(hass): + """Return True if migration to ozw is done.""" + ozw_config_entries = hass.config_entries.async_entries("ozw") + if not ozw_config_entries: + return False + + ozw_config_entry = ozw_config_entries[0] # only one ozw entry is allowed + migrated = bool(ozw_config_entry.data.get("migrated")) + return migrated + + +@callback +def async_is_zwave_js_migrated(hass): + """Return True if migration to Z-Wave JS is done.""" + zwave_js_config_entries = hass.config_entries.async_entries("zwave_js") + if not zwave_js_config_entries: + return False + + migrated = any( + config_entry.data.get("migrated") for config_entry in zwave_js_config_entries + ) + return migrated + + +async def async_add_migration_entity_value( + hass: HomeAssistant, + entity_id: str, + entity_values: ZWaveDeviceEntityValues, +) -> None: + """Add Z-Wave entity value for legacy Z-Wave migration.""" + migration_handler: LegacyZWaveMigration = await get_legacy_zwave_migration(hass) + migration_handler.add_entity_value(entity_id, entity_values) + + +async def async_get_migration_data( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, ZWaveMigrationData]: + """Return Z-Wave migration data.""" + migration_handler: LegacyZWaveMigration = await get_legacy_zwave_migration(hass) + return await migration_handler.get_data(config_entry) + + +@singleton(LEGACY_ZWAVE_MIGRATION) +async def get_legacy_zwave_migration(hass: HomeAssistant) -> LegacyZWaveMigration: + """Return legacy Z-Wave migration handler.""" + migration_handler = LegacyZWaveMigration(hass) + await migration_handler.load_data() + return migration_handler + + +class LegacyZWaveMigration: + """Handle the migration from zwave to ozw and zwave_js.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Set up migration instance.""" + self._hass = hass + self._store = Store(hass, STORAGE_VERSION, STORAGE_KEY) + self._data: dict[str, dict[str, ZWaveMigrationData]] = {} + + async def load_data(self) -> None: + """Load Z-Wave migration data.""" + stored = cast(dict, await self._store.async_load()) + if stored: + self._data = stored + + @callback + def save_data( + self, config_entry_id: str, entity_id: str, data: ZWaveMigrationData + ) -> None: + """Save Z-Wave migration data.""" + if config_entry_id not in self._data: + self._data[config_entry_id] = {} + self._data[config_entry_id][entity_id] = data + self._store.async_delay_save(self._data_to_save, STORAGE_WRITE_DELAY) + + @callback + def _data_to_save(self) -> dict[str, dict[str, ZWaveMigrationData]]: + """Return data to save.""" + return self._data + + @callback + def add_entity_value( + self, + entity_id: str, + entity_values: ZWaveDeviceEntityValues, + ) -> None: + """Add info for one entity and Z-Wave value.""" + ent_reg = async_get_entity_registry(self._hass) + dev_reg = async_get_device_registry(self._hass) + + node = entity_values.primary.node + entity_entry = ent_reg.async_get(entity_id) + assert entity_entry + device_identifier, _ = node_device_id_and_name( + node, entity_values.primary.instance + ) + device_entry = dev_reg.async_get_device({device_identifier}, set()) + assert device_entry + + # Normalize unit of measurement. + if unit := entity_entry.unit_of_measurement: + unit = unit.lower() + if unit == "": + unit = None + + data: ZWaveMigrationData = { + "node_id": node.node_id, + "node_instance": entity_values.primary.instance, + "command_class": entity_values.primary.command_class, + "command_class_label": entity_values.primary.label, + "value_index": entity_values.primary.index, + "device_id": device_entry.id, + "domain": entity_entry.domain, + "entity_id": entity_id, + "unique_id": entity_entry.unique_id, + "unit_of_measurement": unit, + } + + self.save_data(entity_entry.config_entry_id, entity_id, data) + + async def get_data( + self, config_entry: ConfigEntry + ) -> dict[str, ZWaveMigrationData]: + """Return Z-Wave migration data.""" + await self.load_data() + data = self._data.get(config_entry.entry_id) + return data or {} diff --git a/homeassistant/components/zwave/util.py b/homeassistant/components/zwave/util.py index da8fa37f44f..19be3f7a659 100644 --- a/homeassistant/components/zwave/util.py +++ b/homeassistant/components/zwave/util.py @@ -88,6 +88,11 @@ def check_value_schema(value, schema): return True +def compute_value_unique_id(node, value): + """Compute unique_id a value would get if it were to get one.""" + return f"{node.node_id}-{value.object_id}" + + def node_name(node): """Return the name of the node.""" if is_node_parsed(node): diff --git a/homeassistant/components/zwave/websocket_api.py b/homeassistant/components/zwave/websocket_api.py index bf84a27166e..b86e46bee98 100644 --- a/homeassistant/components/zwave/websocket_api.py +++ b/homeassistant/components/zwave/websocket_api.py @@ -2,7 +2,6 @@ import voluptuous as vol from homeassistant.components import websocket_api -from homeassistant.components.ozw.const import DOMAIN as OZW_DOMAIN from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.core import callback @@ -59,12 +58,14 @@ def websocket_get_migration_config(hass, connection, msg): @websocket_api.require_admin +@websocket_api.websocket_command( + {vol.Required(TYPE): "zwave/start_zwave_js_config_flow"} +) @websocket_api.async_response -@websocket_api.websocket_command({vol.Required(TYPE): "zwave/start_ozw_config_flow"}) -async def websocket_start_ozw_config_flow(hass, connection, msg): - """Start the ozw integration config flow (for migration wizard). +async def websocket_start_zwave_js_config_flow(hass, connection, msg): + """Start the Z-Wave JS integration config flow (for migration wizard). - Return data with the flow id of the started ozw config flow. + Return data with the flow id of the started Z-Wave JS config flow. """ config = hass.data[DATA_ZWAVE_CONFIG] data = { @@ -72,7 +73,7 @@ async def websocket_start_ozw_config_flow(hass, connection, msg): "network_key": config[CONF_NETWORK_KEY], } result = await hass.config_entries.flow.async_init( - OZW_DOMAIN, context={"source": SOURCE_IMPORT}, data=data + "zwave_js", context={"source": SOURCE_IMPORT}, data=data ) connection.send_result( msg[ID], @@ -86,4 +87,4 @@ def async_load_websocket_api(hass): websocket_api.async_register_command(hass, websocket_network_status) websocket_api.async_register_command(hass, websocket_get_config) websocket_api.async_register_command(hass, websocket_get_migration_config) - websocket_api.async_register_command(hass, websocket_start_ozw_config_flow) + websocket_api.async_register_command(hass, websocket_start_zwave_js_config_flow) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 03bccd814db..8057b900baa 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -58,8 +58,15 @@ from .const import ( DATA_CLIENT, DOMAIN, EVENT_DEVICE_ADDED_TO_REGISTRY, + LOGGER, ) from .helpers import async_enable_statistics, update_data_collection_preference +from .migrate import ( + ZWaveMigrationData, + async_get_migration_data, + async_map_legacy_zwave_values, + async_migrate_legacy_zwave, +) DATA_UNSUBSCRIBE = "unsubs" @@ -96,6 +103,9 @@ OPTED_IN = "opted_in" SECURITY_CLASSES = "security_classes" CLIENT_SIDE_AUTH = "client_side_auth" +# constants for migration +DRY_RUN = "dry_run" + def async_get_entry(orig_func: Callable) -> Callable: """Decorate async function to get entry.""" @@ -218,6 +228,8 @@ def async_register_api(hass: HomeAssistant) -> None: hass, websocket_subscribe_controller_statistics ) websocket_api.async_register_command(hass, websocket_subscribe_node_statistics) + websocket_api.async_register_command(hass, websocket_node_ready) + websocket_api.async_register_command(hass, websocket_migrate_zwave) hass.http.register_view(DumpView()) hass.http.register_view(FirmwareUploadView()) @@ -272,6 +284,42 @@ async def websocket_network_status( ) +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/node_ready", + vol.Required(ENTRY_ID): str, + vol.Required(NODE_ID): int, + } +) +@websocket_api.async_response +@async_get_node +async def websocket_node_ready( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict, + node: Node, +) -> None: + """Subscribe to the node ready event of a Z-Wave JS node.""" + + @callback + def forward_event(event: dict) -> None: + """Forward the event.""" + connection.send_message( + websocket_api.event_message(msg[ID], {"event": event["event"]}) + ) + + @callback + def async_cleanup() -> None: + """Remove signal listeners.""" + for unsub in unsubs: + unsub() + + connection.subscriptions[msg["id"]] = async_cleanup + msg[DATA_UNSUBSCRIBE] = unsubs = [node.on("ready", forward_event)] + + connection.send_result(msg[ID]) + + @websocket_api.websocket_command( { vol.Required(TYPE): "zwave_js/node_status", @@ -1743,3 +1791,72 @@ async def websocket_subscribe_node_statistics( connection.subscriptions[msg["id"]] = async_cleanup connection.send_result(msg[ID], _get_node_statistics_dict(node.statistics)) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/migrate_zwave", + vol.Required(ENTRY_ID): str, + vol.Optional(DRY_RUN, default=True): bool, + } +) +@websocket_api.async_response +@async_get_entry +async def websocket_migrate_zwave( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict, + entry: ConfigEntry, + client: Client, +) -> None: + """Migrate Z-Wave device and entity data to Z-Wave JS integration.""" + if "zwave" not in hass.config.components: + connection.send_message( + websocket_api.error_message( + msg["id"], "zwave_not_loaded", "Integration zwave is not loaded" + ) + ) + return + + zwave = hass.components.zwave + zwave_config_entries = hass.config_entries.async_entries("zwave") + zwave_config_entry = zwave_config_entries[0] # zwave only has a single config entry + zwave_data: dict[str, ZWaveMigrationData] = await zwave.async_get_migration_data( + hass, zwave_config_entry + ) + LOGGER.debug("Migration zwave data: %s", zwave_data) + + zwave_js_config_entry = entry + zwave_js_data = await async_get_migration_data(hass, zwave_js_config_entry) + LOGGER.debug("Migration zwave_js data: %s", zwave_js_data) + + migration_map = async_map_legacy_zwave_values(zwave_data, zwave_js_data) + + zwave_entity_ids = [entry["entity_id"] for entry in zwave_data.values()] + zwave_js_entity_ids = [entry["entity_id"] for entry in zwave_js_data.values()] + migration_device_map = { + zwave_device_id: zwave_js_device_id + for zwave_js_device_id, zwave_device_id in migration_map.device_entries.items() + } + migration_entity_map = { + zwave_entry["entity_id"]: zwave_js_entity_id + for zwave_js_entity_id, zwave_entry in migration_map.entity_entries.items() + } + LOGGER.debug("Migration entity map: %s", migration_entity_map) + + if not msg[DRY_RUN]: + await async_migrate_legacy_zwave( + hass, zwave_config_entry, zwave_js_config_entry, migration_map + ) + + connection.send_result( + msg[ID], + { + "migration_device_map": migration_device_map, + "zwave_entity_ids": zwave_entity_ids, + "zwave_js_entity_ids": zwave_js_entity_ids, + "migration_entity_map": migration_entity_map, + "migrated": not msg[DRY_RUN], + }, + ) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index a4f7343f0e0..c95078caf04 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -302,6 +302,16 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): """Return the options flow.""" return OptionsFlowHandler(config_entry) + async def async_step_import(self, data: dict[str, Any]) -> FlowResult: + """Handle imported data. + + This step will be used when importing data + during Z-Wave to Z-Wave JS migration. + """ + self.network_key = data.get(CONF_NETWORK_KEY) + self.usb_path = data.get(CONF_USB_PATH) + return await self.async_step_user() + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index 793eaa435d5..f9bba52c95b 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -15,6 +15,7 @@ from homeassistant.helpers.entity import Entity from .const import DOMAIN from .discovery import ZwaveDiscoveryInfo from .helpers import get_device_id, get_unique_id +from .migrate import async_add_migration_entity_value LOGGER = logging.getLogger(__name__) @@ -109,6 +110,11 @@ class ZWaveBaseEntity(Entity): ) ) + # Add legacy Z-Wave migration data. + await async_add_migration_entity_value( + self.hass, self.config_entry, self.entity_id, self.info + ) + def generate_name( self, include_value_name: bool = False, diff --git a/homeassistant/components/zwave_js/migrate.py b/homeassistant/components/zwave_js/migrate.py index 397f7efba24..6598f26d45c 100644 --- a/homeassistant/components/zwave_js/migrate.py +++ b/homeassistant/components/zwave_js/migrate.py @@ -1,27 +1,355 @@ """Functions used to migrate unique IDs for Z-Wave JS entities.""" from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field import logging +from typing import TypedDict, cast from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.model.value import Value as ZwaveValue +from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import ( + DeviceEntry, + async_get as async_get_device_registry, +) from homeassistant.helpers.entity_registry import ( EntityRegistry, RegistryEntry, async_entries_for_device, + async_get as async_get_entity_registry, ) +from homeassistant.helpers.singleton import singleton +from homeassistant.helpers.storage import Store from .const import DOMAIN from .discovery import ZwaveDiscoveryInfo -from .helpers import get_unique_id +from .helpers import get_device_id, get_unique_id _LOGGER = logging.getLogger(__name__) +LEGACY_ZWAVE_MIGRATION = f"{DOMAIN}_legacy_zwave_migration" +MIGRATED = "migrated" +STORAGE_WRITE_DELAY = 30 +STORAGE_KEY = f"{DOMAIN}.legacy_zwave_migration" +STORAGE_VERSION = 1 + +NOTIFICATION_CC_LABEL_TO_PROPERTY_NAME = { + "Smoke": "Smoke Alarm", + "Carbon Monoxide": "CO Alarm", + "Carbon Dioxide": "CO2 Alarm", + "Heat": "Heat Alarm", + "Flood": "Water Alarm", + "Access Control": "Access Control", + "Burglar": "Home Security", + "Power Management": "Power Management", + "System": "System", + "Emergency": "Siren", + "Clock": "Clock", + "Appliance": "Appliance", + "HomeHealth": "Home Health", +} + +SENSOR_MULTILEVEL_CC_LABEL_TO_PROPERTY_NAME = { + "Temperature": "Air temperature", + "General": "General purpose", + "Luminance": "Illuminance", + "Power": "Power", + "Relative Humidity": "Humidity", + "Velocity": "Velocity", + "Direction": "Direction", + "Atmospheric Pressure": "Atmospheric pressure", + "Barometric Pressure": "Barometric pressure", + "Solar Radiation": "Solar radiation", + "Dew Point": "Dew point", + "Rain Rate": "Rain rate", + "Tide Level": "Tide level", + "Weight": "Weight", + "Voltage": "Voltage", + "Current": "Current", + "CO2 Level": "Carbon dioxide (CO₂) level", + "Air Flow": "Air flow", + "Tank Capacity": "Tank capacity", + "Distance": "Distance", + "Angle Position": "Angle position", + "Rotation": "Rotation", + "Water Temperature": "Water temperature", + "Soil Temperature": "Soil temperature", + "Seismic Intensity": "Seismic Intensity", + "Seismic Magnitude": "Seismic magnitude", + "Ultraviolet": "Ultraviolet", + "Electrical Resistivity": "Electrical resistivity", + "Electrical Conductivity": "Electrical conductivity", + "Loudness": "Loudness", + "Moisture": "Moisture", +} + +CC_ID_LABEL_TO_PROPERTY = { + 49: SENSOR_MULTILEVEL_CC_LABEL_TO_PROPERTY_NAME, + 113: NOTIFICATION_CC_LABEL_TO_PROPERTY_NAME, +} + + +class ZWaveMigrationData(TypedDict): + """Represent the Z-Wave migration data dict.""" + + node_id: int + node_instance: int + command_class: int + command_class_label: str + value_index: int + device_id: str + domain: str + entity_id: str + unique_id: str + unit_of_measurement: str | None + + +class ZWaveJSMigrationData(TypedDict): + """Represent the Z-Wave JS migration data dict.""" + + node_id: int + endpoint_index: int + command_class: int + value_property_name: str + value_property_key_name: str | None + value_id: str + device_id: str + domain: str + entity_id: str + unique_id: str + unit_of_measurement: str | None + + +@dataclass +class LegacyZWaveMappedData: + """Represent the mapped data between Z-Wave and Z-Wave JS.""" + + entity_entries: dict[str, ZWaveMigrationData] = field(default_factory=dict) + device_entries: dict[str, str] = field(default_factory=dict) + + +async def async_add_migration_entity_value( + hass: HomeAssistant, + config_entry: ConfigEntry, + entity_id: str, + discovery_info: ZwaveDiscoveryInfo, +) -> None: + """Add Z-Wave JS entity value for legacy Z-Wave migration.""" + migration_handler: LegacyZWaveMigration = await get_legacy_zwave_migration(hass) + migration_handler.add_entity_value(config_entry, entity_id, discovery_info) + + +async def async_get_migration_data( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, ZWaveJSMigrationData]: + """Return Z-Wave JS migration data.""" + migration_handler: LegacyZWaveMigration = await get_legacy_zwave_migration(hass) + return await migration_handler.get_data(config_entry) + + +@singleton(LEGACY_ZWAVE_MIGRATION) +async def get_legacy_zwave_migration(hass: HomeAssistant) -> LegacyZWaveMigration: + """Return legacy Z-Wave migration handler.""" + migration_handler = LegacyZWaveMigration(hass) + await migration_handler.load_data() + return migration_handler + + +class LegacyZWaveMigration: + """Handle the migration from zwave to zwave_js.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Set up migration instance.""" + self._hass = hass + self._store = Store(hass, STORAGE_VERSION, STORAGE_KEY) + self._data: dict[str, dict[str, ZWaveJSMigrationData]] = {} + + async def load_data(self) -> None: + """Load Z-Wave JS migration data.""" + stored = cast(dict, await self._store.async_load()) + if stored: + self._data = stored + + @callback + def save_data( + self, config_entry_id: str, entity_id: str, data: ZWaveJSMigrationData + ) -> None: + """Save Z-Wave JS migration data.""" + if config_entry_id not in self._data: + self._data[config_entry_id] = {} + self._data[config_entry_id][entity_id] = data + self._store.async_delay_save(self._data_to_save, STORAGE_WRITE_DELAY) + + @callback + def _data_to_save(self) -> dict[str, dict[str, ZWaveJSMigrationData]]: + """Return data to save.""" + return self._data + + @callback + def add_entity_value( + self, + config_entry: ConfigEntry, + entity_id: str, + discovery_info: ZwaveDiscoveryInfo, + ) -> None: + """Add info for one entity and Z-Wave JS value.""" + ent_reg = async_get_entity_registry(self._hass) + dev_reg = async_get_device_registry(self._hass) + + node = discovery_info.node + primary_value = discovery_info.primary_value + entity_entry = ent_reg.async_get(entity_id) + assert entity_entry + device_identifier = get_device_id(node.client, node) + device_entry = dev_reg.async_get_device({device_identifier}, set()) + assert device_entry + + # Normalize unit of measurement. + if unit := entity_entry.unit_of_measurement: + unit = unit.lower() + if unit == "": + unit = None + + data: ZWaveJSMigrationData = { + "node_id": node.node_id, + "endpoint_index": node.index, + "command_class": primary_value.command_class, + "value_property_name": primary_value.property_name, + "value_property_key_name": primary_value.property_key_name, + "value_id": primary_value.value_id, + "device_id": device_entry.id, + "domain": entity_entry.domain, + "entity_id": entity_id, + "unique_id": entity_entry.unique_id, + "unit_of_measurement": unit, + } + + self.save_data(config_entry.entry_id, entity_id, data) + + async def get_data( + self, config_entry: ConfigEntry + ) -> dict[str, ZWaveJSMigrationData]: + """Return Z-Wave JS migration data for a config entry.""" + await self.load_data() + data = self._data.get(config_entry.entry_id) + return data or {} + + +@callback +def async_map_legacy_zwave_values( + zwave_data: dict[str, ZWaveMigrationData], + zwave_js_data: dict[str, ZWaveJSMigrationData], +) -> LegacyZWaveMappedData: + """Map Z-Wave node values onto Z-Wave JS node values.""" + migration_map = LegacyZWaveMappedData() + zwave_proc_data: dict[ + tuple[int, int, int, str, str | None, str | None], + ZWaveMigrationData | None, + ] = {} + zwave_js_proc_data: dict[ + tuple[int, int, int, str, str | None, str | None], + ZWaveJSMigrationData | None, + ] = {} + + for zwave_item in zwave_data.values(): + zwave_js_property_name = CC_ID_LABEL_TO_PROPERTY.get( + zwave_item["command_class"], {} + ).get(zwave_item["command_class_label"]) + item_id = ( + zwave_item["node_id"], + zwave_item["command_class"], + zwave_item["node_instance"] - 1, + zwave_item["domain"], + zwave_item["unit_of_measurement"], + zwave_js_property_name, + ) + + # Filter out duplicates that are not resolvable. + if item_id in zwave_proc_data: + zwave_proc_data[item_id] = None + continue + + zwave_proc_data[item_id] = zwave_item + + for zwave_js_item in zwave_js_data.values(): + # Only identify with property name if there is a command class label map. + if zwave_js_item["command_class"] in CC_ID_LABEL_TO_PROPERTY: + zwave_js_property_name = zwave_js_item["value_property_name"] + else: + zwave_js_property_name = None + item_id = ( + zwave_js_item["node_id"], + zwave_js_item["command_class"], + zwave_js_item["endpoint_index"], + zwave_js_item["domain"], + zwave_js_item["unit_of_measurement"], + zwave_js_property_name, + ) + + # Filter out duplicates that are not resolvable. + if item_id in zwave_js_proc_data: + zwave_js_proc_data[item_id] = None + continue + + zwave_js_proc_data[item_id] = zwave_js_item + + for item_id, zwave_entry in zwave_proc_data.items(): + zwave_js_entry = zwave_js_proc_data.pop(item_id, None) + + if zwave_entry is None or zwave_js_entry is None: + continue + + migration_map.entity_entries[zwave_js_entry["entity_id"]] = zwave_entry + migration_map.device_entries[zwave_js_entry["device_id"]] = zwave_entry[ + "device_id" + ] + + return migration_map + + +async def async_migrate_legacy_zwave( + hass: HomeAssistant, + zwave_config_entry: ConfigEntry, + zwave_js_config_entry: ConfigEntry, + migration_map: LegacyZWaveMappedData, +) -> None: + """Perform Z-Wave to Z-Wave JS migration.""" + dev_reg = async_get_device_registry(hass) + for zwave_js_device_id, zwave_device_id in migration_map.device_entries.items(): + zwave_device_entry = dev_reg.async_get(zwave_device_id) + if not zwave_device_entry: + continue + dev_reg.async_update_device( + zwave_js_device_id, + area_id=zwave_device_entry.area_id, + name_by_user=zwave_device_entry.name_by_user, + ) + + ent_reg = async_get_entity_registry(hass) + for zwave_js_entity_id, zwave_entry in migration_map.entity_entries.items(): + zwave_entity_id = zwave_entry["entity_id"] + entity_entry = ent_reg.async_get(zwave_entity_id) + if not entity_entry: + continue + ent_reg.async_remove(zwave_entity_id) + ent_reg.async_update_entity( + zwave_js_entity_id, + new_entity_id=entity_entry.entity_id, + name=entity_entry.name, + icon=entity_entry.icon, + ) + + await hass.config_entries.async_remove(zwave_config_entry.entry_id) + + updates = { + **zwave_js_config_entry.data, + MIGRATED: True, + } + hass.config_entries.async_update_entry(zwave_js_config_entry, data=updates) + @dataclass class ValueID: diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index 1df09d6f0d5..b650d3232ac 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -134,14 +134,14 @@ IGNORE_VIOLATIONS = { # Demo ("demo", "manual"), ("demo", "openalpr_local"), - # Migration wizard from zwave to ozw. - "ozw", # Migration of settings from zeroconf to network ("network", "zeroconf"), # This should become a helper method that integrations can submit data to ("websocket_api", "lovelace"), ("websocket_api", "shopping_list"), "logbook", + # Migration wizard from zwave to zwave_js. + "zwave_js", } diff --git a/tests/components/ozw/test_config_flow.py b/tests/components/ozw/test_config_flow.py index 6fdc86f710e..004c492bb2d 100644 --- a/tests/components/ozw/test_config_flow.py +++ b/tests/components/ozw/test_config_flow.py @@ -512,49 +512,3 @@ async def test_discovery_addon_not_installed( assert result["type"] == "form" assert result["step_id"] == "start_addon" - - -async def test_import_addon_installed( - hass, supervisor, addon_installed, addon_options, set_addon_options, start_addon -): - """Test add-on already installed but not running on Supervisor.""" - hass.config.components.add("mqtt") - await setup.async_setup_component(hass, "persistent_notification", {}) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={"usb_path": "/test/imported", "network_key": "imported123"}, - ) - - assert result["type"] == "form" - assert result["step_id"] == "on_supervisor" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"use_addon": True} - ) - - assert result["type"] == "form" - assert result["step_id"] == "start_addon" - - # the default input should be the imported data - default_input = result["data_schema"]({}) - - with patch( - "homeassistant.components.ozw.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - result["flow_id"], default_input - ) - await hass.async_block_till_done() - - assert result["type"] == "create_entry" - assert result["title"] == TITLE - assert result["data"] == { - "usb_path": "/test/imported", - "network_key": "imported123", - "use_addon": True, - "integration_created_addon": False, - } - assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/ozw/test_migration.py b/tests/components/ozw/test_migration.py deleted file mode 100644 index 076974bc48f..00000000000 --- a/tests/components/ozw/test_migration.py +++ /dev/null @@ -1,285 +0,0 @@ -"""Test zwave to ozw migration.""" -from unittest.mock import patch - -import pytest - -from homeassistant.components.ozw.websocket_api import ID, TYPE -from homeassistant.helpers import device_registry as dr, entity_registry as er - -from .common import setup_ozw - -from tests.common import MockConfigEntry, mock_device_registry, mock_registry - -ZWAVE_SOURCE_NODE_DEVICE_ID = "zwave_source_node_device_id" -ZWAVE_SOURCE_NODE_DEVICE_NAME = "Z-Wave Source Node Device" -ZWAVE_SOURCE_NODE_DEVICE_AREA = "Z-Wave Source Node Area" -ZWAVE_SOURCE_ENTITY = "sensor.zwave_source_node" -ZWAVE_SOURCE_NODE_UNIQUE_ID = "10-4321" -ZWAVE_BATTERY_DEVICE_ID = "zwave_battery_device_id" -ZWAVE_BATTERY_DEVICE_NAME = "Z-Wave Battery Device" -ZWAVE_BATTERY_DEVICE_AREA = "Z-Wave Battery Area" -ZWAVE_BATTERY_ENTITY = "sensor.zwave_battery_level" -ZWAVE_BATTERY_UNIQUE_ID = "36-1234" -ZWAVE_BATTERY_NAME = "Z-Wave Battery Level" -ZWAVE_BATTERY_ICON = "mdi:zwave-test-battery" -ZWAVE_POWER_DEVICE_ID = "zwave_power_device_id" -ZWAVE_POWER_DEVICE_NAME = "Z-Wave Power Device" -ZWAVE_POWER_DEVICE_AREA = "Z-Wave Power Area" -ZWAVE_POWER_ENTITY = "binary_sensor.zwave_power" -ZWAVE_POWER_UNIQUE_ID = "32-5678" -ZWAVE_POWER_NAME = "Z-Wave Power" -ZWAVE_POWER_ICON = "mdi:zwave-test-power" - - -@pytest.fixture(name="zwave_migration_data") -def zwave_migration_data_fixture(hass): - """Return mock zwave migration data.""" - zwave_source_node_device = dr.DeviceEntry( - id=ZWAVE_SOURCE_NODE_DEVICE_ID, - name_by_user=ZWAVE_SOURCE_NODE_DEVICE_NAME, - area_id=ZWAVE_SOURCE_NODE_DEVICE_AREA, - ) - zwave_source_node_entry = er.RegistryEntry( - entity_id=ZWAVE_SOURCE_ENTITY, - unique_id=ZWAVE_SOURCE_NODE_UNIQUE_ID, - platform="zwave", - name="Z-Wave Source Node", - ) - zwave_battery_device = dr.DeviceEntry( - id=ZWAVE_BATTERY_DEVICE_ID, - name_by_user=ZWAVE_BATTERY_DEVICE_NAME, - area_id=ZWAVE_BATTERY_DEVICE_AREA, - ) - zwave_battery_entry = er.RegistryEntry( - entity_id=ZWAVE_BATTERY_ENTITY, - unique_id=ZWAVE_BATTERY_UNIQUE_ID, - platform="zwave", - name=ZWAVE_BATTERY_NAME, - icon=ZWAVE_BATTERY_ICON, - ) - zwave_power_device = dr.DeviceEntry( - id=ZWAVE_POWER_DEVICE_ID, - name_by_user=ZWAVE_POWER_DEVICE_NAME, - area_id=ZWAVE_POWER_DEVICE_AREA, - ) - zwave_power_entry = er.RegistryEntry( - entity_id=ZWAVE_POWER_ENTITY, - unique_id=ZWAVE_POWER_UNIQUE_ID, - platform="zwave", - name=ZWAVE_POWER_NAME, - icon=ZWAVE_POWER_ICON, - ) - zwave_migration_data = { - ZWAVE_SOURCE_NODE_UNIQUE_ID: { - "node_id": 10, - "node_instance": 1, - "device_id": zwave_source_node_device.id, - "command_class": 113, - "command_class_label": "SourceNodeId", - "value_index": 2, - "unique_id": ZWAVE_SOURCE_NODE_UNIQUE_ID, - "entity_entry": zwave_source_node_entry, - }, - ZWAVE_BATTERY_UNIQUE_ID: { - "node_id": 36, - "node_instance": 1, - "device_id": zwave_battery_device.id, - "command_class": 128, - "command_class_label": "Battery Level", - "value_index": 0, - "unique_id": ZWAVE_BATTERY_UNIQUE_ID, - "entity_entry": zwave_battery_entry, - }, - ZWAVE_POWER_UNIQUE_ID: { - "node_id": 32, - "node_instance": 1, - "device_id": zwave_power_device.id, - "command_class": 50, - "command_class_label": "Power", - "value_index": 8, - "unique_id": ZWAVE_POWER_UNIQUE_ID, - "entity_entry": zwave_power_entry, - }, - } - - mock_device_registry( - hass, - { - zwave_source_node_device.id: zwave_source_node_device, - zwave_battery_device.id: zwave_battery_device, - zwave_power_device.id: zwave_power_device, - }, - ) - mock_registry( - hass, - { - ZWAVE_SOURCE_ENTITY: zwave_source_node_entry, - ZWAVE_BATTERY_ENTITY: zwave_battery_entry, - ZWAVE_POWER_ENTITY: zwave_power_entry, - }, - ) - - return zwave_migration_data - - -@pytest.fixture(name="zwave_integration") -def zwave_integration_fixture(hass, zwave_migration_data): - """Mock the zwave integration.""" - hass.config.components.add("zwave") - zwave_config_entry = MockConfigEntry(domain="zwave", data={"usb_path": "/dev/test"}) - zwave_config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.zwave.async_get_ozw_migration_data", - return_value=zwave_migration_data, - ): - yield zwave_config_entry - - -async def test_migrate_zwave(hass, migration_data, hass_ws_client, zwave_integration): - """Test the zwave to ozw migration websocket api.""" - await setup_ozw(hass, fixture=migration_data) - client = await hass_ws_client(hass) - - assert hass.config_entries.async_entries("zwave") - - await client.send_json({ID: 5, TYPE: "ozw/migrate_zwave", "dry_run": False}) - msg = await client.receive_json() - result = msg["result"] - - migration_entity_map = { - ZWAVE_BATTERY_ENTITY: "sensor.water_sensor_6_battery_level", - } - - assert result["zwave_entity_ids"] == [ - ZWAVE_SOURCE_ENTITY, - ZWAVE_BATTERY_ENTITY, - ZWAVE_POWER_ENTITY, - ] - assert result["ozw_entity_ids"] == [ - "sensor.smart_plug_electric_w", - "sensor.water_sensor_6_battery_level", - ] - assert result["migration_entity_map"] == migration_entity_map - assert result["migrated"] is True - - dev_reg = dr.async_get(hass) - ent_reg = er.async_get(hass) - - # check the device registry migration - - # check that the migrated entries have correct attributes - battery_entry = dev_reg.async_get_device( - identifiers={("ozw", "1.36.1")}, connections=set() - ) - assert battery_entry.name_by_user == ZWAVE_BATTERY_DEVICE_NAME - assert battery_entry.area_id == ZWAVE_BATTERY_DEVICE_AREA - power_entry = dev_reg.async_get_device( - identifiers={("ozw", "1.32.1")}, connections=set() - ) - assert power_entry.name_by_user == ZWAVE_POWER_DEVICE_NAME - assert power_entry.area_id == ZWAVE_POWER_DEVICE_AREA - - migration_device_map = { - ZWAVE_BATTERY_DEVICE_ID: battery_entry.id, - ZWAVE_POWER_DEVICE_ID: power_entry.id, - } - - assert result["migration_device_map"] == migration_device_map - - # check the entity registry migration - - # this should have been migrated and no longer present under that id - assert not ent_reg.async_is_registered("sensor.water_sensor_6_battery_level") - - # these should not have been migrated and is still in the registry - assert ent_reg.async_is_registered(ZWAVE_SOURCE_ENTITY) - source_entry = ent_reg.async_get(ZWAVE_SOURCE_ENTITY) - assert source_entry.unique_id == ZWAVE_SOURCE_NODE_UNIQUE_ID - assert ent_reg.async_is_registered(ZWAVE_POWER_ENTITY) - source_entry = ent_reg.async_get(ZWAVE_POWER_ENTITY) - assert source_entry.unique_id == ZWAVE_POWER_UNIQUE_ID - assert ent_reg.async_is_registered("sensor.smart_plug_electric_w") - - # this is the new entity_id of the ozw entity - assert ent_reg.async_is_registered(ZWAVE_BATTERY_ENTITY) - - # check that the migrated entries have correct attributes - battery_entry = ent_reg.async_get(ZWAVE_BATTERY_ENTITY) - assert battery_entry.unique_id == "1-36-610271249" - assert battery_entry.name == ZWAVE_BATTERY_NAME - assert battery_entry.icon == ZWAVE_BATTERY_ICON - - # check that the zwave config entry has been removed - assert not hass.config_entries.async_entries("zwave") - - # Check that the zwave integration fails entry setup after migration - zwave_config_entry = MockConfigEntry(domain="zwave") - zwave_config_entry.add_to_hass(hass) - assert not await hass.config_entries.async_setup(zwave_config_entry.entry_id) - - -async def test_migrate_zwave_dry_run( - hass, migration_data, hass_ws_client, zwave_integration -): - """Test the zwave to ozw migration websocket api dry run.""" - await setup_ozw(hass, fixture=migration_data) - client = await hass_ws_client(hass) - - await client.send_json({ID: 5, TYPE: "ozw/migrate_zwave"}) - msg = await client.receive_json() - result = msg["result"] - - migration_entity_map = { - ZWAVE_BATTERY_ENTITY: "sensor.water_sensor_6_battery_level", - } - - assert result["zwave_entity_ids"] == [ - ZWAVE_SOURCE_ENTITY, - ZWAVE_BATTERY_ENTITY, - ZWAVE_POWER_ENTITY, - ] - assert result["ozw_entity_ids"] == [ - "sensor.smart_plug_electric_w", - "sensor.water_sensor_6_battery_level", - ] - assert result["migration_entity_map"] == migration_entity_map - assert result["migrated"] is False - - ent_reg = er.async_get(hass) - - # no real migration should have been done - assert ent_reg.async_is_registered("sensor.water_sensor_6_battery_level") - assert ent_reg.async_is_registered("sensor.smart_plug_electric_w") - - assert ent_reg.async_is_registered(ZWAVE_SOURCE_ENTITY) - source_entry = ent_reg.async_get(ZWAVE_SOURCE_ENTITY) - assert source_entry.unique_id == ZWAVE_SOURCE_NODE_UNIQUE_ID - - assert ent_reg.async_is_registered(ZWAVE_BATTERY_ENTITY) - battery_entry = ent_reg.async_get(ZWAVE_BATTERY_ENTITY) - assert battery_entry.unique_id == ZWAVE_BATTERY_UNIQUE_ID - - assert ent_reg.async_is_registered(ZWAVE_POWER_ENTITY) - power_entry = ent_reg.async_get(ZWAVE_POWER_ENTITY) - assert power_entry.unique_id == ZWAVE_POWER_UNIQUE_ID - - # check that the zwave config entry has not been removed - assert hass.config_entries.async_entries("zwave") - - # Check that the zwave integration can be setup after dry run - zwave_config_entry = zwave_integration - with patch("openzwave.option.ZWaveOption"), patch("openzwave.network.ZWaveNetwork"): - assert await hass.config_entries.async_setup(zwave_config_entry.entry_id) - - -async def test_migrate_zwave_not_setup(hass, migration_data, hass_ws_client): - """Test the zwave to ozw migration websocket without zwave setup.""" - await setup_ozw(hass, fixture=migration_data) - client = await hass_ws_client(hass) - - await client.send_json({ID: 5, TYPE: "ozw/migrate_zwave"}) - msg = await client.receive_json() - - assert not msg["success"] - assert msg["error"]["code"] == "zwave_not_loaded" - assert msg["error"]["message"] == "Integration zwave is not loaded" diff --git a/tests/components/zwave/test_init.py b/tests/components/zwave/test_init.py index ecf5759b835..b0114d087ad 100644 --- a/tests/components/zwave/test_init.py +++ b/tests/components/zwave/test_init.py @@ -15,8 +15,7 @@ from homeassistant.components.zwave import ( DATA_NETWORK, const, ) -from homeassistant.components.zwave.binary_sensor import get_device -from homeassistant.const import ATTR_ENTITY_ID, ATTR_NAME +from homeassistant.const import ATTR_NAME from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util import dt as dt_util @@ -1854,38 +1853,6 @@ async def test_remove_association(hass, mock_openzwave, zwave_setup_ready): assert group.remove_association.mock_calls[0][1][1] == 5 -async def test_refresh_entity(hass, mock_openzwave, zwave_setup_ready): - """Test zwave refresh_entity service.""" - node = MockNode() - value = MockValue( - data=False, node=node, command_class=const.COMMAND_CLASS_SENSOR_BINARY - ) - power_value = MockValue(data=50, node=node, command_class=const.COMMAND_CLASS_METER) - values = MockEntityValues(primary=value, power=power_value) - device = get_device(node=node, values=values, node_config={}) - device.hass = hass - device.entity_id = "binary_sensor.mock_entity_id" - await device.async_added_to_hass() - await hass.async_block_till_done() - - await hass.services.async_call( - "zwave", "refresh_entity", {ATTR_ENTITY_ID: "binary_sensor.mock_entity_id"} - ) - await hass.async_block_till_done() - - assert node.refresh_value.called - assert len(node.refresh_value.mock_calls) == 2 - assert ( - sorted( - [ - node.refresh_value.mock_calls[0][1][0], - node.refresh_value.mock_calls[1][1][0], - ] - ) - == sorted([value.value_id, power_value.value_id]) - ) - - async def test_refresh_node(hass, mock_openzwave, zwave_setup_ready): """Test zwave refresh_node service.""" zwave_network = hass.data[DATA_NETWORK] diff --git a/tests/components/zwave/test_websocket_api.py b/tests/components/zwave/test_websocket_api.py index 2e37ed47fce..2ad94d29b0e 100644 --- a/tests/components/zwave/test_websocket_api.py +++ b/tests/components/zwave/test_websocket_api.py @@ -44,8 +44,8 @@ async def test_zwave_ws_api(hass, mock_openzwave, hass_ws_client): assert result[CONF_POLLING_INTERVAL] == 6000 -async def test_zwave_ozw_migration_api(hass, mock_openzwave, hass_ws_client): - """Test Z-Wave to OpenZWave websocket migration API.""" +async def test_zwave_zwave_js_migration_api(hass, mock_openzwave, hass_ws_client): + """Test Z-Wave to Z-Wave JS websocket migration API.""" await async_setup_component( hass, @@ -76,14 +76,14 @@ async def test_zwave_ozw_migration_api(hass, mock_openzwave, hass_ws_client): ) as async_init: async_init.return_value = {"flow_id": "mock_flow_id"} - await client.send_json({ID: 7, TYPE: "zwave/start_ozw_config_flow"}) + await client.send_json({ID: 7, TYPE: "zwave/start_zwave_js_config_flow"}) msg = await client.receive_json() result = msg["result"] assert result["flow_id"] == "mock_flow_id" assert async_init.call_args == call( - "ozw", + "zwave_js", context={"source": config_entries.SOURCE_IMPORT}, data={"usb_path": "/dev/zwave", "network_key": NETWORK_KEY}, ) diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 1551b55a429..44b9acf2db5 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -1,4 +1,5 @@ """Test the Z-Wave JS Websocket API.""" +from copy import deepcopy import json from unittest.mock import patch @@ -17,6 +18,7 @@ from zwave_js_server.exceptions import ( NotFoundError, SetValueFailed, ) +from zwave_js_server.model.node import Node from zwave_js_server.model.value import _get_value_id_from_dict, get_value_id from homeassistant.components.websocket_api.const import ERR_NOT_FOUND @@ -78,6 +80,51 @@ async def test_network_status(hass, integration, hass_ws_client): assert msg["error"]["code"] == ERR_NOT_LOADED +async def test_node_ready( + hass, + multisensor_6_state, + client, + integration, + hass_ws_client, +): + """Test the node ready websocket command.""" + entry = integration + ws_client = await hass_ws_client(hass) + node_data = deepcopy(multisensor_6_state) # Copy to allow modification in tests. + node = Node(client, node_data) + node.data["ready"] = False + client.driver.controller.nodes[node.node_id] = node + + await ws_client.send_json( + { + ID: 3, + TYPE: "zwave_js/node_ready", + ENTRY_ID: entry.entry_id, + "node_id": node.node_id, + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + + node.data["ready"] = True + event = Event( + "ready", + { + "source": "node", + "event": "ready", + "nodeId": node.node_id, + "nodeState": node.data, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + + msg = await ws_client.receive_json() + + assert msg["event"]["event"] == "ready" + + async def test_node_status(hass, multisensor_6, integration, hass_ws_client): """Test the node status websocket command.""" entry = integration diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 757dc6d5364..c2f1b12ca15 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -2053,3 +2053,71 @@ async def test_options_addon_not_installed( assert entry.data["integration_created_addon"] is True assert client.connect.call_count == 2 assert client.disconnect.call_count == 1 + + +@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) +async def test_import_addon_installed( + hass, + supervisor, + addon_installed, + addon_options, + set_addon_options, + start_addon, + get_addon_discovery_info, +): + """Test import step while add-on already installed on Supervisor.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={"usb_path": "/test/imported", "network_key": "imported123"}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "on_supervisor" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"use_addon": True} + ) + + assert result["type"] == "form" + assert result["step_id"] == "configure_addon" + + # the default input should be the imported data + default_input = result["data_schema"]({}) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], default_input + ) + + assert set_addon_options.call_args == call( + hass, + "core_zwave_js", + {"options": {"device": "/test/imported", "network_key": "imported123"}}, + ) + + assert result["type"] == "progress" + assert result["step_id"] == "start_addon" + + 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: + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert start_addon.call_args == call(hass, "core_zwave_js") + + assert result["type"] == "create_entry" + assert result["title"] == TITLE + assert result["data"] == { + "url": "ws://host1:3001", + "usb_path": "/test/imported", + "network_key": "imported123", + "use_addon": True, + "integration_created_addon": False, + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/zwave_js/test_migrate.py b/tests/components/zwave_js/test_migrate.py index 37c53700d95..ff3712b607e 100644 --- a/tests/components/zwave_js/test_migrate.py +++ b/tests/components/zwave_js/test_migrate.py @@ -1,15 +1,443 @@ """Test the Z-Wave JS migration module.""" import copy +from unittest.mock import patch import pytest from zwave_js_server.model.node import Node +from homeassistant.components.zwave_js.api import ENTRY_ID, ID, TYPE from homeassistant.components.zwave_js.const import DOMAIN from homeassistant.components.zwave_js.helpers import get_device_id from homeassistant.helpers import device_registry as dr, entity_registry as er from .common import AIR_TEMPERATURE_SENSOR, NOTIFICATION_MOTION_BINARY_SENSOR +from tests.common import MockConfigEntry, mock_device_registry, mock_registry + +# Switch device +ZWAVE_SWITCH_DEVICE_ID = "zwave_switch_device_id" +ZWAVE_SWITCH_DEVICE_NAME = "Z-Wave Switch Device" +ZWAVE_SWITCH_DEVICE_AREA = "Z-Wave Switch Area" +ZWAVE_SWITCH_ENTITY = "switch.zwave_switch_node" +ZWAVE_SWITCH_UNIQUE_ID = "102-6789" +ZWAVE_SWITCH_NAME = "Z-Wave Switch" +ZWAVE_SWITCH_ICON = "mdi:zwave-test-switch" +ZWAVE_POWER_ENTITY = "sensor.zwave_power" +ZWAVE_POWER_UNIQUE_ID = "102-5678" +ZWAVE_POWER_NAME = "Z-Wave Power" +ZWAVE_POWER_ICON = "mdi:zwave-test-power" + +# Multisensor device +ZWAVE_MULTISENSOR_DEVICE_ID = "zwave_multisensor_device_id" +ZWAVE_MULTISENSOR_DEVICE_NAME = "Z-Wave Multisensor Device" +ZWAVE_MULTISENSOR_DEVICE_AREA = "Z-Wave Multisensor Area" +ZWAVE_SOURCE_NODE_ENTITY = "sensor.zwave_source_node" +ZWAVE_SOURCE_NODE_UNIQUE_ID = "52-4321" +ZWAVE_BATTERY_ENTITY = "sensor.zwave_battery_level" +ZWAVE_BATTERY_UNIQUE_ID = "52-1234" +ZWAVE_BATTERY_NAME = "Z-Wave Battery Level" +ZWAVE_BATTERY_ICON = "mdi:zwave-test-battery" +ZWAVE_TAMPERING_ENTITY = "sensor.zwave_tampering" +ZWAVE_TAMPERING_UNIQUE_ID = "52-3456" +ZWAVE_TAMPERING_NAME = "Z-Wave Tampering" +ZWAVE_TAMPERING_ICON = "mdi:zwave-test-tampering" + + +@pytest.fixture(name="zwave_migration_data") +def zwave_migration_data_fixture(hass): + """Return mock zwave migration data.""" + zwave_switch_device = dr.DeviceEntry( + id=ZWAVE_SWITCH_DEVICE_ID, + name_by_user=ZWAVE_SWITCH_DEVICE_NAME, + area_id=ZWAVE_SWITCH_DEVICE_AREA, + ) + zwave_switch_entry = er.RegistryEntry( + entity_id=ZWAVE_SWITCH_ENTITY, + unique_id=ZWAVE_SWITCH_UNIQUE_ID, + platform="zwave", + name=ZWAVE_SWITCH_NAME, + icon=ZWAVE_SWITCH_ICON, + ) + zwave_multisensor_device = dr.DeviceEntry( + id=ZWAVE_MULTISENSOR_DEVICE_ID, + name_by_user=ZWAVE_MULTISENSOR_DEVICE_NAME, + area_id=ZWAVE_MULTISENSOR_DEVICE_AREA, + ) + zwave_source_node_entry = er.RegistryEntry( + entity_id=ZWAVE_SOURCE_NODE_ENTITY, + unique_id=ZWAVE_SOURCE_NODE_UNIQUE_ID, + platform="zwave", + name="Z-Wave Source Node", + ) + zwave_battery_entry = er.RegistryEntry( + entity_id=ZWAVE_BATTERY_ENTITY, + unique_id=ZWAVE_BATTERY_UNIQUE_ID, + platform="zwave", + name=ZWAVE_BATTERY_NAME, + icon=ZWAVE_BATTERY_ICON, + unit_of_measurement="%", + ) + zwave_power_entry = er.RegistryEntry( + entity_id=ZWAVE_POWER_ENTITY, + unique_id=ZWAVE_POWER_UNIQUE_ID, + platform="zwave", + name=ZWAVE_POWER_NAME, + icon=ZWAVE_POWER_ICON, + unit_of_measurement="W", + ) + zwave_tampering_entry = er.RegistryEntry( + entity_id=ZWAVE_TAMPERING_ENTITY, + unique_id=ZWAVE_TAMPERING_UNIQUE_ID, + platform="zwave", + name=ZWAVE_TAMPERING_NAME, + icon=ZWAVE_TAMPERING_ICON, + unit_of_measurement="", # Test empty string unit normalization. + ) + + zwave_migration_data = { + ZWAVE_SWITCH_ENTITY: { + "node_id": 102, + "node_instance": 1, + "command_class": 37, + "command_class_label": "", + "value_index": 1, + "device_id": zwave_switch_device.id, + "domain": zwave_switch_entry.domain, + "entity_id": zwave_switch_entry.entity_id, + "unique_id": ZWAVE_SWITCH_UNIQUE_ID, + "unit_of_measurement": zwave_switch_entry.unit_of_measurement, + }, + ZWAVE_POWER_ENTITY: { + "node_id": 102, + "node_instance": 1, + "command_class": 50, + "command_class_label": "Power", + "value_index": 8, + "device_id": zwave_switch_device.id, + "domain": zwave_power_entry.domain, + "entity_id": zwave_power_entry.entity_id, + "unique_id": ZWAVE_POWER_UNIQUE_ID, + "unit_of_measurement": zwave_power_entry.unit_of_measurement, + }, + ZWAVE_SOURCE_NODE_ENTITY: { + "node_id": 52, + "node_instance": 1, + "command_class": 113, + "command_class_label": "SourceNodeId", + "value_index": 1, + "device_id": zwave_multisensor_device.id, + "domain": zwave_source_node_entry.domain, + "entity_id": zwave_source_node_entry.entity_id, + "unique_id": ZWAVE_SOURCE_NODE_UNIQUE_ID, + "unit_of_measurement": zwave_source_node_entry.unit_of_measurement, + }, + ZWAVE_BATTERY_ENTITY: { + "node_id": 52, + "node_instance": 1, + "command_class": 128, + "command_class_label": "Battery Level", + "value_index": 0, + "device_id": zwave_multisensor_device.id, + "domain": zwave_battery_entry.domain, + "entity_id": zwave_battery_entry.entity_id, + "unique_id": ZWAVE_BATTERY_UNIQUE_ID, + "unit_of_measurement": zwave_battery_entry.unit_of_measurement, + }, + ZWAVE_TAMPERING_ENTITY: { + "node_id": 52, + "node_instance": 1, + "command_class": 113, + "command_class_label": "Burglar", + "value_index": 10, + "device_id": zwave_multisensor_device.id, + "domain": zwave_tampering_entry.domain, + "entity_id": zwave_tampering_entry.entity_id, + "unique_id": ZWAVE_TAMPERING_UNIQUE_ID, + "unit_of_measurement": zwave_tampering_entry.unit_of_measurement, + }, + } + + mock_device_registry( + hass, + { + zwave_switch_device.id: zwave_switch_device, + zwave_multisensor_device.id: zwave_multisensor_device, + }, + ) + mock_registry( + hass, + { + ZWAVE_SWITCH_ENTITY: zwave_switch_entry, + ZWAVE_SOURCE_NODE_ENTITY: zwave_source_node_entry, + ZWAVE_BATTERY_ENTITY: zwave_battery_entry, + ZWAVE_POWER_ENTITY: zwave_power_entry, + ZWAVE_TAMPERING_ENTITY: zwave_tampering_entry, + }, + ) + + return zwave_migration_data + + +@pytest.fixture(name="zwave_integration") +def zwave_integration_fixture(hass, zwave_migration_data): + """Mock the zwave integration.""" + hass.config.components.add("zwave") + zwave_config_entry = MockConfigEntry(domain="zwave", data={"usb_path": "/dev/test"}) + zwave_config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.zwave.async_get_migration_data", + return_value=zwave_migration_data, + ): + yield zwave_config_entry + + +async def test_migrate_zwave( + hass, + zwave_integration, + aeon_smart_switch_6, + multisensor_6, + integration, + hass_ws_client, +): + """Test the Z-Wave to Z-Wave JS migration websocket api.""" + entry = integration + client = await hass_ws_client(hass) + + assert hass.config_entries.async_entries("zwave") + + await client.send_json( + { + ID: 5, + TYPE: "zwave_js/migrate_zwave", + ENTRY_ID: entry.entry_id, + "dry_run": False, + } + ) + msg = await client.receive_json() + result = msg["result"] + + migration_entity_map = { + ZWAVE_SWITCH_ENTITY: "switch.smart_switch_6", + ZWAVE_BATTERY_ENTITY: "sensor.multisensor_6_battery_level", + } + + assert result["zwave_entity_ids"] == [ + ZWAVE_SWITCH_ENTITY, + ZWAVE_POWER_ENTITY, + ZWAVE_SOURCE_NODE_ENTITY, + ZWAVE_BATTERY_ENTITY, + ZWAVE_TAMPERING_ENTITY, + ] + expected_zwave_js_entities = [ + "switch.smart_switch_6", + "sensor.multisensor_6_air_temperature", + "sensor.multisensor_6_illuminance", + "sensor.multisensor_6_humidity", + "sensor.multisensor_6_ultraviolet", + "binary_sensor.multisensor_6_home_security_tampering_product_cover_removed", + "binary_sensor.multisensor_6_home_security_motion_detection", + "sensor.multisensor_6_battery_level", + "binary_sensor.multisensor_6_low_battery_level", + "light.smart_switch_6", + "sensor.smart_switch_6_electric_consumed_kwh", + "sensor.smart_switch_6_electric_consumed_w", + "sensor.smart_switch_6_electric_consumed_v", + "sensor.smart_switch_6_electric_consumed_a", + ] + # Assert that both lists have the same items without checking order + assert not set(result["zwave_js_entity_ids"]) ^ set(expected_zwave_js_entities) + assert result["migration_entity_map"] == migration_entity_map + assert result["migrated"] is True + + dev_reg = dr.async_get(hass) + ent_reg = er.async_get(hass) + + # check the device registry migration + + # check that the migrated entries have correct attributes + multisensor_device_entry = dev_reg.async_get_device( + identifiers={("zwave_js", "3245146787-52")}, connections=set() + ) + assert multisensor_device_entry + assert multisensor_device_entry.name_by_user == ZWAVE_MULTISENSOR_DEVICE_NAME + assert multisensor_device_entry.area_id == ZWAVE_MULTISENSOR_DEVICE_AREA + switch_device_entry = dev_reg.async_get_device( + identifiers={("zwave_js", "3245146787-102")}, connections=set() + ) + assert switch_device_entry + assert switch_device_entry.name_by_user == ZWAVE_SWITCH_DEVICE_NAME + assert switch_device_entry.area_id == ZWAVE_SWITCH_DEVICE_AREA + + migration_device_map = { + ZWAVE_SWITCH_DEVICE_ID: switch_device_entry.id, + ZWAVE_MULTISENSOR_DEVICE_ID: multisensor_device_entry.id, + } + + assert result["migration_device_map"] == migration_device_map + + # check the entity registry migration + + # this should have been migrated and no longer present under that id + assert not ent_reg.async_is_registered("sensor.multisensor_6_battery_level") + + # these should not have been migrated and is still in the registry + assert ent_reg.async_is_registered(ZWAVE_SOURCE_NODE_ENTITY) + source_entry = ent_reg.async_get(ZWAVE_SOURCE_NODE_ENTITY) + assert source_entry.unique_id == ZWAVE_SOURCE_NODE_UNIQUE_ID + assert ent_reg.async_is_registered(ZWAVE_POWER_ENTITY) + source_entry = ent_reg.async_get(ZWAVE_POWER_ENTITY) + assert source_entry.unique_id == ZWAVE_POWER_UNIQUE_ID + assert ent_reg.async_is_registered(ZWAVE_TAMPERING_ENTITY) + tampering_entry = ent_reg.async_get(ZWAVE_TAMPERING_ENTITY) + assert tampering_entry.unique_id == ZWAVE_TAMPERING_UNIQUE_ID + assert ent_reg.async_is_registered("sensor.smart_switch_6_electric_consumed_w") + + # this is the new entity_ids of the zwave_js entities + assert ent_reg.async_is_registered(ZWAVE_SWITCH_ENTITY) + assert ent_reg.async_is_registered(ZWAVE_BATTERY_ENTITY) + + # check that the migrated entries have correct attributes + switch_entry = ent_reg.async_get(ZWAVE_SWITCH_ENTITY) + assert switch_entry + assert switch_entry.unique_id == "3245146787.102-37-0-currentValue" + assert switch_entry.name == ZWAVE_SWITCH_NAME + assert switch_entry.icon == ZWAVE_SWITCH_ICON + battery_entry = ent_reg.async_get(ZWAVE_BATTERY_ENTITY) + assert battery_entry + assert battery_entry.unique_id == "3245146787.52-128-0-level" + assert battery_entry.name == ZWAVE_BATTERY_NAME + assert battery_entry.icon == ZWAVE_BATTERY_ICON + + # check that the zwave config entry has been removed + assert not hass.config_entries.async_entries("zwave") + + # Check that the zwave integration fails entry setup after migration + zwave_config_entry = MockConfigEntry(domain="zwave") + zwave_config_entry.add_to_hass(hass) + assert not await hass.config_entries.async_setup(zwave_config_entry.entry_id) + + +async def test_migrate_zwave_dry_run( + hass, + zwave_integration, + aeon_smart_switch_6, + multisensor_6, + integration, + hass_ws_client, +): + """Test the zwave to zwave_js migration websocket api dry run.""" + entry = integration + client = await hass_ws_client(hass) + + await client.send_json( + {ID: 5, TYPE: "zwave_js/migrate_zwave", ENTRY_ID: entry.entry_id} + ) + msg = await client.receive_json() + result = msg["result"] + + migration_entity_map = { + ZWAVE_SWITCH_ENTITY: "switch.smart_switch_6", + ZWAVE_BATTERY_ENTITY: "sensor.multisensor_6_battery_level", + } + + assert result["zwave_entity_ids"] == [ + ZWAVE_SWITCH_ENTITY, + ZWAVE_POWER_ENTITY, + ZWAVE_SOURCE_NODE_ENTITY, + ZWAVE_BATTERY_ENTITY, + ZWAVE_TAMPERING_ENTITY, + ] + expected_zwave_js_entities = [ + "switch.smart_switch_6", + "sensor.multisensor_6_air_temperature", + "sensor.multisensor_6_illuminance", + "sensor.multisensor_6_humidity", + "sensor.multisensor_6_ultraviolet", + "binary_sensor.multisensor_6_home_security_tampering_product_cover_removed", + "binary_sensor.multisensor_6_home_security_motion_detection", + "sensor.multisensor_6_battery_level", + "binary_sensor.multisensor_6_low_battery_level", + "light.smart_switch_6", + "sensor.smart_switch_6_electric_consumed_kwh", + "sensor.smart_switch_6_electric_consumed_w", + "sensor.smart_switch_6_electric_consumed_v", + "sensor.smart_switch_6_electric_consumed_a", + ] + # Assert that both lists have the same items without checking order + assert not set(result["zwave_js_entity_ids"]) ^ set(expected_zwave_js_entities) + assert result["migration_entity_map"] == migration_entity_map + + dev_reg = dr.async_get(hass) + + multisensor_device_entry = dev_reg.async_get_device( + identifiers={("zwave_js", "3245146787-52")}, connections=set() + ) + assert multisensor_device_entry + assert multisensor_device_entry.name_by_user is None + assert multisensor_device_entry.area_id is None + switch_device_entry = dev_reg.async_get_device( + identifiers={("zwave_js", "3245146787-102")}, connections=set() + ) + assert switch_device_entry + assert switch_device_entry.name_by_user is None + assert switch_device_entry.area_id is None + + migration_device_map = { + ZWAVE_SWITCH_DEVICE_ID: switch_device_entry.id, + ZWAVE_MULTISENSOR_DEVICE_ID: multisensor_device_entry.id, + } + + assert result["migration_device_map"] == migration_device_map + + assert result["migrated"] is False + + ent_reg = er.async_get(hass) + + # no real migration should have been done + assert ent_reg.async_is_registered("switch.smart_switch_6") + assert ent_reg.async_is_registered("sensor.multisensor_6_battery_level") + assert ent_reg.async_is_registered("sensor.smart_switch_6_electric_consumed_w") + + assert ent_reg.async_is_registered(ZWAVE_SOURCE_NODE_ENTITY) + source_entry = ent_reg.async_get(ZWAVE_SOURCE_NODE_ENTITY) + assert source_entry + assert source_entry.unique_id == ZWAVE_SOURCE_NODE_UNIQUE_ID + + assert ent_reg.async_is_registered(ZWAVE_BATTERY_ENTITY) + battery_entry = ent_reg.async_get(ZWAVE_BATTERY_ENTITY) + assert battery_entry + assert battery_entry.unique_id == ZWAVE_BATTERY_UNIQUE_ID + + assert ent_reg.async_is_registered(ZWAVE_POWER_ENTITY) + power_entry = ent_reg.async_get(ZWAVE_POWER_ENTITY) + assert power_entry + assert power_entry.unique_id == ZWAVE_POWER_UNIQUE_ID + + # check that the zwave config entry has not been removed + assert hass.config_entries.async_entries("zwave") + + # Check that the zwave integration can be setup after dry run + zwave_config_entry = zwave_integration + with patch("openzwave.option.ZWaveOption"), patch("openzwave.network.ZWaveNetwork"): + assert await hass.config_entries.async_setup(zwave_config_entry.entry_id) + + +async def test_migrate_zwave_not_setup( + hass, aeon_smart_switch_6, multisensor_6, integration, hass_ws_client +): + """Test the zwave to zwave_js migration websocket without zwave setup.""" + entry = integration + client = await hass_ws_client(hass) + + await client.send_json( + {ID: 5, TYPE: "zwave_js/migrate_zwave", ENTRY_ID: entry.entry_id} + ) + msg = await client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_not_loaded" + assert msg["error"]["message"] == "Integration zwave is not loaded" + async def test_unique_id_migration_dupes( hass, multisensor_6_state, client, integration