diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 20eb006eb74c9..fdf7af7d5369e 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -69,8 +69,17 @@ REPAIR_MY_URL = "https://my.home-assistant.io/redirect/repairs/" -DEFAULT_ZHA_ZEROCONF_PORT = 6638 -ESPHOME_API_PORT = 6053 +LEGACY_ZEROCONF_PORT = 6638 +LEGACY_ZEROCONF_ESPHOME_API_PORT = 6053 + +ZEROCONF_SERVICE_TYPE = "_zigbee-coordinator._tcp.local." +ZEROCONF_PROPERTIES_SCHEMA = vol.Schema( + { + vol.Required("radio_type"): vol.All(str, vol.In([t.name for t in RadioType])), + vol.Required("serial_number"): str, + }, + extra=vol.ALLOW_EXTRA, +) def _format_backup_choice( @@ -615,34 +624,65 @@ async def async_step_zeroconf( ) -> ConfigFlowResult: """Handle zeroconf discovery.""" - # Hostname is format: livingroom.local. - local_name = discovery_info.hostname[:-1] - port = discovery_info.port or DEFAULT_ZHA_ZEROCONF_PORT - - # Fix incorrect port for older TubesZB devices - if "tube" in local_name and port == ESPHOME_API_PORT: - port = DEFAULT_ZHA_ZEROCONF_PORT - - if "radio_type" in discovery_info.properties: - self._radio_mgr.radio_type = self._radio_mgr.parse_radio_type( - discovery_info.properties["radio_type"] + # Transform legacy zeroconf discovery into the new format + if discovery_info.type != ZEROCONF_SERVICE_TYPE: + port = discovery_info.port or LEGACY_ZEROCONF_PORT + name = discovery_info.name + + # Fix incorrect port for older TubesZB devices + if "tube" in name and port == LEGACY_ZEROCONF_ESPHOME_API_PORT: + port = LEGACY_ZEROCONF_PORT + + # Determine the radio type + if "radio_type" in discovery_info.properties: + radio_type = discovery_info.properties["radio_type"] + elif "efr32" in name: + radio_type = RadioType.ezsp.name + elif "zigate" in name: + radio_type = RadioType.zigate.name + else: + radio_type = RadioType.znp.name + + fallback_title = name.split("._", 1)[0] + title = discovery_info.properties.get("name", fallback_title) + + discovery_info = zeroconf.ZeroconfServiceInfo( + ip_address=discovery_info.ip_address, + ip_addresses=discovery_info.ip_addresses, + port=port, + hostname=discovery_info.hostname, + type=ZEROCONF_SERVICE_TYPE, + name=f"{title}.{ZEROCONF_SERVICE_TYPE}", + properties={ + "radio_type": radio_type, + # To maintain backwards compatibility + "serial_number": discovery_info.hostname.removesuffix(".local."), + }, ) - elif "efr32" in local_name: - self._radio_mgr.radio_type = RadioType.ezsp - else: - self._radio_mgr.radio_type = RadioType.znp - node_name = local_name.removesuffix(".local") - device_path = f"socket://{discovery_info.host}:{port}" + try: + discovery_props = ZEROCONF_PROPERTIES_SCHEMA(discovery_info.properties) + except vol.Invalid: + return self.async_abort(reason="invalid_zeroconf_data") + + radio_type = self._radio_mgr.parse_radio_type(discovery_props["radio_type"]) + device_path = f"socket://{discovery_info.host}:{discovery_info.port}" + title = discovery_info.name.removesuffix(f".{ZEROCONF_SERVICE_TYPE}") await self._set_unique_id_and_update_ignored_flow( - unique_id=node_name, + unique_id=discovery_props["serial_number"], device_path=device_path, ) - self.context["title_placeholders"] = {CONF_NAME: node_name} - self._title = device_path + 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 = { + CONF_DEVICE_PATH: device_path, + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: None, + } return await self.async_step_confirm() diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 2bda92c664832..00869f447c92c 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -130,6 +130,10 @@ { "type": "_czc._tcp.local.", "name": "czc*" + }, + { + "type": "_zigbee-coordinator._tcp.local.", + "name": "*" } ] } diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index d0505bf2460a9..de30fa8b4eb6e 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -76,7 +76,8 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "not_zha_device": "This device is not a zha device", "usb_probe_failed": "Failed to probe the usb device", - "wrong_firmware_installed": "Your device is running the wrong firmware and cannot be used with ZHA until the correct firmware is installed. [A repair has been created]({repair_url}) with more information and instructions for how to fix this." + "wrong_firmware_installed": "Your device is running the wrong firmware and cannot be used with ZHA until the correct firmware is installed. [A repair has been created]({repair_url}) with more information and instructions for how to fix this.", + "invalid_zeroconf_data": "The coordinator has invalid zeroconf service info and cannot be identified by ZHA" } }, "options": { diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 1fbd6337fdb0f..5f7161a8245f1 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -872,6 +872,12 @@ "name": "*zigate*", }, ], + "_zigbee-coordinator._tcp.local.": [ + { + "domain": "zha", + "name": "*", + }, + ], "_zigstar_gw._tcp.local.": [ { "domain": "zha", diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 1382c5c2569e7..28b26aac82b7a 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -154,104 +154,180 @@ def com_port(device="/dev/ttyUSB1234") -> ListPortInfo: return port +@pytest.mark.parametrize( + ("entry_name", "unique_id", "radio_type", "service_info"), + [ + ( + # TubesZB, old ESPHome devices (ZNP) + "tubeszb-cc2652-poe", + "tubeszb-cc2652-poe", + RadioType.znp, + zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.200"), + ip_addresses=[ip_address("192.168.1.200")], + hostname="tubeszb-cc2652-poe.local.", + name="tubeszb-cc2652-poe._esphomelib._tcp.local.", + port=6053, # the ESPHome API port is remapped to 6638 + type="_esphomelib._tcp.local.", + properties={ + "project_version": "3.0", + "project_name": "tubezb.cc2652-poe", + "network": "ethernet", + "board": "esp32-poe", + "platform": "ESP32", + "maс": "8c4b14c33c24", + "version": "2023.12.8", + }, + ), + ), + ( + # TubesZB, old ESPHome device (EFR32) + "tubeszb-efr32-poe", + "tubeszb-efr32-poe", + RadioType.ezsp, + zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.200"), + ip_addresses=[ip_address("192.168.1.200")], + hostname="tubeszb-efr32-poe.local.", + name="tubeszb-efr32-poe._esphomelib._tcp.local.", + port=6053, # the ESPHome API port is remapped to 6638 + type="_esphomelib._tcp.local.", + properties={ + "project_version": "3.0", + "project_name": "tubezb.efr32-poe", + "network": "ethernet", + "board": "esp32-poe", + "platform": "ESP32", + "maс": "8c4b14c33c24", + "version": "2023.12.8", + }, + ), + ), + ( + # TubesZB, newer devices + "TubeZB", + "tubeszb-cc2652-poe", + RadioType.znp, + zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.200"), + ip_addresses=[ip_address("192.168.1.200")], + hostname="tubeszb-cc2652-poe.local.", + name="tubeszb-cc2652-poe._tubeszb._tcp.local.", + port=6638, + properties={ + "name": "TubeZB", + "radio_type": "znp", + "version": "1.0", + "baud_rate": "115200", + "data_flow_control": "software", + }, + type="_tubeszb._tcp.local.", + ), + ), + ( + # Expected format for all new devices + "Some Zigbee Gateway (12345)", + "aabbccddeeff", + RadioType.znp, + zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.200"), + ip_addresses=[ip_address("192.168.1.200")], + hostname="some-zigbee-gateway-12345.local.", + name="Some Zigbee Gateway (12345)._zigbee-coordinator._tcp.local.", + port=6638, + properties={"radio_type": "znp", "serial_number": "aabbccddeeff"}, + type="_zigbee-coordinator._tcp.local.", + ), + ), + ], +) @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) @patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) -async def test_zeroconf_discovery_znp(hass: HomeAssistant) -> None: +@patch(f"bellows.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) +async def test_zeroconf_discovery( + entry_name: str, + unique_id: str, + radio_type: RadioType, + service_info: zeroconf.ZeroconfServiceInfo, + hass: HomeAssistant, +) -> None: """Test zeroconf flow -- radio detected.""" - service_info = zeroconf.ZeroconfServiceInfo( - ip_address=ip_address("192.168.1.200"), - ip_addresses=[ip_address("192.168.1.200")], - hostname="tube._tube_zb_gw._tcp.local.", - name="tube", - port=6053, - properties={"name": "tube_123456"}, - type="mock_type", - ) - flow = await hass.config_entries.flow.async_init( + result_init = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=service_info ) - assert flow["step_id"] == "confirm" - - # Confirm discovery - result1 = await hass.config_entries.flow.async_configure( - flow["flow_id"], user_input={} - ) - assert result1["step_id"] == "manual_port_config" + assert result_init["step_id"] == "confirm" # Confirm port settings - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], user_input={} + result_confirm = await hass.config_entries.flow.async_configure( + result_init["flow_id"], user_input={} ) - assert result2["type"] is FlowResultType.MENU - assert result2["step_id"] == "choose_formation_strategy" + assert result_confirm["type"] is FlowResultType.MENU + assert result_confirm["step_id"] == "choose_formation_strategy" - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result_form = await hass.config_entries.flow.async_configure( + result_confirm["flow_id"], user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS}, ) await hass.async_block_till_done() - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == "socket://192.168.1.200:6638" - assert result3["data"] == { + 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: { CONF_BAUDRATE: 115200, CONF_FLOW_CONTROL: None, CONF_DEVICE_PATH: "socket://192.168.1.200:6638", }, - CONF_RADIO_TYPE: "znp", + CONF_RADIO_TYPE: radio_type.name, } @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) @patch(f"zigpy_zigate.{PROBE_FUNCTION_PATH}") -async def test_zigate_via_zeroconf(setup_entry_mock, hass: HomeAssistant) -> None: +async def test_legacy_zeroconf_discovery_zigate( + setup_entry_mock, hass: HomeAssistant +) -> None: """Test zeroconf flow -- zigate radio detected.""" service_info = zeroconf.ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], - hostname="_zigate-zigbee-gateway._tcp.local.", - name="any", + hostname="_zigate-zigbee-gateway.local.", + name="some name._zigate-zigbee-gateway._tcp.local.", port=1234, - properties={"radio_type": "zigate"}, + properties={}, type="mock_type", ) - flow = await hass.config_entries.flow.async_init( + result_init = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=service_info ) - assert flow["step_id"] == "confirm" - - # Confirm discovery - result1 = await hass.config_entries.flow.async_configure( - flow["flow_id"], user_input={} - ) - assert result1["step_id"] == "manual_port_config" + assert result_init["step_id"] == "confirm" # Confirm the radio is deprecated - result2 = await hass.config_entries.flow.async_configure( - flow["flow_id"], user_input={} + result_confirm_deprecated = await hass.config_entries.flow.async_configure( + result_init["flow_id"], user_input={} ) - assert result2["step_id"] == "verify_radio" - assert "ZiGate" in result2["description_placeholders"]["name"] + assert result_confirm_deprecated["step_id"] == "verify_radio" + assert "ZiGate" in result_confirm_deprecated["description_placeholders"]["name"] # Confirm port settings - result3 = await hass.config_entries.flow.async_configure( - result1["flow_id"], user_input={} + result_confirm = await hass.config_entries.flow.async_configure( + result_confirm_deprecated["flow_id"], user_input={} ) - assert result3["type"] is FlowResultType.MENU - assert result3["step_id"] == "choose_formation_strategy" + assert result_confirm["type"] is FlowResultType.MENU + assert result_confirm["step_id"] == "choose_formation_strategy" - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], + result_form = await hass.config_entries.flow.async_configure( + result_confirm["flow_id"], user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS}, ) await hass.async_block_till_done() - assert result4["type"] is FlowResultType.CREATE_ENTRY - assert result4["title"] == "socket://192.168.1.200:1234" - assert result4["data"] == { + 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", CONF_BAUDRATE: 115200, @@ -261,75 +337,50 @@ async def test_zigate_via_zeroconf(setup_entry_mock, hass: HomeAssistant) -> Non } -@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) -@patch(f"bellows.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) -async def test_efr32_via_zeroconf(hass: HomeAssistant) -> None: - """Test zeroconf flow -- efr32 radio detected.""" +async def test_zeroconf_discovery_bad_payload(hass: HomeAssistant) -> None: + """Test zeroconf flow with a bad payload.""" service_info = zeroconf.ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), ip_addresses=[ip_address("192.168.1.200")], - hostname="efr32._esphomelib._tcp.local.", - name="efr32", + hostname="some.hostname", + name="any", port=1234, - properties={}, - type="mock_type", + properties={"radio_type": "some bogus radio"}, + type="_zigbee-coordinator._tcp.local.", ) - flow = await hass.config_entries.flow.async_init( + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=service_info ) - assert flow["step_id"] == "confirm" - - # Confirm discovery - result1 = await hass.config_entries.flow.async_configure( - flow["flow_id"], user_input={} - ) - assert result1["step_id"] == "manual_port_config" - - # Confirm port settings - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], user_input={} - ) - - assert result2["type"] is FlowResultType.MENU - assert result2["step_id"] == "choose_formation_strategy" - - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS}, - ) - await hass.async_block_till_done() - - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == "socket://192.168.1.200:1234" - assert result3["data"] == { - CONF_DEVICE: { - CONF_DEVICE_PATH: "socket://192.168.1.200:1234", - CONF_BAUDRATE: 115200, - CONF_FLOW_CONTROL: None, - }, - CONF_RADIO_TYPE: "ezsp", - } + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "invalid_zeroconf_data" @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) @patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) -async def test_discovery_via_zeroconf_ip_change_ignored(hass: HomeAssistant) -> None: +async def test_legacy_zeroconf_discovery_ip_change_ignored(hass: HomeAssistant) -> None: """Test zeroconf flow that was ignored gets updated.""" + entry = MockConfigEntry( domain=DOMAIN, - unique_id="tube_zb_gw_cc2652p2_poe", + unique_id="tubeszb-cc2652-poe", source=config_entries.SOURCE_IGNORE, ) entry.add_to_hass(hass) service_info = zeroconf.ZeroconfServiceInfo( - ip_address=ip_address("192.168.1.22"), - ip_addresses=[ip_address("192.168.1.22")], - hostname="tube_zb_gw_cc2652p2_poe.local.", - name="mock_name", - port=6053, - properties={"address": "tube_zb_gw_cc2652p2_poe.local"}, - type="mock_type", + ip_address=ip_address("192.168.1.200"), + ip_addresses=[ip_address("192.168.1.200")], + hostname="tubeszb-cc2652-poe.local.", + name="tubeszb-cc2652-poe._tubeszb._tcp.local.", + port=6638, + properties={ + "name": "TubeZB", + "radio_type": "znp", + "version": "1.0", + "baud_rate": "115200", + "data_flow_control": "software", + }, + type="_tubeszb._tcp.local.", ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=service_info @@ -338,11 +389,13 @@ async def test_discovery_via_zeroconf_ip_change_ignored(hass: HomeAssistant) -> assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" assert entry.data[CONF_DEVICE] == { - CONF_DEVICE_PATH: "socket://192.168.1.22:6638", + CONF_DEVICE_PATH: "socket://192.168.1.200:6638", } -async def test_discovery_confirm_final_abort_if_entries(hass: HomeAssistant) -> None: +async def test_legacy_zeroconf_discovery_confirm_final_abort_if_entries( + hass: HomeAssistant, +) -> None: """Test discovery aborts if ZHA was set up after the confirmation dialog is shown.""" service_info = zeroconf.ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"), @@ -677,7 +730,7 @@ async def test_discovery_via_usb_zha_ignored_updates(hass: HomeAssistant) -> Non @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) @patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) -async def test_discovery_already_setup(hass: HomeAssistant) -> None: +async def test_legacy_zeroconf_discovery_already_setup(hass: HomeAssistant) -> None: """Test zeroconf flow -- radio detected.""" service_info = zeroconf.ZeroconfServiceInfo( ip_address=ip_address("192.168.1.200"),