diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index ced8b2c68cb..6b0fc7b692e 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -12,8 +12,9 @@ import voluptuous as vol from zwave_js_server.version import VersionInfo, get_server_version from homeassistant import config_entries, exceptions +from homeassistant.components import usb from homeassistant.components.hassio import is_hassio -from homeassistant.const import CONF_URL +from homeassistant.const import CONF_NAME, CONF_URL from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import ( AbortFlow, @@ -286,6 +287,7 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): """Set up flow instance.""" super().__init__() self.use_addon = False + self._title: str | None = None @property def flow_manager(self) -> config_entries.ConfigEntriesFlowManager: @@ -309,6 +311,64 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_manual() + async def async_step_usb(self, discovery_info: dict[str, str]) -> FlowResult: + """Handle USB Discovery.""" + if not is_hassio(self.hass): + return self.async_abort(reason="discovery_requires_supervisor") + if self._async_current_entries(): + return self.async_abort(reason="already_configured") + if self._async_in_progress(): + return self.async_abort(reason="already_in_progress") + + vid = discovery_info["vid"] + pid = discovery_info["pid"] + serial_number = discovery_info["serial_number"] + device = discovery_info["device"] + manufacturer = discovery_info["manufacturer"] + description = discovery_info["description"] + # The Nortek sticks are a special case since they + # have a Z-Wave and a Zigbee radio. We need to reject + # the Zigbee radio. + if vid == "10C4" and pid == "8A2A" and "Z-Wave" not in description: + return self.async_abort(reason="not_zwave_device") + # Zooz uses this vid/pid, but so do 2652 sticks + if vid == "10C4" and pid == "EA60" and "2652" in description: + return self.async_abort(reason="not_zwave_device") + + addon_info = await self._async_get_addon_info() + if addon_info.state not in (AddonState.NOT_INSTALLED, AddonState.NOT_RUNNING): + return self.async_abort(reason="already_configured") + + await self.async_set_unique_id( + f"{vid}:{pid}_{serial_number}_{manufacturer}_{description}" + ) + self._abort_if_unique_id_configured() + dev_path = await self.hass.async_add_executor_job(usb.get_serial_by_id, device) + self.usb_path = dev_path + self._title = usb.human_readable_device_name( + dev_path, + serial_number, + manufacturer, + description, + vid, + pid, + ) + self.context["title_placeholders"] = {CONF_NAME: self._title} + return await self.async_step_usb_confirm() + + async def async_step_usb_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle USB Discovery confirmation.""" + if user_input is None: + return self.async_show_form( + step_id="usb_confirm", + description_placeholders={CONF_NAME: self._title}, + data_schema=vol.Schema({}), + ) + + return await self.async_step_on_supervisor({CONF_USE_ADDON: True}) + async def async_step_manual( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -352,6 +412,9 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): This flow is triggered by the Z-Wave JS add-on. """ + if self._async_in_progress(): + return self.async_abort(reason="already_in_progress") + self.ws_address = f"ws://{discovery_info['host']}:{discovery_info['port']}" try: version_info = await async_get_version_info(self.hass, self.ws_address) @@ -422,7 +485,7 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_start_addon() - usb_path = addon_config.get(CONF_ADDON_DEVICE, self.usb_path or "") + usb_path = addon_config.get(CONF_ADDON_DEVICE) or self.usb_path or "" network_key = addon_config.get(CONF_ADDON_NETWORK_KEY, self.network_key or "") data_schema = vol.Schema( @@ -446,7 +509,7 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): discovery_info = await self._async_get_addon_discovery_info() self.ws_address = f"ws://{discovery_info['host']}:{discovery_info['port']}" - if not self.unique_id: + if not self.unique_id or self.context["source"] == config_entries.SOURCE_USB: if not self.version_info: try: self.version_info = await async_get_version_info( @@ -471,6 +534,10 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): @callback def _async_create_entry_from_vars(self) -> FlowResult: """Return a config entry for the flow.""" + # Abort any other flows that may be in progress + for progress in self._async_in_progress(): + self.hass.config_entries.flow.async_abort(progress["flow_id"]) + return self.async_create_entry( title=TITLE, data={ diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 6f713ed2ef2..5c2d1f0db81 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -5,6 +5,11 @@ "documentation": "https://www.home-assistant.io/integrations/zwave_js", "requirements": ["zwave-js-server-python==0.29.0"], "codeowners": ["@home-assistant/z-wave"], - "dependencies": ["http", "websocket_api"], - "iot_class": "local_push" + "dependencies": ["usb", "http", "websocket_api"], + "iot_class": "local_push", + "usb": [ + {"vid":"0658","pid":"0200"}, + {"vid":"10C4","pid":"8A2A"}, + {"vid":"10C4","pid":"EA60"} + ] } diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index cc5e241c09e..d0bdec1a80c 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -1,11 +1,15 @@ { "config": { + "flow_title": "{name}", "step": { "manual": { "data": { "url": "[%key:common::config_flow::data::url%]" } }, + "usb_confirm": { + "description": "Do you want to setup {name} with the Z-Wave JS add-on?" + }, "on_supervisor": { "title": "Select connection method", "description": "Do you want to use the Z-Wave JS Supervisor add-on?", @@ -44,7 +48,9 @@ "addon_set_config_failed": "Failed to set Z-Wave JS configuration.", "addon_start_failed": "Failed to start the Z-Wave JS add-on.", "addon_get_discovery_info_failed": "Failed to get Z-Wave JS add-on discovery info.", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "discovery_requires_supervisor": "Discovery requires the supervisor.", + "not_zwave_device": "Discovered device is not a Z-Wave device." }, "progress": { "install_addon": "Please wait while the Z-Wave JS add-on installation finishes. This can take several minutes.", diff --git a/homeassistant/components/zwave_js/translations/en.json b/homeassistant/components/zwave_js/translations/en.json index 7e366724f40..6f5d08933db 100644 --- a/homeassistant/components/zwave_js/translations/en.json +++ b/homeassistant/components/zwave_js/translations/en.json @@ -8,7 +8,9 @@ "addon_start_failed": "Failed to start the Z-Wave JS add-on.", "already_configured": "Device is already configured", "already_in_progress": "Configuration flow is already in progress", - "cannot_connect": "Failed to connect" + "cannot_connect": "Failed to connect", + "discovery_requires_supervisor": "Discovery requires the supervisor.", + "not_zwave_device": "Discovered device is not a Z-Wave device." }, "error": { "addon_start_failed": "Failed to start the Z-Wave JS add-on. Check the configuration.", @@ -16,6 +18,7 @@ "invalid_ws_url": "Invalid websocket URL", "unknown": "Unexpected error" }, + "flow_title": "{name}", "progress": { "install_addon": "Please wait while the Z-Wave JS add-on installation finishes. This can take several minutes.", "start_addon": "Please wait while the Z-Wave JS add-on start completes. This may take some seconds." @@ -48,6 +51,9 @@ }, "start_addon": { "title": "The Z-Wave JS add-on is starting." + }, + "usb_confirm": { + "description": "Do you want to setup {name} with the Z-Wave JS add-on?" } } }, diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index 717640ce2f8..cb672c736b2 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -25,5 +25,20 @@ USB = [ "domain": "zha", "vid": "10C4", "pid": "8A2A" + }, + { + "domain": "zwave_js", + "vid": "0658", + "pid": "0200" + }, + { + "domain": "zwave_js", + "vid": "10C4", + "pid": "8A2A" + }, + { + "domain": "zwave_js", + "vid": "10C4", + "pid": "EA60" } ] diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 7d02c215d45..393de228d87 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -20,6 +20,34 @@ ADDON_DISCOVERY_INFO = { } +USB_DISCOVERY_INFO = { + "device": "/dev/zwave", + "pid": "AAAA", + "vid": "AAAA", + "serial_number": "1234", + "description": "zwave radio", + "manufacturer": "test", +} + +NORTEK_ZIGBEE_DISCOVERY_INFO = { + "device": "/dev/zigbee", + "pid": "8A2A", + "vid": "10C4", + "serial_number": "1234", + "description": "nortek zigbee radio", + "manufacturer": "nortek", +} + +CP2652_ZIGBEE_DISCOVERY_INFO = { + "device": "/dev/zigbee", + "pid": "EA60", + "vid": "10C4", + "serial_number": "", + "description": "cp2652", + "manufacturer": "generic", +} + + @pytest.fixture(name="persistent_notification", autouse=True) async def setup_persistent_notification(hass): """Set up persistent notification integration.""" @@ -383,6 +411,94 @@ async def test_abort_discovery_with_existing_entry( assert entry.data["url"] == "ws://host1:3001" +async def test_abort_hassio_discovery_with_existing_flow( + hass, supervisor, addon_options +): + """Test hassio discovery flow is aborted when another discovery has happened.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=USB_DISCOVERY_INFO, + ) + assert result["type"] == "form" + assert result["step_id"] == "usb_confirm" + + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=ADDON_DISCOVERY_INFO, + ) + + assert result2["type"] == "abort" + assert result2["reason"] == "already_in_progress" + + +async def test_usb_discovery( + hass, + supervisor, + install_addon, + addon_options, + get_addon_discovery_info, + set_addon_options, + start_addon, +): + """Test usb discovery success path.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=USB_DISCOVERY_INFO, + ) + assert result["type"] == "form" + assert result["step_id"] == "usb_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == "progress" + assert result["step_id"] == "install_addon" + + # Make sure the flow continues when the progress task is done. + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert install_addon.call_args == call(hass, "core_zwave_js") + + assert result["type"] == "form" + assert result["step_id"] == "configure_addon" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"usb_path": "/test", "network_key": "abc123"} + ) + + assert set_addon_options.call_args == call( + hass, "core_zwave_js", {"options": {"device": "/test", "network_key": "abc123"}} + ) + + 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"]["usb_path"] == "/test" + assert result["data"]["integration_created_addon"] is True + assert result["data"]["use_addon"] is True + assert result["data"]["network_key"] == "abc123" + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_discovery_addon_not_running( hass, supervisor, addon_installed, addon_options, set_addon_options, start_addon ): @@ -512,6 +628,84 @@ async def test_discovery_addon_not_installed( assert len(mock_setup_entry.mock_calls) == 1 +async def test_abort_usb_discovery_with_existing_flow(hass, supervisor, addon_options): + """Test usb discovery flow is aborted when another discovery has happened.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=ADDON_DISCOVERY_INFO, + ) + + assert result["type"] == "form" + assert result["step_id"] == "hassio_confirm" + + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=USB_DISCOVERY_INFO, + ) + assert result2["type"] == "abort" + assert result2["reason"] == "already_in_progress" + + +async def test_abort_usb_discovery_already_configured(hass, supervisor, addon_options): + """Test usb discovery flow is aborted when there is an existing entry.""" + entry = MockConfigEntry( + domain=DOMAIN, data={"url": "ws://localhost:3000"}, title=TITLE, unique_id=1234 + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=USB_DISCOVERY_INFO, + ) + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +async def test_usb_discovery_requires_supervisor(hass): + """Test usb discovery flow is aborted when there is no supervisor.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=USB_DISCOVERY_INFO, + ) + assert result["type"] == "abort" + assert result["reason"] == "discovery_requires_supervisor" + + +async def test_usb_discovery_already_running(hass, supervisor, addon_running): + """Test usb discovery flow is aborted when the addon is running.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=USB_DISCOVERY_INFO, + ) + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + "discovery_info", + [ + NORTEK_ZIGBEE_DISCOVERY_INFO, + CP2652_ZIGBEE_DISCOVERY_INFO, + ], +) +async def test_abort_usb_discovery_aborts_specific_devices( + hass, supervisor, addon_options, discovery_info +): + """Test usb discovery flow is aborted on specific devices.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USB}, + data=discovery_info, + ) + assert result["type"] == "abort" + assert result["reason"] == "not_zwave_device" + + async def test_not_addon(hass, supervisor): """Test opting out of add-on on Supervisor.""" await setup.async_setup_component(hass, "persistent_notification", {})