diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 60807471e0376bf7fb0a8003cd93446201be5731..3da785cdcf22dfc594d30d550bb132ad83319a0b 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -8,6 +8,7 @@ from typing import Any import aiohttp from async_timeout import timeout +from serial.tools import list_ports import voluptuous as vol from zwave_js_server.version import VersionInfo, get_server_version @@ -119,6 +120,30 @@ async def async_get_version_info(hass: HomeAssistant, ws_address: str) -> Versio return version_info +def get_usb_ports() -> dict[str, str]: + """Return a dict of USB ports and their friendly names.""" + ports = list_ports.comports() + port_descriptions = {} + for port in ports: + usb_device = usb.usb_device_from_port(port) + dev_path = usb.get_serial_by_id(usb_device.device) + human_name = usb.human_readable_device_name( + dev_path, + usb_device.serial_number, + usb_device.manufacturer, + usb_device.description, + usb_device.vid, + usb_device.pid, + ) + port_descriptions[dev_path] = human_name + return port_descriptions + + +async def async_get_usb_ports(hass: HomeAssistant) -> dict[str, str]: + """Return a dict of USB ports and their friendly names.""" + return await hass.async_add_executor_job(get_usb_ports) + + class BaseZwaveJSFlow(FlowHandler): """Represent the base config flow for Z-Wave JS.""" @@ -402,7 +427,9 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): vid, pid, ) - self.context["title_placeholders"] = {CONF_NAME: self._title} + self.context["title_placeholders"] = { + CONF_NAME: self._title.split(" - ")[0].strip() + } return await self.async_step_usb_confirm() async def async_step_usb_confirm( @@ -579,7 +606,11 @@ class ConfigFlow(BaseZwaveJSFlow, config_entries.ConfigFlow, domain=DOMAIN): } if not self._usb_discovery: - schema = {vol.Required(CONF_USB_PATH, default=usb_path): str, **schema} + ports = await async_get_usb_ports(self.hass) + schema = { + vol.Required(CONF_USB_PATH, default=usb_path): vol.In(ports), + **schema, + } data_schema = vol.Schema(schema) @@ -801,9 +832,11 @@ class OptionsFlowHandler(BaseZwaveJSFlow, config_entries.OptionsFlow): log_level = addon_config.get(CONF_ADDON_LOG_LEVEL, "info") emulate_hardware = addon_config.get(CONF_ADDON_EMULATE_HARDWARE, False) + ports = await async_get_usb_ports(self.hass) + data_schema = vol.Schema( { - vol.Required(CONF_USB_PATH, default=usb_path): str, + vol.Required(CONF_USB_PATH, default=usb_path): vol.In(ports), vol.Optional(CONF_S0_LEGACY_KEY, default=s0_legacy_key): str, vol.Optional( CONF_S2_ACCESS_CONTROL_KEY, default=s2_access_control_key diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 29be66cd02467ae3f762ce159221da4c2742b925..9ee08b0505d050bd737d435b2c734172f0a24a67 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -3,7 +3,7 @@ "name": "Z-Wave", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave_js", - "requirements": ["zwave-js-server-python==0.40.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.40.0"], "codeowners": ["@home-assistant/z-wave"], "dependencies": ["usb", "http", "websocket_api"], "iot_class": "local_push", diff --git a/requirements_all.txt b/requirements_all.txt index 44075fdf14a69ffd8b911fefa2ca3f2a780bc5e0..7e07889d05f0705f14d78f194b4228b9c0a882eb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1815,6 +1815,7 @@ pyserial-asyncio==0.6 # homeassistant.components.crownstone # homeassistant.components.usb # homeassistant.components.zha +# homeassistant.components.zwave_js pyserial==3.5 # homeassistant.components.sesame diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5470bf91d7362847929a91ef0ebbcd2fe96d5dbe..e04b0d7cea5f21319a34d95cfa9a243e4fc98e9a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1250,6 +1250,7 @@ pyserial-asyncio==0.6 # homeassistant.components.crownstone # homeassistant.components.usb # homeassistant.components.zha +# homeassistant.components.zwave_js pyserial==3.5 # homeassistant.components.sia diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index a8a2c6c7191490973292e1e2272e7e550d59d53c..6c4b18e8dc3ef47abf1a68865babb05e982f9c12 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -1,9 +1,12 @@ """Test the Z-Wave JS config flow.""" import asyncio -from unittest.mock import DEFAULT, call, patch +from collections.abc import Generator +from copy import copy +from unittest.mock import DEFAULT, MagicMock, call, patch import aiohttp import pytest +from serial.tools.list_ports_common import ListPortInfo from zwave_js_server.version import VersionInfo from homeassistant import config_entries @@ -134,6 +137,45 @@ def mock_addon_setup_time(): yield addon_setup_time +@pytest.fixture(name="serial_port") +def serial_port_fixture() -> ListPortInfo: + """Return a mock serial port.""" + port = ListPortInfo("/test", skip_link_detection=True) + port.serial_number = "1234" + port.manufacturer = "Virtual serial port" + port.device = "/test" + port.description = "Some serial port" + port.pid = 9876 + port.vid = 5678 + + return port + + +@pytest.fixture(name="mock_list_ports", autouse=True) +def mock_list_ports_fixture(serial_port) -> Generator[MagicMock, None, None]: + """Mock list ports.""" + with patch( + "homeassistant.components.zwave_js.config_flow.list_ports.comports" + ) as mock_list_ports: + another_port = copy(serial_port) + another_port.device = "/new" + another_port.description = "New serial port" + another_port.serial_number = "5678" + another_port.pid = 8765 + mock_list_ports.return_value = [serial_port, another_port] + yield mock_list_ports + + +@pytest.fixture(name="mock_usb_serial_by_id", autouse=True) +def mock_usb_serial_by_id_fixture() -> Generator[MagicMock, None, None]: + """Mock usb serial by id.""" + with patch( + "homeassistant.components.zwave_js.config_flow.usb.get_serial_by_id" + ) as mock_usb_serial_by_id: + mock_usb_serial_by_id.side_effect = lambda x: x + yield mock_usb_serial_by_id + + async def test_manual(hass): """Test we create an entry with manual step.""" @@ -1397,7 +1439,7 @@ async def test_addon_installed_already_configured( result = await hass.config_entries.flow.async_configure( result["flow_id"], { - "usb_path": "/test_new", + "usb_path": "/new", "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -1410,7 +1452,7 @@ async def test_addon_installed_already_configured( "core_zwave_js", { "options": { - "device": "/test_new", + "device": "/new", "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -1430,7 +1472,7 @@ async def test_addon_installed_already_configured( assert result["type"] == "abort" assert result["reason"] == "already_configured" assert entry.data["url"] == "ws://host1:3001" - assert entry.data["usb_path"] == "/test_new" + assert entry.data["usb_path"] == "/new" assert entry.data["s0_legacy_key"] == "new123" assert entry.data["s2_access_control_key"] == "new456" assert entry.data["s2_authenticated_key"] == "new789" @@ -2380,8 +2422,10 @@ async def test_import_addon_installed( set_addon_options, start_addon, get_addon_discovery_info, + serial_port, ): """Test import step while add-on already installed on Supervisor.""" + serial_port.device = "/test/imported" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT},