From 0bd859db5810d27709ea0e091c60519ca0e1c531 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:15:21 -0400 Subject: [PATCH 1/7] Migrate ZHA to use the serial port selector --- homeassistant/components/zha/config_flow.py | 154 +++----------------- 1 file changed, 23 insertions(+), 131 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 7af174686d8cf..e863b97f31dfb 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -9,7 +9,6 @@ from enum import StrEnum import json import logging -import os from typing import Any import voluptuous as vol @@ -20,17 +19,10 @@ from homeassistant.components import onboarding, usb from homeassistant.components.file_upload import process_uploaded_file -from homeassistant.components.hassio import AddonError, AddonState -from homeassistant.components.homeassistant_hardware import silabs_multiprotocol_addon from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( ZigbeeFlowStrategy, ) -from homeassistant.components.homeassistant_yellow import hardware as yellow_hardware -from homeassistant.components.usb import ( - SerialDevice, - USBDevice, - async_scan_serial_ports, -) +from homeassistant.components.usb import async_scan_serial_ports from homeassistant.config_entries import ( SOURCE_IGNORE, SOURCE_ZEROCONF, @@ -46,8 +38,11 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.hassio import is_hassio -from homeassistant.helpers.selector import FileSelector, FileSelectorConfig +from homeassistant.helpers.selector import ( + FileSelector, + FileSelectorConfig, + SerialPortSelector, +) from homeassistant.helpers.service_info.usb import UsbServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.util import dt as dt_util @@ -64,7 +59,6 @@ _LOGGER = logging.getLogger(__name__) -CONF_MANUAL_PATH = "Enter Manually" DECONZ_DOMAIN = "deconz" # The ZHA config flow takes different branches depending on if you are migrating to a @@ -107,12 +101,6 @@ extra=vol.ALLOW_EXTRA, ) -# USB devices to ignore in serial port selection (non-Zigbee devices) -# Format: (manufacturer, description) -IGNORED_USB_DEVICES = { - ("Nabu Casa", "ZWA-2"), -} - class OptionsMigrationIntent(StrEnum): """Zigbee options flow intents.""" @@ -138,76 +126,6 @@ def _format_backup_choice( return f"{dt_util.as_local(backup.backup_time).strftime('%c')} ({identifier})" -def _format_serial_port_choice( - serial_port: USBDevice | SerialDevice, resolved_paths: dict[str, str] -) -> str: - """Format a serial port selector entry into a line of text.""" - text = resolved_paths[serial_port.device] - - if serial_port.description: - text += f" - {serial_port.description}" - - if serial_port.serial_number: - text += f", s/n: {serial_port.serial_number}" - - if serial_port.manufacturer: - text += f" - {serial_port.manufacturer}" - - return text - - -async def list_serial_ports(hass: HomeAssistant) -> list[USBDevice | SerialDevice]: - """List all serial ports, including the Yellow radio and the multi-PAN addon.""" - ports: list[USBDevice | SerialDevice] = [] - ports.extend(await async_scan_serial_ports(hass)) - - # Add useful info to the Yellow's serial port selection screen - try: - yellow_hardware.async_info(hass) - except HomeAssistantError: - pass - else: - # PySerial does not properly handle the Yellow's serial port with the CM5 - # so we manually include it - port = SerialDevice( - device="/dev/ttyAMA1", - serial_number=None, - manufacturer="Nabu Casa", - description="Yellow Zigbee module", - ) - - ports = [p for p in ports if not p.device.startswith("/dev/ttyAMA")] - ports.insert(0, port) - - if is_hassio(hass): - # Present the multi-PAN addon as a setup option, if it's available - multipan_manager = ( - await silabs_multiprotocol_addon.get_multiprotocol_addon_manager(hass) - ) - - try: - addon_info = await multipan_manager.async_get_addon_info() - except AddonError, KeyError: - addon_info = None - - if addon_info is not None and addon_info.state != AddonState.NOT_INSTALLED: - addon_port = SerialDevice( - device=silabs_multiprotocol_addon.get_zigbee_socket(), - serial_number=None, - manufacturer="Nabu Casa", - description="Silicon Labs Multiprotocol add-on", - ) - - ports.append(addon_port) - - # Filter out ignored USB devices - return [ - port - for port in ports - if (port.manufacturer, port.description) not in IGNORED_USB_DEVICES - ] - - class BaseZhaFlow(ConfigEntryBaseFlow): """Mixin for common ZHA flow steps and forms.""" @@ -272,29 +190,9 @@ async def async_step_choose_serial_port( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Choose a serial port.""" - ports = await list_serial_ports(self.hass) - - # The full `/dev/serial/by-id/` path is too verbose to show - resolved_paths = { - p.device: await self.hass.async_add_executor_job(os.path.realpath, p.device) - for p in ports - } - - list_of_ports = [_format_serial_port_choice(p, resolved_paths) for p in ports] - - if not list_of_ports: - return await self.async_step_manual_pick_radio_type() - - list_of_ports.append(CONF_MANUAL_PATH) - if user_input is not None: - user_selection = user_input[CONF_DEVICE_PATH] - - if user_selection == CONF_MANUAL_PATH: - return await self.async_step_manual_pick_radio_type() - - port = ports[list_of_ports.index(user_selection)] - self._radio_mgr.device_path = port.device + device_path = user_input[CONF_DEVICE_PATH] + self._radio_mgr.device_path = device_path probe_result = await self._radio_mgr.detect_radio_type() if probe_result == ProbeResult.WRONG_FIRMWARE_INSTALLED: @@ -303,34 +201,28 @@ async def async_step_choose_serial_port( description_placeholders={"repair_url": REPAIR_MY_URL}, ) if probe_result == ProbeResult.PROBING_FAILED: - # Did not autodetect anything, proceed to manual selection + # Did not autodetect anything, proceed to manual radio type return await self.async_step_manual_pick_radio_type() - self._title = ( - f"{port.description}{', s/n: ' + port.serial_number if port.serial_number else ''}" - f" - {port.manufacturer}" - if port.manufacturer - else "" - ) + ports = await async_scan_serial_ports(self.hass) + port = next((p for p in ports if p.device == device_path), None) + if port is not None and port.manufacturer: + self._title = ( + f"{port.description or ''}" + f"{', s/n: ' + port.serial_number if port.serial_number else ''}" + f" - {port.manufacturer}" + ) + else: + self._title = device_path return await self.async_step_verify_radio() - # Preselect the currently configured port - default_port: vol.Undefined | str = vol.UNDEFINED - - if self._radio_mgr.device_path is not None: - for description, port in zip(list_of_ports, ports, strict=False): - if port.device == self._radio_mgr.device_path: - default_port = description - break - else: - default_port = CONF_MANUAL_PATH - + default_path = self._radio_mgr.device_path or vol.UNDEFINED schema = vol.Schema( { - vol.Required(CONF_DEVICE_PATH, default=default_port): vol.In( - list_of_ports - ) + vol.Required( + CONF_DEVICE_PATH, default=default_path + ): SerialPortSelector(), } ) return self.async_show_form(step_id="choose_serial_port", data_schema=schema) From 6dab0951a811a03fb6761a3067a45a74ef3d8f91 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:20:12 -0400 Subject: [PATCH 2/7] Drop unnecessary title --- homeassistant/components/zha/config_flow.py | 51 +++++++++------------ 1 file changed, 22 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index e863b97f31dfb..b6343b1488b17 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -22,7 +22,6 @@ from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( ZigbeeFlowStrategy, ) -from homeassistant.components.usb import async_scan_serial_ports from homeassistant.config_entries import ( SOURCE_IGNORE, SOURCE_ZEROCONF, @@ -132,7 +131,6 @@ class BaseZhaFlow(ConfigEntryBaseFlow): _flow_strategy: ZigbeeFlowStrategy | None = None _overwrite_ieee_during_restore: bool = False _hass: HomeAssistant - _title: str def __init__(self) -> None: """Initialize flow instance.""" @@ -204,17 +202,6 @@ async def async_step_choose_serial_port( # Did not autodetect anything, proceed to manual radio type return await self.async_step_manual_pick_radio_type() - ports = await async_scan_serial_ports(self.hass) - port = next((p for p in ports if p.device == device_path), None) - if port is not None and port.manufacturer: - self._title = ( - f"{port.description or ''}" - f"{', s/n: ' + port.serial_number if port.serial_number else ''}" - f" - {port.manufacturer}" - ) - else: - self._title = device_path - return await self.async_step_verify_radio() default_path = self._radio_mgr.device_path or vol.UNDEFINED @@ -260,7 +247,6 @@ async def async_step_manual_port_config( errors = {} if user_input is not None: - self._title = user_input[CONF_DEVICE_PATH] self._radio_mgr.device_path = user_input[CONF_DEVICE_PATH] self._radio_mgr.device_settings = DEVICE_SCHEMA( { @@ -870,7 +856,11 @@ async def async_step_confirm( return self.async_show_form( step_id="confirm", - description_placeholders={CONF_NAME: self._title}, + description_placeholders={ + CONF_NAME: self.context.get("title_placeholders", {}).get( + CONF_NAME, self._radio_mgr.device_path or "" + ) + }, ) async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: @@ -896,15 +886,17 @@ async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResu return self.async_abort(reason="not_zha_device") self._radio_mgr.device_path = dev_path - self._title = description or usb.human_readable_device_name( - dev_path, - serial_number, - manufacturer, - description, - vid, - pid, - ) - self.context["title_placeholders"] = {CONF_NAME: self._title} + self.context["title_placeholders"] = { + CONF_NAME: description + or usb.human_readable_device_name( + dev_path, + serial_number, + manufacturer, + description, + vid, + pid, + ) + } return await self.async_step_confirm() async def async_step_zeroconf( @@ -963,7 +955,6 @@ async def async_step_zeroconf( ) self.context["title_placeholders"] = {CONF_NAME: title} - self._title = title self._radio_mgr.device_path = device_path self._radio_mgr.radio_type = radio_type self._radio_mgr.device_settings = DEVICE_SCHEMA( @@ -996,7 +987,6 @@ async def async_step_hardware( device_path=device_path, ) - self._title = name self._radio_mgr.radio_type = radio_type self._radio_mgr.device_path = device_path self._radio_mgr.device_settings = device_settings @@ -1013,11 +1003,15 @@ async def _async_create_radio_entry(self) -> ConfigFlowResult: DOMAIN, include_ignore=False ) data = self._get_config_entry_data() + title = ( + self.context.get("title_placeholders", {}).get(CONF_NAME) + or self._radio_mgr.device_path + ) if len(zha_config_entries) == 1: return self.async_update_reload_and_abort( entry=zha_config_entries[0], - title=self._title, + title=title, data=data, reload_even_if_entry_is_unchanged=True, reason="reconfigure_successful", @@ -1033,7 +1027,7 @@ async def _async_create_radio_entry(self) -> ConfigFlowResult: await self.async_set_unique_id(unique_id) return self.async_create_entry( - title=self._title, + title=title, data=data, ) # This should never be reached @@ -1051,7 +1045,6 @@ def __init__(self, config_entry: ConfigEntry) -> None: self._radio_mgr.device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] self._radio_mgr.device_settings = config_entry.data[CONF_DEVICE] self._radio_mgr.radio_type = RadioType[config_entry.data[CONF_RADIO_TYPE]] - self._title = config_entry.title async def async_step_init( self, user_input: dict[str, Any] | None = None From 71634c0d81b0e74715e5d7e6700a5dea775743ac Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:26:56 -0400 Subject: [PATCH 3/7] Clean up unnecessary tests --- tests/components/zha/test_config_flow.py | 305 ++--------------------- 1 file changed, 16 insertions(+), 289 deletions(-) diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 0d6bae972803e..084f9b8b30712 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -31,7 +31,6 @@ import zigpy.types from homeassistant import config_entries -from homeassistant.components.hassio import AddonError, AddonState from homeassistant.components.usb import SerialDevice, USBDevice from homeassistant.components.zha import config_flow, radio_manager from homeassistant.components.zha.const import ( @@ -1068,21 +1067,16 @@ async def test_zeroconf_not_onboarded(hass: HomeAssistant) -> None: "homeassistant.components.zha.radio_manager.ZhaRadioManager.detect_radio_type", mock_detect_radio_type(radio_type=RadioType.deconz), ) -@patch( - "homeassistant.components.zha.config_flow.list_serial_ports", - AsyncMock(return_value=[usb_port()]), -) async def test_user_flow(hass: HomeAssistant) -> None: """Test user flow -- radio detected.""" port = usb_port() - port_select = f"{port.device} - {port.description}, s/n: {port.serial_number} - {port.manufacturer}" result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data={ - zigpy.config.CONF_DEVICE_PATH: port_select, + zigpy.config.CONF_DEVICE_PATH: port.device, }, ) assert result["type"] is FlowResultType.MENU @@ -1102,7 +1096,7 @@ async def test_user_flow(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"].startswith(port.description) + assert result2["title"] == port.device assert result2["data"] == { "device": { "path": port.device, @@ -1117,21 +1111,16 @@ async def test_user_flow(hass: HomeAssistant) -> None: "homeassistant.components.zha.radio_manager.ZhaRadioManager.detect_radio_type", AsyncMock(return_value=ProbeResult.PROBING_FAILED), ) -@patch( - "homeassistant.components.zha.config_flow.list_serial_ports", - AsyncMock(return_value=[usb_port()]), -) async def test_user_flow_not_detected(hass: HomeAssistant) -> None: """Test user flow, radio not detected.""" port = com_port() - port_select = f"{port.device} - {port.description}, s/n: {port.serial_number} - {port.manufacturer}" result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data={ - zigpy.config.CONF_DEVICE_PATH: port_select, + zigpy.config.CONF_DEVICE_PATH: port.device, CONF_BAUDRATE: 115200, CONF_FLOW_CONTROL: None, }, @@ -1141,10 +1130,6 @@ async def test_user_flow_not_detected(hass: HomeAssistant) -> None: assert result["step_id"] == "manual_pick_radio_type" -@patch( - "homeassistant.components.zha.config_flow.list_serial_ports", - AsyncMock(return_value=[usb_port()]), -) async def test_user_flow_show_form(hass: HomeAssistant) -> None: """Test user step form.""" result = await hass.config_entries.flow.async_init( @@ -1156,34 +1141,6 @@ async def test_user_flow_show_form(hass: HomeAssistant) -> None: assert result["step_id"] == "choose_serial_port" -@pytest.mark.usefixtures("addon_not_installed") -@patch( - "homeassistant.components.zha.config_flow.list_serial_ports", - AsyncMock(return_value=[]), -) -async def test_user_flow_show_manual(hass: HomeAssistant) -> None: - """Test user flow manual entry when no comport detected.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_USER}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "manual_pick_radio_type" - - -async def test_user_flow_manual(hass: HomeAssistant) -> None: - """Test user flow manual entry.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_USER}, - data={zigpy.config.CONF_DEVICE_PATH: config_flow.CONF_MANUAL_PATH}, - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "manual_pick_radio_type" - - @pytest.mark.parametrize("radio_type", RadioType.list()) async def test_pick_radio_flow(hass: HomeAssistant, radio_type) -> None: """Test radio picker.""" @@ -1701,7 +1658,6 @@ def advanced_pick_radio(hass: HomeAssistant) -> Generator[RadioPicker]: async def wrapper(radio_type: RadioType) -> ConfigFlowResult: port = com_port() - port_select = f"{port.device} - {port.description}, s/n: {port.serial_number} - {port.manufacturer}" with patch( "homeassistant.components.zha.radio_manager.ZhaRadioManager.detect_radio_type", @@ -1711,7 +1667,7 @@ async def wrapper(radio_type: RadioType) -> ConfigFlowResult: DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data={ - zigpy.config.CONF_DEVICE_PATH: port_select, + zigpy.config.CONF_DEVICE_PATH: port.device, }, ) @@ -1728,13 +1684,7 @@ async def wrapper(radio_type: RadioType) -> ConfigFlowResult: return advanced_strategy_result - p1 = patch( - "homeassistant.components.zha.config_flow.list_serial_ports", - AsyncMock(return_value=[usb_port()]), - ) - p2 = patch("homeassistant.components.zha.async_setup_entry") - - with p1, p2: + with patch("homeassistant.components.zha.async_setup_entry"): yield wrapper @@ -2317,16 +2267,6 @@ async def test_options_flow_creates_backup( ("none", None), ], ) -@patch( - "homeassistant.components.zha.config_flow.list_serial_ports", - AsyncMock( - return_value=[ - usb_port("/dev/SomePort"), - usb_port("/dev/ttyUSB0"), - usb_port("/dev/SomeOtherPort"), - ] - ), -) @patch("homeassistant.components.zha.async_setup_entry", return_value=True) async def test_options_flow_defaults( async_setup_entry, @@ -2472,15 +2412,6 @@ async def test_options_flow_defaults( assert async_setup_entry.call_count == 1 -@patch( - "homeassistant.components.zha.config_flow.list_serial_ports", - AsyncMock( - return_value=[ - usb_port("/dev/SomePort"), - usb_port("/dev/SomeOtherPort"), - ] - ), -) @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) async def test_options_flow_defaults_socket(hass: HomeAssistant) -> None: """Test options flow defaults work even for serial ports that can't be listed.""" @@ -2518,13 +2449,17 @@ async def test_options_flow_defaults_socket(hass: HomeAssistant) -> None: user_input={"next_step_id": config_flow.OptionsMigrationIntent.RECONFIGURE}, ) - # Radio path must be manually entered + # The existing device path is the default assert result2["step_id"] == "choose_serial_port" - assert result2["data_schema"]({})[CONF_DEVICE_PATH] == config_flow.CONF_MANUAL_PATH + assert result2["data_schema"]({})[CONF_DEVICE_PATH] == "socket://localhost:5678" - result3 = await hass.config_entries.options.async_configure( - flow["flow_id"], user_input={} - ) + with patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager.detect_radio_type", + AsyncMock(return_value=ProbeResult.PROBING_FAILED), + ): + result3 = await hass.config_entries.options.async_configure( + flow["flow_id"], user_input={} + ) # Current radio type is the default assert result3["step_id"] == "manual_pick_radio_type" @@ -2556,10 +2491,6 @@ async def test_options_flow_defaults_socket(hass: HomeAssistant) -> None: assert result5["step_id"] == "choose_migration_strategy" -@patch( - "homeassistant.components.zha.config_flow.list_serial_ports", - AsyncMock(return_value=[usb_port()]), -) @patch("homeassistant.components.zha.async_setup_entry", return_value=True) async def test_options_flow_restarts_running_zha_if_cancelled( async_setup_entry, hass: HomeAssistant @@ -2656,10 +2587,6 @@ async def test_options_flow_migration_reset_old_adapter( "homeassistant.components.zha.radio_manager.ZhaRadioManager.detect_radio_type", return_value=ProbeResult.RADIO_TYPE_DETECTED, ), - patch( - "homeassistant.components.zha.config_flow.list_serial_ports", - AsyncMock(return_value=[usb_port("/dev/ttyUSB_new")]), - ), patch( "homeassistant.components.zha.radio_manager.ZhaRadioManager._async_read_backups_from_database", return_value=[backup], @@ -2675,9 +2602,7 @@ async def test_options_flow_migration_reset_old_adapter( result_port = await hass.config_entries.options.async_configure( flow["flow_id"], - user_input={ - CONF_DEVICE_PATH: "/dev/ttyUSB_new - Some serial port, s/n: 1234 - Virtual serial port" - }, + user_input={CONF_DEVICE_PATH: "/dev/ttyUSB_new"}, ) assert result_port["step_id"] == "choose_migration_strategy" @@ -2715,10 +2640,6 @@ async def test_options_flow_migration_reset_old_adapter( assert entry.data["device"]["path"] == "/dev/ttyUSB_new" -@patch( - "homeassistant.components.zha.config_flow.list_serial_ports", - AsyncMock(return_value=[usb_port()]), -) @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) async def test_options_flow_reconfigure_no_reset( hass: HomeAssistant, backup, mock_app @@ -2761,10 +2682,6 @@ async def test_options_flow_reconfigure_no_reset( "homeassistant.components.zha.radio_manager.ZhaRadioManager.detect_radio_type", return_value=ProbeResult.RADIO_TYPE_DETECTED, ), - patch( - "homeassistant.components.zha.config_flow.list_serial_ports", - AsyncMock(return_value=[usb_port("/dev/ttyUSB_new")]), - ), patch( "homeassistant.components.zha.radio_manager.ZhaRadioManager._async_read_backups_from_database", return_value=[backup], @@ -2780,9 +2697,7 @@ async def test_options_flow_reconfigure_no_reset( result_port = await hass.config_entries.options.async_configure( flow["flow_id"], - user_input={ - CONF_DEVICE_PATH: "/dev/ttyUSB_new - Some serial port, s/n: 1234 - Virtual serial port" - }, + user_input={CONF_DEVICE_PATH: "/dev/ttyUSB_new"}, ) assert result_port["step_id"] == "choose_migration_strategy" @@ -2814,194 +2729,6 @@ async def test_options_flow_reconfigure_no_reset( assert entry.data["device"]["path"] == "/dev/ttyUSB_new" -@pytest.mark.parametrize( - "device", - [ - "/dev/ttyAMA1", # CM4 - "/dev/ttyAMA10", # CM5, erroneously detected by pyserial - ], -) -async def test_config_flow_port_yellow_port_name( - hass: HomeAssistant, device: str -) -> None: - """Test config flow serial port name for Yellow Zigbee radio.""" - # Create a USB device with the parametrized device path - port = USBDevice( - device=device, - vid="10C4", - pid="EA60", - serial_number=None, - manufacturer=None, - description=None, - ) - - with ( - patch("homeassistant.components.zha.config_flow.yellow_hardware.async_info"), - patch( - "homeassistant.components.zha.config_flow.async_scan_serial_ports", - return_value=[port], - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_USER}, - ) - - # list_serial_ports replaces all /dev/ttyAMA* with the Yellow port at /dev/ttyAMA1 - assert ( - result["data_schema"].schema["path"].container[0] - == "/dev/ttyAMA1 - Yellow Zigbee module - Nabu Casa" - ) - - -async def test_config_flow_ports_no_hassio(hass: HomeAssistant) -> None: - """Test config flow serial port name when this is not a hassio install.""" - - with ( - patch("homeassistant.components.zha.config_flow.is_hassio", return_value=False), - patch( - "homeassistant.components.zha.config_flow.async_scan_serial_ports", - return_value=[], - ), - ): - ports = await config_flow.list_serial_ports(hass) - - assert ports == [] - - -async def test_config_flow_port_multiprotocol_port_name(hass: HomeAssistant) -> None: - """Test config flow serial port name for multiprotocol add-on.""" - - with ( - patch("homeassistant.components.zha.config_flow.is_hassio", return_value=True), - patch( - "homeassistant.components.hassio.addon_manager.AddonManager.async_get_addon_info" - ) as async_get_addon_info, - patch( - "homeassistant.components.zha.config_flow.async_scan_serial_ports", - return_value=[], - ), - ): - async_get_addon_info.return_value.state = AddonState.RUNNING - async_get_addon_info.return_value.hostname = "core-silabs-multiprotocol" - ports = await config_flow.list_serial_ports(hass) - - assert len(ports) == 1 - assert ports[0].description == "Silicon Labs Multiprotocol add-on" - assert ports[0].manufacturer == "Nabu Casa" - assert ports[0].device == "socket://core-silabs-multiprotocol:9999" - - -async def test_config_flow_port_no_multiprotocol(hass: HomeAssistant) -> None: - """Test config flow serial port listing when addon info fails to load.""" - - with ( - patch("homeassistant.components.zha.config_flow.is_hassio", return_value=True), - patch( - "homeassistant.components.hassio.addon_manager.AddonManager.async_get_addon_info", - new_callable=DelayedAsyncMock, - side_effect=AddonError, - ), - patch( - "homeassistant.components.zha.config_flow.async_scan_serial_ports", - return_value=[], - ), - ): - ports = await config_flow.list_serial_ports(hass) - - assert ports == [] - - -async def test_list_serial_ports_ignored_devices(hass: HomeAssistant) -> None: - """Test that list_serial_ports filters out ignored non-Zigbee devices.""" - mock_ports = [ - USBDevice( - device="/dev/ttyUSB0", - vid="303A", - pid="4001", - serial_number="1234", - manufacturer="Nabu Casa", - description="ZWA-2", - ), - USBDevice( - device="/dev/ttyUSB1", - vid="303A", - pid="4001", - serial_number="1235", - manufacturer="Nabu Casa", - description="ZBT-2", - ), - USBDevice( - device="/dev/ttyUSB2", - vid="10C4", - pid="EA60", - serial_number="1236", - manufacturer="Nabu Casa", - description="Home Assistant Connect ZBT-1", - ), - USBDevice( - device="/dev/ttyUSB3", - vid="10C4", - pid="EA60", - serial_number="1237", - manufacturer="Nabu Casa", - description="SkyConnect v1.0", - ), - USBDevice( - device="/dev/ttyUSB4", - vid="1234", - pid="5678", - serial_number="1238", - manufacturer="Another Manufacturer", - description="Zigbee USB Adapter", - ), - USBDevice( - device="/dev/ttyUSB5", - vid="1234", - pid="5678", - serial_number=None, - manufacturer=None, - description=None, - ), - ] - - with ( - patch("homeassistant.components.zha.config_flow.is_hassio", return_value=False), - patch( - "homeassistant.components.zha.config_flow.async_scan_serial_ports", - return_value=mock_ports, - ), - ): - ports = await config_flow.list_serial_ports(hass) - - # ZWA-2 should be filtered out, others should remain - assert len(ports) == 5 - - assert ports[0].device == "/dev/ttyUSB1" - assert ports[0].manufacturer == "Nabu Casa" - assert ports[0].description == "ZBT-2" - - assert ports[1].device == "/dev/ttyUSB2" - assert ports[1].manufacturer == "Nabu Casa" - assert ports[1].description == "Home Assistant Connect ZBT-1" - - assert ports[2].device == "/dev/ttyUSB3" - assert ports[2].manufacturer == "Nabu Casa" - assert ports[2].description == "SkyConnect v1.0" - - assert ports[3].device == "/dev/ttyUSB4" - assert ports[3].manufacturer == "Another Manufacturer" - assert ports[3].description == "Zigbee USB Adapter" - - assert ports[4].device == "/dev/ttyUSB5" - assert ports[4].manufacturer is None - assert ports[4].description is None - - -@patch( - "homeassistant.components.zha.config_flow.list_serial_ports", - AsyncMock(return_value=[usb_port()]), -) async def test_probe_wrong_firmware_installed(hass: HomeAssistant) -> None: """Test auto-probing failing because the wrong firmware is installed.""" From 90cdf2511fe3d7790147d582ee167ff3c0b1457b Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:30:14 -0400 Subject: [PATCH 4/7] Fix pre-commit --- homeassistant/components/zha/config_flow.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index b6343b1488b17..872b5ed66f7e2 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -1003,7 +1003,8 @@ async def _async_create_radio_entry(self) -> ConfigFlowResult: DOMAIN, include_ignore=False ) data = self._get_config_entry_data() - title = ( + assert self._radio_mgr.device_path is not None + title: str = ( self.context.get("title_placeholders", {}).get(CONF_NAME) or self._radio_mgr.device_path ) From bde7036d676cba76565046103928983fede8cbf8 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:43:28 -0400 Subject: [PATCH 5/7] Drop remaining title --- homeassistant/components/zha/config_flow.py | 6 +----- tests/components/zha/test_config_flow.py | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 872b5ed66f7e2..4a561653993bd 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -1003,11 +1003,7 @@ async def _async_create_radio_entry(self) -> ConfigFlowResult: DOMAIN, include_ignore=False ) data = self._get_config_entry_data() - assert self._radio_mgr.device_path is not None - title: str = ( - self.context.get("title_placeholders", {}).get(CONF_NAME) - or self._radio_mgr.device_path - ) + title = self.context.get("title_placeholders", {}).get(CONF_NAME, "") if len(zha_config_entries) == 1: return self.async_update_reload_and_abort( diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 084f9b8b30712..a1a02b511b3f2 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -1096,7 +1096,7 @@ async def test_user_flow(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == port.device + assert result2["title"] == "" assert result2["data"] == { "device": { "path": port.device, From d04ce13fd0425cf9d7bfe2853aca4562d7146cf9 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 24 Apr 2026 19:29:46 -0400 Subject: [PATCH 6/7] Preserve titles for reconfigure flows but drop them everywhere else --- homeassistant/components/zha/config_flow.py | 7 +------ tests/components/zha/test_config_flow.py | 13 +++---------- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 4a561653993bd..4a58d56dfd4d8 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -1003,12 +1003,10 @@ async def _async_create_radio_entry(self) -> ConfigFlowResult: DOMAIN, include_ignore=False ) data = self._get_config_entry_data() - title = self.context.get("title_placeholders", {}).get(CONF_NAME, "") if len(zha_config_entries) == 1: return self.async_update_reload_and_abort( entry=zha_config_entries[0], - title=title, data=data, reload_even_if_entry_is_unchanged=True, reason="reconfigure_successful", @@ -1023,10 +1021,7 @@ async def _async_create_radio_entry(self) -> ConfigFlowResult: ) await self.async_set_unique_id(unique_id) - return self.async_create_entry( - title=title, - data=data, - ) + return self.async_create_entry(title="", data=data) # This should never be reached return self.async_abort(reason="single_instance_allowed") diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index a1a02b511b3f2..f0515ec1c5ff9 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -346,7 +346,6 @@ async def test_zeroconf_discovery( await hass.async_block_till_done() assert result_form["type"] is FlowResultType.CREATE_ENTRY - assert result_form["title"] == entry_name assert result_form["context"]["unique_id"] == unique_id assert result_form["data"] == { CONF_DEVICE: { @@ -406,7 +405,6 @@ async def test_legacy_zeroconf_discovery_zigate( await hass.async_block_till_done() assert result_form["type"] is FlowResultType.CREATE_ENTRY - assert result_form["title"] == "some name" assert result_form["data"] == { CONF_DEVICE: { CONF_DEVICE_PATH: "socket://192.168.1.200:1234", @@ -547,7 +545,6 @@ async def test_discovery_via_usb(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == "zigbee radio" assert result3["data"] == { "device": { "baudrate": 115200, @@ -1096,7 +1093,6 @@ async def test_user_flow(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "" assert result2["data"] == { "device": { "path": port.device, @@ -1299,7 +1295,6 @@ async def test_hardware_not_onboarded(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result_create["title"] == "Yellow" assert result_create["data"] == { CONF_DEVICE: { CONF_BAUDRATE: 115200, @@ -1353,7 +1348,6 @@ async def test_hardware_no_flow_strategy(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result_create["title"] == "Yellow" assert result_create["data"] == { CONF_DEVICE: { CONF_BAUDRATE: 115200, @@ -1408,7 +1402,6 @@ async def test_hardware_flow_strategy_advanced(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result_create["type"] is FlowResultType.CREATE_ENTRY - assert result_create["title"] == "Yellow" assert result_create["data"] == { CONF_DEVICE: { CONF_BAUDRATE: 115200, @@ -1455,7 +1448,6 @@ async def test_hardware_flow_strategy_recommended(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result_create["type"] is FlowResultType.CREATE_ENTRY - assert result_create["title"] == "Yellow" assert result_create["data"] == { CONF_DEVICE: { CONF_BAUDRATE: 115200, @@ -1827,7 +1819,6 @@ async def form_network_side_effect(*args, **kwargs): await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "zigbee radio" assert result["data"] == { "device": { "baudrate": 115200, @@ -2649,6 +2640,7 @@ async def test_options_flow_reconfigure_no_reset( entry = MockConfigEntry( version=config_flow.ZhaConfigFlowHandler.VERSION, domain=DOMAIN, + title="My custom Zigbee name", data={ CONF_DEVICE: { CONF_DEVICE_PATH: "/dev/ttyUSB_old", @@ -2727,6 +2719,8 @@ async def test_options_flow_reconfigure_no_reset( # The entry is updated assert entry.data["device"]["path"] == "/dev/ttyUSB_new" + # The user-customized title is preserved across reconfigure + assert entry.title == "My custom Zigbee name" async def test_probe_wrong_firmware_installed(hass: HomeAssistant) -> None: @@ -3334,7 +3328,6 @@ async def remove_entry_during_reset(): # Since config entry was removed, flow skipped to maybe_confirm_ezsp_restore # and restored backup, creating a new entry in the end assert result_recommended["type"] is FlowResultType.CREATE_ENTRY - assert result_recommended["title"] == "zigbee radio" assert result_recommended["data"] == { "device": { "baudrate": 115200, From 1d7c50ddab8558c56266ee29260c82d1a2ec56b1 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:45:33 -0400 Subject: [PATCH 7/7] Reinstate tests to check for empty title --- tests/components/zha/test_config_flow.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index f0515ec1c5ff9..71f0923e00907 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -346,6 +346,7 @@ async def test_zeroconf_discovery( await hass.async_block_till_done() assert result_form["type"] is FlowResultType.CREATE_ENTRY + assert result_form["title"] == "" assert result_form["context"]["unique_id"] == unique_id assert result_form["data"] == { CONF_DEVICE: { @@ -405,6 +406,7 @@ async def test_legacy_zeroconf_discovery_zigate( await hass.async_block_till_done() assert result_form["type"] is FlowResultType.CREATE_ENTRY + assert result_form["title"] == "" assert result_form["data"] == { CONF_DEVICE: { CONF_DEVICE_PATH: "socket://192.168.1.200:1234", @@ -545,6 +547,7 @@ async def test_discovery_via_usb(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == "" assert result3["data"] == { "device": { "baudrate": 115200, @@ -1093,6 +1096,7 @@ async def test_user_flow(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "" assert result2["data"] == { "device": { "path": port.device, @@ -1295,6 +1299,7 @@ async def test_hardware_not_onboarded(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() + assert result_create["title"] == "" assert result_create["data"] == { CONF_DEVICE: { CONF_BAUDRATE: 115200, @@ -1348,6 +1353,7 @@ async def test_hardware_no_flow_strategy(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() + assert result_create["title"] == "" assert result_create["data"] == { CONF_DEVICE: { CONF_BAUDRATE: 115200, @@ -1402,6 +1408,7 @@ async def test_hardware_flow_strategy_advanced(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result_create["type"] is FlowResultType.CREATE_ENTRY + assert result_create["title"] == "" assert result_create["data"] == { CONF_DEVICE: { CONF_BAUDRATE: 115200, @@ -1448,6 +1455,7 @@ async def test_hardware_flow_strategy_recommended(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result_create["type"] is FlowResultType.CREATE_ENTRY + assert result_create["title"] == "" assert result_create["data"] == { CONF_DEVICE: { CONF_BAUDRATE: 115200, @@ -1819,6 +1827,7 @@ async def form_network_side_effect(*args, **kwargs): await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "" assert result["data"] == { "device": { "baudrate": 115200, @@ -3328,6 +3337,7 @@ async def remove_entry_during_reset(): # Since config entry was removed, flow skipped to maybe_confirm_ezsp_restore # and restored backup, creating a new entry in the end assert result_recommended["type"] is FlowResultType.CREATE_ENTRY + assert result_recommended["title"] == "" assert result_recommended["data"] == { "device": { "baudrate": 115200,