mirror of https://github.com/home-assistant/core
Add support for USB discovery to zwave_js (#54938)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
parent
33f660118f
commit
a7d8e2b817
|
@ -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={
|
||||
|
|
|
@ -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"}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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?"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
|
@ -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", {})
|
||||
|
|
Loading…
Reference in New Issue