Add support for USB discovery to zwave_js (#54938)

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
J. Nick Koston 2021-08-21 09:30:45 -05:00 committed by GitHub
parent 33f660118f
commit a7d8e2b817
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 300 additions and 7 deletions

View File

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

View File

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

View File

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

View File

@ -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?"
}
}
},

View File

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

View File

@ -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", {})