From 8b72324ae695d7231506e98daa1c3ae83e0bac3d Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sat, 9 Jan 2021 15:23:03 +0100 Subject: [PATCH] Add zwave to ozw migration (#39081) Co-authored-by: Paulus Schoutsen Co-authored-by: Bram Kragten --- homeassistant/components/ozw/__init__.py | 3 +- homeassistant/components/ozw/config_flow.py | 19 +- homeassistant/components/ozw/const.py | 4 + homeassistant/components/ozw/manifest.json | 3 +- homeassistant/components/ozw/migration.py | 171 ++++++++++ homeassistant/components/ozw/websocket_api.py | 64 ++++ homeassistant/components/zwave/__init__.py | 68 ++++ homeassistant/components/zwave/manifest.json | 1 + .../components/zwave/websocket_api.py | 25 ++ tests/components/ozw/conftest.py | 6 + tests/components/ozw/test_config_flow.py | 49 +++ tests/components/ozw/test_migration.py | 292 ++++++++++++++++++ tests/components/zwave/test_websocket_api.py | 47 ++- tests/fixtures/ozw/migration_fixture.csv | 9 + 14 files changed, 751 insertions(+), 10 deletions(-) create mode 100644 homeassistant/components/ozw/migration.py create mode 100644 tests/components/ozw/test_migration.py create mode 100644 tests/fixtures/ozw/migration_fixture.csv diff --git a/homeassistant/components/ozw/__init__.py b/homeassistant/components/ozw/__init__.py index 18fffdbc66e06b..e56e3deb06681b 100644 --- a/homeassistant/components/ozw/__init__.py +++ b/homeassistant/components/ozw/__init__.py @@ -36,6 +36,7 @@ DATA_UNSUBSCRIBE, DOMAIN, MANAGER, + NODES_VALUES, PLATFORMS, TOPIC_OPENZWAVE, ) @@ -68,7 +69,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): ozw_data[DATA_UNSUBSCRIBE] = [] data_nodes = {} - data_values = {} + hass.data[DOMAIN][NODES_VALUES] = data_values = {} removed_nodes = [] manager_options = {"topic_prefix": f"{TOPIC_OPENZWAVE}/"} diff --git a/homeassistant/components/ozw/config_flow.py b/homeassistant/components/ozw/config_flow.py index 7c7c6e65dfe06a..14d875e0a70faa 100644 --- a/homeassistant/components/ozw/config_flow.py +++ b/homeassistant/components/ozw/config_flow.py @@ -37,6 +37,15 @@ def __init__(self): 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(): @@ -163,13 +172,15 @@ async def async_step_start_addon(self, user_input=None): else: return self._async_create_entry_from_vars() - self.usb_path = self.addon_config.get(CONF_ADDON_DEVICE, "") - self.network_key = self.addon_config.get(CONF_ADDON_NETWORK_KEY, "") + usb_path = self.addon_config.get(CONF_ADDON_DEVICE, self.usb_path or "") + network_key = self.addon_config.get( + CONF_ADDON_NETWORK_KEY, self.network_key or "" + ) data_schema = vol.Schema( { - vol.Required(CONF_USB_PATH, default=self.usb_path): str, - vol.Optional(CONF_NETWORK_KEY, default=self.network_key): str, + vol.Required(CONF_USB_PATH, default=usb_path): str, + vol.Optional(CONF_NETWORK_KEY, default=network_key): str, } ) diff --git a/homeassistant/components/ozw/const.py b/homeassistant/components/ozw/const.py index f8d5090aa84e1b..68eaf9f7c8a36f 100644 --- a/homeassistant/components/ozw/const.py +++ b/homeassistant/components/ozw/const.py @@ -25,6 +25,7 @@ SWITCH_DOMAIN, ] MANAGER = "manager" +NODES_VALUES = "nodes_values" # MQTT Topics TOPIC_OPENZWAVE = "OpenZWave" @@ -40,6 +41,9 @@ ATTR_SCENE_VALUE_ID = "scene_value_id" ATTR_SCENE_VALUE_LABEL = "scene_value_label" +# Config entry data and options +MIGRATED = "migrated" + # Service specific SERVICE_ADD_NODE = "add_node" SERVICE_REMOVE_NODE = "remove_node" diff --git a/homeassistant/components/ozw/manifest.json b/homeassistant/components/ozw/manifest.json index a1409fd79a81ec..984e3f9c51afc7 100644 --- a/homeassistant/components/ozw/manifest.json +++ b/homeassistant/components/ozw/manifest.json @@ -7,7 +7,8 @@ "python-openzwave-mqtt[mqtt-client]==1.4.0" ], "after_dependencies": [ - "mqtt" + "mqtt", + "zwave" ], "codeowners": [ "@cgarwood", diff --git a/homeassistant/components/ozw/migration.py b/homeassistant/components/ozw/migration.py new file mode 100644 index 00000000000000..86df69bc955254 --- /dev/null +++ b/homeassistant/components/ozw/migration.py @@ -0,0 +1,171 @@ +"""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 3ee6e040743de0..708b9045b57deb 100644 --- a/homeassistant/components/ozw/websocket_api.py +++ b/homeassistant/components/ozw/websocket_api.py @@ -1,4 +1,6 @@ """Web socket API for OpenZWave.""" +import logging + from openzwavemqtt.const import ( ATTR_CODE_SLOT, ATTR_LABEL, @@ -23,7 +25,11 @@ 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__) +DRY_RUN = "dry_run" TYPE = "type" ID = "id" OZW_INSTANCE = "ozw_instance" @@ -52,6 +58,7 @@ @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) @@ -161,6 +168,63 @@ 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 aba169a1919fc9..27f6c0a4801fb9 100644 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -28,6 +28,7 @@ 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 @@ -81,6 +82,8 @@ CONF_DEVICE_CONFIG_GLOB = "device_config_glob" CONF_DEVICE_CONFIG_DOMAIN = "device_config_domain" +DATA_ZWAVE_CONFIG_YAML_PRESENT = "zwave_config_yaml_present" + DEFAULT_CONF_IGNORED = False DEFAULT_CONF_INVERT_OPENCLOSE_BUTTONS = False DEFAULT_CONF_INVERT_PERCENT = False @@ -250,6 +253,64 @@ ) +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 { @@ -312,6 +373,7 @@ async def async_setup(hass, config): conf = config[DOMAIN] hass.data[DATA_ZWAVE_CONFIG] = conf + hass.data[DATA_ZWAVE_CONFIG_YAML_PRESENT] = True if not hass.config_entries.async_entries(DOMAIN): hass.async_create_task( @@ -343,6 +405,12 @@ async def async_setup_entry(hass, config_entry): # pylint: enable=import-error from pydispatch import dispatcher + if async_is_ozw_migrated(hass): + _LOGGER.error( + "Migration to ozw has been done. Please remove the zwave integration" + ) + return False + # Merge config entry and yaml config config = config_entry.data if DATA_ZWAVE_CONFIG in hass.data: diff --git a/homeassistant/components/zwave/manifest.json b/homeassistant/components/zwave/manifest.json index 5fda2eac7c3df1..a3a2b5e0d83e39 100644 --- a/homeassistant/components/zwave/manifest.json +++ b/homeassistant/components/zwave/manifest.json @@ -4,5 +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"] } diff --git a/homeassistant/components/zwave/websocket_api.py b/homeassistant/components/zwave/websocket_api.py index 5e3a49df63ce38..bf84a27166ec6a 100644 --- a/homeassistant/components/zwave/websocket_api.py +++ b/homeassistant/components/zwave/websocket_api.py @@ -2,6 +2,8 @@ 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 from .const import ( @@ -56,9 +58,32 @@ def websocket_get_migration_config(hass, connection, msg): ) +@websocket_api.require_admin +@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). + + Return data with the flow id of the started ozw config flow. + """ + config = hass.data[DATA_ZWAVE_CONFIG] + data = { + "usb_path": config[CONF_USB_STICK_PATH], + "network_key": config[CONF_NETWORK_KEY], + } + result = await hass.config_entries.flow.async_init( + OZW_DOMAIN, context={"source": SOURCE_IMPORT}, data=data + ) + connection.send_result( + msg[ID], + {"flow_id": result["flow_id"]}, + ) + + @callback def async_load_websocket_api(hass): """Set up the web socket API.""" 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) diff --git a/tests/components/ozw/conftest.py b/tests/components/ozw/conftest.py index b2bd6486d0f741..00f8d8e52d2fb7 100644 --- a/tests/components/ozw/conftest.py +++ b/tests/components/ozw/conftest.py @@ -16,6 +16,12 @@ def generic_data_fixture(): return load_fixture("ozw/generic_network_dump.csv") +@pytest.fixture(name="migration_data", scope="session") +def migration_data_fixture(): + """Load migration MQTT data and return it.""" + return load_fixture("ozw/migration_fixture.csv") + + @pytest.fixture(name="fan_data", scope="session") def fan_data_fixture(): """Load fan MQTT data and return it.""" diff --git a/tests/components/ozw/test_config_flow.py b/tests/components/ozw/test_config_flow.py index c7ff2512ca7f18..d1ac413270daa9 100644 --- a/tests/components/ozw/test_config_flow.py +++ b/tests/components/ozw/test_config_flow.py @@ -535,3 +535,52 @@ 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", return_value=True + ) as mock_setup, 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.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/ozw/test_migration.py b/tests/components/ozw/test_migration.py new file mode 100644 index 00000000000000..d83a39f2b15331 --- /dev/null +++ b/tests/components/ozw/test_migration.py @@ -0,0 +1,292 @@ +"""Test zwave to ozw migration.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.ozw.websocket_api import ID, TYPE +from homeassistant.helpers.device_registry import ( + DeviceEntry, + async_get_registry as async_get_device_registry, +) +from homeassistant.helpers.entity_registry import ( + RegistryEntry, + async_get_registry as async_get_entity_registry, +) + +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 = 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 = RegistryEntry( + entity_id=ZWAVE_SOURCE_ENTITY, + unique_id=ZWAVE_SOURCE_NODE_UNIQUE_ID, + platform="zwave", + name="Z-Wave Source Node", + ) + zwave_battery_device = DeviceEntry( + id=ZWAVE_BATTERY_DEVICE_ID, + name_by_user=ZWAVE_BATTERY_DEVICE_NAME, + area_id=ZWAVE_BATTERY_DEVICE_AREA, + ) + zwave_battery_entry = 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 = DeviceEntry( + id=ZWAVE_POWER_DEVICE_ID, + name_by_user=ZWAVE_POWER_DEVICE_NAME, + area_id=ZWAVE_POWER_DEVICE_AREA, + ) + zwave_power_entry = 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 = await async_get_device_registry(hass) + ent_reg = await async_get_entity_registry(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 = await async_get_entity_registry(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_websocket_api.py b/tests/components/zwave/test_websocket_api.py index 25bc364a630994..9727906709ffe0 100644 --- a/tests/components/zwave/test_websocket_api.py +++ b/tests/components/zwave/test_websocket_api.py @@ -1,4 +1,6 @@ """Test Z-Wave Websocket API.""" +from unittest.mock import call, patch + from homeassistant.bootstrap import async_setup_component from homeassistant.components.zwave.const import ( CONF_AUTOHEAL, @@ -8,6 +10,8 @@ ) from homeassistant.components.zwave.websocket_api import ID, TYPE +NETWORK_KEY = "0xTE, 0xST, 0xTE, 0xST, 0xTE, 0xST, 0xTE, 0xST, 0xTE, 0xST, 0xTE, 0xST, 0xTE, 0xST, 0xTE, 0xST" + async def test_zwave_ws_api(hass, mock_openzwave, hass_ws_client): """Test Z-Wave websocket API.""" @@ -20,7 +24,7 @@ async def test_zwave_ws_api(hass, mock_openzwave, hass_ws_client): CONF_AUTOHEAL: False, CONF_USB_STICK_PATH: "/dev/zwave", CONF_POLLING_INTERVAL: 6000, - CONF_NETWORK_KEY: "0xTE, 0xST, 0xTE, 0xST, 0xTE, 0xST, 0xTE, 0xST, 0xTE, 0xST, 0xTE, 0xST, 0xTE, 0xST, 0xTE, 0xST", + CONF_NETWORK_KEY: NETWORK_KEY, } }, ) @@ -38,12 +42,47 @@ async def test_zwave_ws_api(hass, mock_openzwave, hass_ws_client): assert not result[CONF_AUTOHEAL] 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.""" + + await async_setup_component( + hass, + "zwave", + { + "zwave": { + CONF_AUTOHEAL: False, + CONF_USB_STICK_PATH: "/dev/zwave", + CONF_POLLING_INTERVAL: 6000, + CONF_NETWORK_KEY: NETWORK_KEY, + } + }, + ) + + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + await client.send_json({ID: 6, TYPE: "zwave/get_migration_config"}) msg = await client.receive_json() result = msg["result"] assert result[CONF_USB_STICK_PATH] == "/dev/zwave" - assert ( - result[CONF_NETWORK_KEY] - == "0xTE, 0xST, 0xTE, 0xST, 0xTE, 0xST, 0xTE, 0xST, 0xTE, 0xST, 0xTE, 0xST, 0xTE, 0xST, 0xTE, 0xST" + assert result[CONF_NETWORK_KEY] == NETWORK_KEY + + with patch( + "homeassistant.config_entries.ConfigEntriesFlowManager.async_init" + ) as async_init: + + async_init.return_value = {"flow_id": "mock_flow_id"} + await client.send_json({ID: 7, TYPE: "zwave/start_ozw_config_flow"}) + msg = await client.receive_json() + + result = msg["result"] + + assert result["flow_id"] == "mock_flow_id" + assert async_init.call_args == call( + "ozw", + context={"source": "import"}, + data={"usb_path": "/dev/zwave", "network_key": NETWORK_KEY}, ) diff --git a/tests/fixtures/ozw/migration_fixture.csv b/tests/fixtures/ozw/migration_fixture.csv new file mode 100644 index 00000000000000..92b68f448f697b --- /dev/null +++ b/tests/fixtures/ozw/migration_fixture.csv @@ -0,0 +1,9 @@ +OpenZWave/1/status/,{ "OpenZWave_Version": "1.6.1008", "OZWDeamon_Version": "0.1", "QTOpenZWave_Version": "1.0.0", "QT_Version": "5.12.5", "Status": "driverAllNodesQueried", "TimeStamp": 1579566933, "ManufacturerSpecificDBReady": true, "homeID": 3245146787, "getControllerNodeId": 1, "getSUCNodeId": 1, "isPrimaryController": true, "isBridgeController": false, "hasExtendedTXStatistics": true, "getControllerLibraryVersion": "Z-Wave 3.95", "getControllerLibraryType": "Static Controller", "getControllerPath": "/dev/zwave"} +OpenZWave/1/node/32/,{ "NodeID": 32, "NodeQueryStage": "Complete", "isListening": true, "isFlirs": false, "isBeaming": true, "isRouting": true, "isSecurityv1": false, "isZWavePlus": true, "isNIFRecieved": true, "isAwake": true, "isFailed": false, "MetaData": { "OZWInfoURL": "http://www.openzwave.com/device-database/0208:0005:0101", "ZWAProductURL": "", "ProductPic": "images/hank/hkzw-so01-smartplug.png", "Description": "fixture description", "WakeupHelp": "", "ProductSupportURL": "", "Frequency": "", "Name": "Smart Plug", "ProductPicBase64": "iVBORggg==" }, "Event": "nodeQueriesComplete", "TimeStamp": 1579566933, "NodeManufacturerName": "HANK Electronics Ltd", "NodeProductName": "HKZW-SO01 Smart Plug", "NodeBasicString": "Routing Slave", "NodeBasic": 4, "NodeGenericString": "Binary Switch", "NodeGeneric": 16, "NodeSpecificString": "Binary Power Switch", "NodeSpecific": 1, "NodeManufacturerID": "0x0208", "NodeProductType": "0x0101", "NodeProductID": "0x0005", "NodeBaudRate": 100000, "NodeVersion": 4, "NodeGroups": 1, "NodeName": "", "NodeLocation": "", "NodeDeviceTypeString": "On/Off Power Switch", "NodeDeviceType": 1792, "NodeRole": 5, "NodeRoleString": "Always On Slave", "NodePlusType": 0, "NodePlusTypeString": "Z-Wave+ node", "Neighbors": [ 1, 33, 36, 37, 39 ]} +OpenZWave/1/node/32/instance/1/,{ "Instance": 1, "TimeStamp": 1579566891} +OpenZWave/1/node/32/instance/1/commandclass/50/,{ "Instance": 1, "CommandClassId": 50, "CommandClass": "COMMAND_CLASS_METER", "TimeStamp": 1579566891} +OpenZWave/1/node/32/instance/1/commandclass/50/value/562950495305746/,{ "Label": "Electric - W", "Value": 0.0, "Units": "W", "Min": 0, "Max": 0, "Type": "Decimal", "Instance": 1, "CommandClass": "COMMAND_CLASS_METER", "Index": 2, "Node": 32, "Genre": "User", "Help": "", "ValueIDKey": 562950495305746, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/36/,{ "NodeID": 36, "NodeQueryStage": "CacheLoad", "isListening": false, "isFlirs": false, "isBeaming": true, "isRouting": true, "isSecurityv1": false, "isZWavePlus": false, "isNIFRecieved": true, "isAwake": true, "isFailed": false, "MetaData": { "OZWInfoURL": "http://www.openzwave.com/device-database/0086:007A:0102", "ZWAProductURL": "", "ProductPic": "images/aeotec/zw122.png", "Description": "fixture description", "WakeupHelp": "Pressing the Action Button once will trigger sending the Wake up notification command. If press and hold the Z-Wave button for 3 seconds, the Water Sensor will wake up for 10 minutes.", "ProductSupportURL": "", "Frequency": "", "Name": "Water Sensor 6", "ProductPicBase64": "kSuQmCC" }, "Event": "nodeNaming", "TimeStamp": 1579566891, "NodeManufacturerName": "AEON Labs", "NodeProductName": "ZW122 Water Sensor 6", "NodeBasicString": "Routing Slave", "NodeBasic": 4, "NodeGenericString": "Notification Sensor", "NodeGeneric": 7, "NodeSpecificString": "Notification Sensor", "NodeSpecific": 1, "NodeManufacturerID": "0x0086", "NodeProductType": "0x0102", "NodeProductID": "0x007a", "NodeBaudRate": 100000, "NodeVersion": 4, "NodeGroups": 4} +OpenZWave/1/node/36/instance/1/,{ "Instance": 1, "TimeStamp": 1579566891} +OpenZWave/1/node/36/instance/1/commandclass/128/,{ "Instance": 1, "CommandClassId": 128, "CommandClass": "COMMAND_CLASS_BATTERY", "TimeStamp": 1579566891} +OpenZWave/1/node/36/instance/1/commandclass/128/value/610271249/,{ "Label": "Battery Level", "Value": 100, "Units": "%", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_BATTERY", "Index": 0, "Node": 36, "Genre": "User", "Help": "Current Battery Level", "ValueIDKey": 610271249, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891}