Generic ZHA Zeroconf discovery (#126294)

This commit is contained in:
puddly 2024-11-21 14:50:21 -05:00 committed by GitHub
parent e9286971aa
commit 50fdbe9b3b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 231 additions and 127 deletions

View File

@ -70,8 +70,17 @@ UPLOADED_BACKUP_FILE = "uploaded_backup_file"
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(
@ -617,34 +626,65 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
# Hostname is format: livingroom.local.
local_name = discovery_info.hostname[:-1]
port = discovery_info.port or DEFAULT_ZHA_ZEROCONF_PORT
# 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 local_name and port == ESPHOME_API_PORT:
port = DEFAULT_ZHA_ZEROCONF_PORT
# Fix incorrect port for older TubesZB devices
if "tube" in name and port == LEGACY_ZEROCONF_ESPHOME_API_PORT:
port = LEGACY_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"]
# 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()

View File

@ -130,6 +130,10 @@
{
"type": "_czc._tcp.local.",
"name": "czc*"
},
{
"type": "_zigbee-coordinator._tcp.local.",
"name": "*"
}
]
}

View File

@ -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": {

View File

@ -872,6 +872,12 @@ ZEROCONF = {
"name": "*zigate*",
},
],
"_zigbee-coordinator._tcp.local.": [
{
"domain": "zha",
"name": "*",
},
],
"_zigstar_gw._tcp.local.": [
{
"domain": "zha",

View File

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