From c7f05602088da6a29156ea12a64790998dd1a575 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 8 Sep 2025 09:22:12 +0200 Subject: [PATCH 1/2] Deprecate object_id and instead suggest to use default_entity_id to set the suggested entity_id in MQTT entity configurations (#151775) --- .../components/mqtt/abbreviations.py | 1 + homeassistant/components/mqtt/const.py | 1 + homeassistant/components/mqtt/entity.py | 59 ++++++++++++++++++- homeassistant/components/mqtt/schemas.py | 2 + homeassistant/components/mqtt/strings.json | 4 ++ tests/components/mqtt/test_button.py | 2 +- tests/components/mqtt/test_discovery.py | 52 ++++++++++++++-- tests/components/mqtt/test_mixins.py | 40 ++++++++++++- tests/components/mqtt/test_notify.py | 2 +- 9 files changed, 150 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index f0d000f79dba57..89857efc149264 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -41,6 +41,7 @@ "curr_hum_tpl": "current_humidity_template", "curr_temp_t": "current_temperature_topic", "curr_temp_tpl": "current_temperature_template", + "def_ent_id": "default_entity_id", "dev": "device", "dev_cla": "device_class", "dir_cmd_t": "direction_command_topic", diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index d1feb25b281399..90f484b1a90ef5 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -38,6 +38,7 @@ CONF_CODE_TRIGGER_REQUIRED = "code_trigger_required" CONF_COMMAND_TEMPLATE = "command_template" CONF_COMMAND_TOPIC = "command_topic" +CONF_DEFAULT_ENTITY_ID = "default_entity_id" CONF_DISCOVERY_PREFIX = "discovery_prefix" CONF_ENCODING = "encoding" CONF_JSON_ATTRS_TOPIC = "json_attributes_topic" diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index f0e7f915551b18..ff4532381ce33c 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -29,6 +29,7 @@ CONF_MODEL_ID, CONF_NAME, CONF_UNIQUE_ID, + CONF_URL, CONF_VALUE_TEMPLATE, ) from homeassistant.core import Event, HassJobType, HomeAssistant, callback @@ -74,6 +75,7 @@ CONF_AVAILABILITY_TOPIC, CONF_CONFIGURATION_URL, CONF_CONNECTIONS, + CONF_DEFAULT_ENTITY_ID, CONF_ENABLED_BY_DEFAULT, CONF_ENCODING, CONF_ENTITY_PICTURE, @@ -83,6 +85,7 @@ CONF_JSON_ATTRS_TOPIC, CONF_MANUFACTURER, CONF_OBJECT_ID, + CONF_ORIGIN, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, @@ -1406,12 +1409,62 @@ def __init__( ensure_via_device_exists(self.hass, self.device_info, self._config_entry) def _init_entity_id(self) -> None: - """Set entity_id from object_id if defined in config.""" - if CONF_OBJECT_ID not in self._config: + """Set entity_id from default_entity_id if defined in config.""" + object_id: str + default_entity_id: str | None + # Setting the default entity_id through the CONF_OBJECT_ID is deprecated + # Support will be removed with HA Core 2026.4 + if ( + CONF_DEFAULT_ENTITY_ID not in self._config + and CONF_OBJECT_ID not in self._config + ): return + if (default_entity_id := self._config.get(CONF_DEFAULT_ENTITY_ID)) is None: + object_id = self._config[CONF_OBJECT_ID] + else: + _, _, object_id = default_entity_id.partition(".") self.entity_id = async_generate_entity_id( - self._entity_id_format, self._config[CONF_OBJECT_ID], None, self.hass + self._entity_id_format, object_id, None, self.hass ) + if CONF_OBJECT_ID in self._config: + domain = self.entity_id.split(".")[0] + if not self._discovery: + async_create_issue( + self.hass, + DOMAIN, + self.entity_id, + issue_domain=DOMAIN, + is_fixable=False, + breaks_in_ha_version="2026.4", + severity=IssueSeverity.WARNING, + learn_more_url=f"{learn_more_url(domain)}#default_enity_id", + translation_placeholders={ + "entity_id": self.entity_id, + "object_id": self._config[CONF_OBJECT_ID], + "domain": domain, + }, + translation_key="deprecated_object_id", + ) + else: + if CONF_ORIGIN in self._config: + origin_name = self._config[CONF_ORIGIN][CONF_NAME] + url = self._config[CONF_ORIGIN].get(CONF_URL) + origin = f"[{origin_name}]({url})" if url else origin_name + else: + origin = "the integration" + _LOGGER.warning( + "The configuration for entity %s uses the deprecated option " + "`object_id` to set the default entity id. Replace the " + '`"object_id": "%s"` option with `"default_entity_id": ' + '"%s"` in your published discovery configuration to fix this ' + "issue, or contact the maintainer of %s that published this config " + "to fix this. This will stop working in Home Assistant Core 2026.4", + self.entity_id, + self._config[CONF_OBJECT_ID], + f"{domain}.{self._config[CONF_OBJECT_ID]}", + origin, + ) + if self.unique_id is None: return # Check for previous deleted entities diff --git a/homeassistant/components/mqtt/schemas.py b/homeassistant/components/mqtt/schemas.py index 5e942c24738e34..0a9609dfc6d7c9 100644 --- a/homeassistant/components/mqtt/schemas.py +++ b/homeassistant/components/mqtt/schemas.py @@ -32,6 +32,7 @@ CONF_COMPONENTS, CONF_CONFIGURATION_URL, CONF_CONNECTIONS, + CONF_DEFAULT_ENTITY_ID, CONF_DEPRECATED_VIA_HUB, CONF_ENABLED_BY_DEFAULT, CONF_ENCODING, @@ -180,6 +181,7 @@ def validate_device_has_at_least_one_identifier(value: ConfigType) -> ConfigType vol.Optional(CONF_ICON): cv.icon, vol.Optional(CONF_JSON_ATTRS_TOPIC): valid_subscribe_topic, vol.Optional(CONF_JSON_ATTRS_TEMPLATE): cv.template, + vol.Optional(CONF_DEFAULT_ENTITY_ID): cv.string, vol.Optional(CONF_OBJECT_ID): cv.string, vol.Optional(CONF_UNIQUE_ID): cv.string, } diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index fa615ed1f9194c..dce546b3e6d635 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -1,5 +1,9 @@ { "issues": { + "deprecated_object_id": { + "title": "Deprecated option object_id used", + "description": "Entity {entity_id} uses the `object_id` option which deprecated. To fix the issue, replace the `object_id: {object_id}` option with `default_entity_id: {domain}.{object_id}` in your \"configuration.yaml\", and restart Home Assistant." + }, "deprecated_vacuum_battery_feature": { "title": "Deprecated battery feature used", "description": "Vacuum entity {entity_id} implements the battery feature which is deprecated. This will stop working in Home Assistant 2026.2. Implement a separate entity for the battery state instead. To fix the issue, remove the `battery` feature from the configured supported features, and restart Home Assistant." diff --git a/tests/components/mqtt/test_button.py b/tests/components/mqtt/test_button.py index f99c48a440fa39..571308f01588a4 100644 --- a/tests/components/mqtt/test_button.py +++ b/tests/components/mqtt/test_button.py @@ -55,7 +55,7 @@ button.DOMAIN: { "command_topic": "command-topic", "name": "test", - "object_id": "test_button", + "default_entity_id": "button.test_button", "payload_press": "beer press", "qos": "2", } diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 04b4bda0d794e9..0643f7c11d11fc 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -1473,6 +1473,48 @@ async def test_discover_alarm_control_panel( "Hello World 19", "device_tracker", ), + ( + "homeassistant/binary_sensor/object/bla/config", + '{ "name": "Hello World 2", "obj_id": "hello_id", ' + '"o": {"name": "X2mqtt"}, "state_topic": "test-topic" }', + "binary_sensor.hello_id", + "Hello World 2", + "binary_sensor", + ), + ( + "homeassistant/button/object/bla/config", + '{ "name": "Hello World button", "obj_id": "hello_id", ' + '"o": {"name": "X2mqtt", "url": "https://example.com/x2mqtt"}, ' + '"command_topic": "test-topic" }', + "button.hello_id", + "Hello World button", + "button", + ), + ( + "homeassistant/alarm_control_panel/object/bla/config", + '{ "name": "Hello World 1", "def_ent_id": "alarm_control_panel.hello_id", ' + '"state_topic": "test-topic", "command_topic": "test-topic" }', + "alarm_control_panel.hello_id", + "Hello World 1", + "alarm_control_panel", + ), + ( + "homeassistant/binary_sensor/object/bla/config", + '{ "name": "Hello World 2", "def_ent_id": "binary_sensor.hello_id", ' + '"o": {"name": "X2mqtt"}, "state_topic": "test-topic" }', + "binary_sensor.hello_id", + "Hello World 2", + "binary_sensor", + ), + ( + "homeassistant/button/object/bla/config", + '{ "name": "Hello World button", "def_ent_id": "button.hello_id", ' + '"o": {"name": "X2mqtt", "url": "https://example.com/x2mqtt"}, ' + '"command_topic": "test-topic" }', + "button.hello_id", + "Hello World button", + "button", + ), ], ) async def test_discovery_with_object_id( @@ -1496,20 +1538,20 @@ async def test_discovery_with_object_id( assert (domain, "object bla") in hass.data["mqtt"].discovery_already_discovered -async def test_discovery_with_object_id_for_previous_deleted_entity( +async def test_discovery_with_default_entity_id_for_previous_deleted_entity( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: - """Test discovering an MQTT entity with object_id and unique_id.""" + """Test discovering an MQTT entity with default_entity_id and unique_id.""" topic = "homeassistant/sensor/object/bla/config" config = ( '{ "name": "Hello World 11", "unique_id": "very_unique", ' - '"obj_id": "hello_id", "state_topic": "test-topic" }' + '"def_ent_id": "sensor.hello_id", "state_topic": "test-topic" }' ) new_config = ( '{ "name": "Hello World 11", "unique_id": "very_unique", ' - '"obj_id": "updated_hello_id", "state_topic": "test-topic" }' + '"def_ent_id": "sensor.updated_hello_id", "state_topic": "test-topic" }' ) initial_entity_id = "sensor.hello_id" new_entity_id = "sensor.updated_hello_id" @@ -1531,7 +1573,7 @@ async def test_discovery_with_object_id_for_previous_deleted_entity( await hass.async_block_till_done() assert (domain, "object bla") not in hass.data["mqtt"].discovery_already_discovered - # Rediscover with new object_id + # Rediscover with new default_entity_id async_fire_mqtt_message(hass, topic, new_config) await hass.async_block_till_done() diff --git a/tests/components/mqtt/test_mixins.py b/tests/components/mqtt/test_mixins.py index fa30283962b4bd..23c63c9ba58b3a 100644 --- a/tests/components/mqtt/test_mixins.py +++ b/tests/components/mqtt/test_mixins.py @@ -368,7 +368,7 @@ async def test_name_attribute_is_set_or_not( hass, "homeassistant/binary_sensor/bla/config", '{ "name": "Gate", "state_topic": "test-topic", "device_class": "door", ' - '"object_id": "gate",' + '"default_entity_id": "binary_sensor.gate",' '"device": {"identifiers": "very_unique", "name": "xyz_door_sensor"}' "}", ) @@ -384,7 +384,7 @@ async def test_name_attribute_is_set_or_not( hass, "homeassistant/binary_sensor/bla/config", '{ "state_topic": "test-topic", "device_class": "door", ' - '"object_id": "gate",' + '"default_entity_id": "binary_sensor.gate",' '"device": {"identifiers": "very_unique", "name": "xyz_door_sensor"}' "}", ) @@ -400,7 +400,7 @@ async def test_name_attribute_is_set_or_not( hass, "homeassistant/binary_sensor/bla/config", '{ "name": null, "state_topic": "test-topic", "device_class": "door", ' - '"object_id": "gate",' + '"default_entity_id": "binary_sensor.gate",' '"device": {"identifiers": "very_unique", "name": "xyz_door_sensor"}' "}", ) @@ -468,6 +468,40 @@ async def test_value_template_fails( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "test", + "state_topic": "test-topic", + "object_id": "test", + } + } + }, + ], +) +async def test_deprecated_option_object_id_is_used_in_yaml( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test issue registry in case the deprecated option object_id was used in YAML.""" + await mqtt_mock_entry() + await hass.async_block_till_done() + + state = hass.states.get("sensor.test") + assert state is not None + + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue(mqtt.DOMAIN, "sensor.test") + assert issue is not None + assert issue.translation_placeholders == { + "entity_id": "sensor.test", + "object_id": "test", + "domain": "sensor", + } + + @pytest.mark.parametrize( "mqtt_config_subentries_data", [ diff --git a/tests/components/mqtt/test_notify.py b/tests/components/mqtt/test_notify.py index 56da809d1b645a..cd919d3c94d3f4 100644 --- a/tests/components/mqtt/test_notify.py +++ b/tests/components/mqtt/test_notify.py @@ -54,7 +54,7 @@ notify.DOMAIN: { "command_topic": "command-topic", "name": "test", - "object_id": "test_notify", + "default_entity_id": "notify.test_notify", "qos": "2", } } From 12f152d6e4a074e90fa9c1269cb5b5d98b057274 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 8 Sep 2025 05:18:31 -0400 Subject: [PATCH 2/2] Home Assistant Connect ZBT-2 integration (#151015) Co-authored-by: Martin Hjelmare --- CODEOWNERS | 2 + .../homeassistant_connect_zbt2/__init__.py | 71 +++++ .../homeassistant_connect_zbt2/config_flow.py | 206 ++++++++++++++ .../homeassistant_connect_zbt2/const.py | 19 ++ .../homeassistant_connect_zbt2/hardware.py | 42 +++ .../homeassistant_connect_zbt2/manifest.json | 18 ++ .../quality_scale.yaml | 68 +++++ .../homeassistant_connect_zbt2/strings.json | 166 +++++++++++ .../homeassistant_connect_zbt2/update.py | 214 ++++++++++++++ .../homeassistant_connect_zbt2/util.py | 22 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 5 + homeassistant/generated/usb.py | 6 + script/hassfest/manifest.py | 1 + .../homeassistant_connect_zbt2/__init__.py | 1 + .../homeassistant_connect_zbt2/common.py | 12 + .../homeassistant_connect_zbt2/conftest.py | 59 ++++ .../test_config_flow.py | 269 ++++++++++++++++++ .../test_hardware.py | 66 +++++ .../homeassistant_connect_zbt2/test_init.py | 135 +++++++++ .../homeassistant_connect_zbt2/test_update.py | 131 +++++++++ .../homeassistant_connect_zbt2/test_util.py | 36 +++ 22 files changed, 1550 insertions(+) create mode 100644 homeassistant/components/homeassistant_connect_zbt2/__init__.py create mode 100644 homeassistant/components/homeassistant_connect_zbt2/config_flow.py create mode 100644 homeassistant/components/homeassistant_connect_zbt2/const.py create mode 100644 homeassistant/components/homeassistant_connect_zbt2/hardware.py create mode 100644 homeassistant/components/homeassistant_connect_zbt2/manifest.json create mode 100644 homeassistant/components/homeassistant_connect_zbt2/quality_scale.yaml create mode 100644 homeassistant/components/homeassistant_connect_zbt2/strings.json create mode 100644 homeassistant/components/homeassistant_connect_zbt2/update.py create mode 100644 homeassistant/components/homeassistant_connect_zbt2/util.py create mode 100644 tests/components/homeassistant_connect_zbt2/__init__.py create mode 100644 tests/components/homeassistant_connect_zbt2/common.py create mode 100644 tests/components/homeassistant_connect_zbt2/conftest.py create mode 100644 tests/components/homeassistant_connect_zbt2/test_config_flow.py create mode 100644 tests/components/homeassistant_connect_zbt2/test_hardware.py create mode 100644 tests/components/homeassistant_connect_zbt2/test_init.py create mode 100644 tests/components/homeassistant_connect_zbt2/test_update.py create mode 100644 tests/components/homeassistant_connect_zbt2/test_util.py diff --git a/CODEOWNERS b/CODEOWNERS index f8f4513ac06cbb..6a5e4ea437b2b3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -648,6 +648,8 @@ build.json @home-assistant/supervisor /tests/components/homeassistant/ @home-assistant/core /homeassistant/components/homeassistant_alerts/ @home-assistant/core /tests/components/homeassistant_alerts/ @home-assistant/core +/homeassistant/components/homeassistant_connect_zbt2/ @home-assistant/core +/tests/components/homeassistant_connect_zbt2/ @home-assistant/core /homeassistant/components/homeassistant_green/ @home-assistant/core /tests/components/homeassistant_green/ @home-assistant/core /homeassistant/components/homeassistant_hardware/ @home-assistant/core diff --git a/homeassistant/components/homeassistant_connect_zbt2/__init__.py b/homeassistant/components/homeassistant_connect_zbt2/__init__.py new file mode 100644 index 00000000000000..7862f1b3422769 --- /dev/null +++ b/homeassistant/components/homeassistant_connect_zbt2/__init__.py @@ -0,0 +1,71 @@ +"""The Home Assistant Connect ZBT-2 integration.""" + +from __future__ import annotations + +import logging +import os.path + +from homeassistant.components.usb import USBDevice, async_register_port_event_callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType + +from .const import DEVICE, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Home Assistant Connect ZBT-2 integration.""" + + @callback + def async_port_event_callback( + added: set[USBDevice], removed: set[USBDevice] + ) -> None: + """Handle USB port events.""" + current_entries_by_path = { + entry.data[DEVICE]: entry + for entry in hass.config_entries.async_entries(DOMAIN) + } + + for device in added | removed: + path = device.device + entry = current_entries_by_path.get(path) + + if entry is not None: + _LOGGER.debug( + "Device %r has changed state, reloading config entry %s", + path, + entry, + ) + hass.config_entries.async_schedule_reload(entry.entry_id) + + async_register_port_event_callback(hass, async_port_event_callback) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a Home Assistant Connect ZBT-2 config entry.""" + + # Postpone loading the config entry if the device is missing + device_path = entry.data[DEVICE] + if not await hass.async_add_executor_job(os.path.exists, device_path): + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="device_disconnected", + ) + + await hass.config_entries.async_forward_entry_setups(entry, ["update"]) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + await hass.config_entries.async_unload_platforms(entry, ["update"]) + return True diff --git a/homeassistant/components/homeassistant_connect_zbt2/config_flow.py b/homeassistant/components/homeassistant_connect_zbt2/config_flow.py new file mode 100644 index 00000000000000..8f106a8669c1ca --- /dev/null +++ b/homeassistant/components/homeassistant_connect_zbt2/config_flow.py @@ -0,0 +1,206 @@ +"""Config flow for the Home Assistant Connect ZBT-2 integration.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any, Protocol + +from homeassistant.components import usb +from homeassistant.components.homeassistant_hardware import firmware_config_flow +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, +) +from homeassistant.config_entries import ( + ConfigEntry, + ConfigEntryBaseFlow, + ConfigFlowContext, + ConfigFlowResult, + OptionsFlow, +) +from homeassistant.core import callback +from homeassistant.helpers.service_info.usb import UsbServiceInfo + +from .const import ( + DEVICE, + DOMAIN, + FIRMWARE, + FIRMWARE_VERSION, + HARDWARE_NAME, + MANUFACTURER, + NABU_CASA_FIRMWARE_RELEASES_URL, + PID, + PRODUCT, + SERIAL_NUMBER, + VID, +) +from .util import get_usb_service_info + +_LOGGER = logging.getLogger(__name__) + + +if TYPE_CHECKING: + + class FirmwareInstallFlowProtocol(Protocol): + """Protocol describing `BaseFirmwareInstallFlow` for a mixin.""" + + def _get_translation_placeholders(self) -> dict[str, str]: + return {} + + async def _install_firmware_step( + self, + fw_update_url: str, + fw_type: str, + firmware_name: str, + expected_installed_firmware_type: ApplicationType, + step_id: str, + next_step_id: str, + ) -> ConfigFlowResult: ... + +else: + # Multiple inheritance with `Protocol` seems to break + FirmwareInstallFlowProtocol = object + + +class ZBT2FirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol): + """Mixin for Home Assistant Connect ZBT-2 firmware methods.""" + + context: ConfigFlowContext + + async def async_step_install_zigbee_firmware( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Install Zigbee firmware.""" + return await self._install_firmware_step( + fw_update_url=NABU_CASA_FIRMWARE_RELEASES_URL, + fw_type="zbt2_zigbee_ncp", + firmware_name="Zigbee", + expected_installed_firmware_type=ApplicationType.EZSP, + step_id="install_zigbee_firmware", + next_step_id="pre_confirm_zigbee", + ) + + async def async_step_install_thread_firmware( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Install Thread firmware.""" + return await self._install_firmware_step( + fw_update_url=NABU_CASA_FIRMWARE_RELEASES_URL, + fw_type="zbt2_openthread_rcp", + firmware_name="OpenThread", + expected_installed_firmware_type=ApplicationType.SPINEL, + step_id="install_thread_firmware", + next_step_id="start_otbr_addon", + ) + + +class HomeAssistantConnectZBT2ConfigFlow( + ZBT2FirmwareMixin, + firmware_config_flow.BaseFirmwareConfigFlow, + domain=DOMAIN, +): + """Handle a config flow for Home Assistant Connect ZBT-2.""" + + VERSION = 1 + MINOR_VERSION = 1 + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Initialize the config flow.""" + super().__init__(*args, **kwargs) + + self._usb_info: UsbServiceInfo | None = None + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> OptionsFlow: + """Return the options flow.""" + return HomeAssistantConnectZBT2OptionsFlowHandler(config_entry) + + async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: + """Handle usb discovery.""" + device = discovery_info.device + vid = discovery_info.vid + pid = discovery_info.pid + serial_number = discovery_info.serial_number + manufacturer = discovery_info.manufacturer + description = discovery_info.description + unique_id = f"{vid}:{pid}_{serial_number}_{manufacturer}_{description}" + + device = discovery_info.device = await self.hass.async_add_executor_job( + usb.get_serial_by_id, discovery_info.device + ) + + try: + await self.async_set_unique_id(unique_id) + finally: + self._abort_if_unique_id_configured(updates={DEVICE: device}) + + self._usb_info = discovery_info + + # Set parent class attributes + self._device = self._usb_info.device + self._hardware_name = HARDWARE_NAME + + return await self.async_step_confirm() + + def _async_flow_finished(self) -> ConfigFlowResult: + """Create the config entry.""" + assert self._usb_info is not None + assert self._probed_firmware_info is not None + + return self.async_create_entry( + title=HARDWARE_NAME, + data={ + VID: self._usb_info.vid, + PID: self._usb_info.pid, + SERIAL_NUMBER: self._usb_info.serial_number, + MANUFACTURER: self._usb_info.manufacturer, + PRODUCT: self._usb_info.description, + DEVICE: self._usb_info.device, + FIRMWARE: self._probed_firmware_info.firmware_type.value, + FIRMWARE_VERSION: self._probed_firmware_info.firmware_version, + }, + ) + + +class HomeAssistantConnectZBT2OptionsFlowHandler( + ZBT2FirmwareMixin, firmware_config_flow.BaseFirmwareOptionsFlow +): + """Zigbee and Thread options flow handlers.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Instantiate options flow.""" + super().__init__(*args, **kwargs) + + self._usb_info = get_usb_service_info(self.config_entry) + self._hardware_name = HARDWARE_NAME + self._device = self._usb_info.device + + self._probed_firmware_info = FirmwareInfo( + device=self._device, + firmware_type=ApplicationType(self.config_entry.data[FIRMWARE]), + firmware_version=self.config_entry.data[FIRMWARE_VERSION], + source="guess", + owners=[], + ) + + # Regenerate the translation placeholders + self._get_translation_placeholders() + + def _async_flow_finished(self) -> ConfigFlowResult: + """Create the config entry.""" + assert self._probed_firmware_info is not None + + self.hass.config_entries.async_update_entry( + entry=self.config_entry, + data={ + **self.config_entry.data, + FIRMWARE: self._probed_firmware_info.firmware_type.value, + FIRMWARE_VERSION: self._probed_firmware_info.firmware_version, + }, + options=self.config_entry.options, + ) + + return self.async_create_entry(title="", data={}) diff --git a/homeassistant/components/homeassistant_connect_zbt2/const.py b/homeassistant/components/homeassistant_connect_zbt2/const.py new file mode 100644 index 00000000000000..c0b07a88687b76 --- /dev/null +++ b/homeassistant/components/homeassistant_connect_zbt2/const.py @@ -0,0 +1,19 @@ +"""Constants for the Home Assistant Connect ZBT-2 integration.""" + +DOMAIN = "homeassistant_connect_zbt2" + +NABU_CASA_FIRMWARE_RELEASES_URL = ( + "https://api.github.com/repos/NabuCasa/silabs-firmware-builder/releases/latest" +) + +FIRMWARE = "firmware" +FIRMWARE_VERSION = "firmware_version" +SERIAL_NUMBER = "serial_number" +MANUFACTURER = "manufacturer" +PRODUCT = "product" +DESCRIPTION = "description" +PID = "pid" +VID = "vid" +DEVICE = "device" + +HARDWARE_NAME = "Home Assistant Connect ZBT-2" diff --git a/homeassistant/components/homeassistant_connect_zbt2/hardware.py b/homeassistant/components/homeassistant_connect_zbt2/hardware.py new file mode 100644 index 00000000000000..8367df6501d47c --- /dev/null +++ b/homeassistant/components/homeassistant_connect_zbt2/hardware.py @@ -0,0 +1,42 @@ +"""The Home Assistant Connect ZBT-2 hardware platform.""" + +from __future__ import annotations + +from homeassistant.components.hardware.models import HardwareInfo, USBInfo +from homeassistant.core import HomeAssistant, callback + +from .config_flow import HomeAssistantConnectZBT2ConfigFlow +from .const import DOMAIN, HARDWARE_NAME, MANUFACTURER, PID, PRODUCT, SERIAL_NUMBER, VID + +DOCUMENTATION_URL = ( + "https://support.nabucasa.com/hc/en-us/categories/" + "24734620813469-Home-Assistant-Connect-ZBT-1" +) +EXPECTED_ENTRY_VERSION = ( + HomeAssistantConnectZBT2ConfigFlow.VERSION, + HomeAssistantConnectZBT2ConfigFlow.MINOR_VERSION, +) + + +@callback +def async_info(hass: HomeAssistant) -> list[HardwareInfo]: + """Return board info.""" + entries = hass.config_entries.async_entries(DOMAIN) + return [ + HardwareInfo( + board=None, + config_entries=[entry.entry_id], + dongle=USBInfo( + vid=entry.data[VID], + pid=entry.data[PID], + serial_number=entry.data[SERIAL_NUMBER], + manufacturer=entry.data[MANUFACTURER], + description=entry.data[PRODUCT], + ), + name=HARDWARE_NAME, + url=DOCUMENTATION_URL, + ) + for entry in entries + # Ignore unmigrated config entries in the hardware page + if (entry.version, entry.minor_version) == EXPECTED_ENTRY_VERSION + ] diff --git a/homeassistant/components/homeassistant_connect_zbt2/manifest.json b/homeassistant/components/homeassistant_connect_zbt2/manifest.json new file mode 100644 index 00000000000000..5d5c2996e47e27 --- /dev/null +++ b/homeassistant/components/homeassistant_connect_zbt2/manifest.json @@ -0,0 +1,18 @@ +{ + "domain": "homeassistant_connect_zbt2", + "name": "Home Assistant Connect ZBT-2", + "codeowners": ["@home-assistant/core"], + "config_flow": true, + "dependencies": ["hardware", "usb", "homeassistant_hardware"], + "documentation": "https://www.home-assistant.io/integrations/homeassistant_connect_zbt2", + "integration_type": "hardware", + "quality_scale": "bronze", + "usb": [ + { + "vid": "303A", + "pid": "4001", + "description": "*zbt-2*", + "known_devices": ["ZBT-2"] + } + ] +} diff --git a/homeassistant/components/homeassistant_connect_zbt2/quality_scale.yaml b/homeassistant/components/homeassistant_connect_zbt2/quality_scale.yaml new file mode 100644 index 00000000000000..a52b5abf0f1eef --- /dev/null +++ b/homeassistant/components/homeassistant_connect_zbt2/quality_scale.yaml @@ -0,0 +1,68 @@ +rules: + # Bronze + action-setup: + status: done + comment: | + No actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: + status: done + comment: | + Integration isn't set up by users. + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: + status: exempt + comment: Nothing to store. + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: todo + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/homeassistant_connect_zbt2/strings.json b/homeassistant/components/homeassistant_connect_zbt2/strings.json new file mode 100644 index 00000000000000..13775d1f1eb449 --- /dev/null +++ b/homeassistant/components/homeassistant_connect_zbt2/strings.json @@ -0,0 +1,166 @@ +{ + "options": { + "step": { + "addon_not_installed": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_not_installed::title%]", + "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_not_installed::description%]", + "data": { + "enable_multi_pan": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_not_installed::data::enable_multi_pan%]" + } + }, + "addon_installed_other_device": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_installed_other_device::title%]" + }, + "addon_menu": { + "menu_options": { + "reconfigure_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]", + "uninstall_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]" + } + }, + "change_channel": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]", + "data": { + "channel": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::change_channel::data::channel%]" + }, + "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::change_channel::description%]" + }, + "install_addon": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_addon::title%]" + }, + "notify_channel_change": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::title%]", + "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::description%]" + }, + "notify_unknown_multipan_user": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_unknown_multipan_user::title%]", + "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_unknown_multipan_user::description%]" + }, + "reconfigure_addon": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]" + }, + "start_addon": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_addon::title%]" + }, + "uninstall_addon": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]", + "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::description%]", + "data": { + "disable_multi_pan": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::data::disable_multi_pan%]" + } + }, + "pick_firmware": { + "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::title%]", + "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::description%]", + "menu_options": { + "pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread%]", + "pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]" + } + }, + "confirm_zigbee": { + "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::title%]", + "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]" + }, + "install_otbr_addon": { + "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]", + "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::description%]" + }, + "start_otbr_addon": { + "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]", + "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::description%]" + }, + "otbr_failed": { + "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::otbr_failed::title%]", + "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::otbr_failed::description%]" + }, + "confirm_otbr": { + "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::title%]", + "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::description%]" + } + }, + "error": { + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "addon_info_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_info_failed%]", + "addon_install_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_install_failed%]", + "addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]", + "addon_set_config_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_set_config_failed%]", + "addon_start_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_start_failed%]", + "zha_migration_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::zha_migration_failed%]", + "not_hassio": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::not_hassio%]", + "not_hassio_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::not_hassio_thread%]", + "otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]", + "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]", + "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]", + "unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]", + "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]", + "fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]" + }, + "progress": { + "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", + "start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]", + "start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]", + "install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]" + } + }, + "config": { + "flow_title": "{model}", + "step": { + "pick_firmware": { + "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::title%]", + "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::description%]", + "menu_options": { + "pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]", + "pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread%]" + } + }, + "confirm_zigbee": { + "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::title%]", + "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]" + }, + "install_otbr_addon": { + "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::title%]", + "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_otbr_addon::description%]" + }, + "start_otbr_addon": { + "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]", + "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::description%]" + }, + "otbr_failed": { + "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::otbr_failed::title%]", + "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::otbr_failed::description%]" + }, + "confirm_otbr": { + "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::title%]", + "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::description%]" + } + }, + "abort": { + "addon_info_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_info_failed%]", + "addon_install_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_install_failed%]", + "addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]", + "addon_set_config_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_set_config_failed%]", + "addon_start_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_start_failed%]", + "zha_migration_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::zha_migration_failed%]", + "not_hassio": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::not_hassio%]", + "not_hassio_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::not_hassio_thread%]", + "otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]", + "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]", + "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]", + "unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]", + "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]", + "fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]" + }, + "progress": { + "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", + "start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]", + "start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]", + "install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]" + } + }, + "exceptions": { + "device_disconnected": { + "message": "The device is not plugged in" + } + } +} diff --git a/homeassistant/components/homeassistant_connect_zbt2/update.py b/homeassistant/components/homeassistant_connect_zbt2/update.py new file mode 100644 index 00000000000000..24ddf417180420 --- /dev/null +++ b/homeassistant/components/homeassistant_connect_zbt2/update.py @@ -0,0 +1,214 @@ +"""Home Assistant Connect ZBT-2 firmware update entity.""" + +from __future__ import annotations + +import logging + +import aiohttp + +from homeassistant.components.homeassistant_hardware.coordinator import ( + FirmwareUpdateCoordinator, +) +from homeassistant.components.homeassistant_hardware.update import ( + BaseFirmwareUpdateEntity, + FirmwareUpdateEntityDescription, +) +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, +) +from homeassistant.components.update import UpdateDeviceClass +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import ( + DOMAIN, + FIRMWARE, + FIRMWARE_VERSION, + HARDWARE_NAME, + NABU_CASA_FIRMWARE_RELEASES_URL, + SERIAL_NUMBER, +) + +_LOGGER = logging.getLogger(__name__) + + +FIRMWARE_ENTITY_DESCRIPTIONS: dict[ + ApplicationType | None, FirmwareUpdateEntityDescription +] = { + ApplicationType.EZSP: FirmwareUpdateEntityDescription( + key="firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw.split(" ", 1)[0], + fw_type="zbt2_zigbee_ncp", + version_key="ezsp_version", + expected_firmware_type=ApplicationType.EZSP, + firmware_name="EmberZNet Zigbee", + ), + ApplicationType.SPINEL: FirmwareUpdateEntityDescription( + key="firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw.split("/", 1)[1].split("_", 1)[0], + fw_type="zbt2_openthread_rcp", + version_key="ot_rcp_version", + expected_firmware_type=ApplicationType.SPINEL, + firmware_name="OpenThread RCP", + ), + ApplicationType.GECKO_BOOTLOADER: FirmwareUpdateEntityDescription( + key="firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw, + fw_type=None, # We don't want to update the bootloader + version_key="gecko_bootloader_version", + expected_firmware_type=ApplicationType.GECKO_BOOTLOADER, + firmware_name="Gecko Bootloader", + ), + None: FirmwareUpdateEntityDescription( + key="firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw, + fw_type=None, + version_key=None, + expected_firmware_type=None, + firmware_name=None, + ), +} + + +def _async_create_update_entity( + hass: HomeAssistant, + config_entry: ConfigEntry, + session: aiohttp.ClientSession, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> FirmwareUpdateEntity: + """Create an update entity that handles firmware type changes.""" + firmware_type = config_entry.data[FIRMWARE] + + try: + entity_description = FIRMWARE_ENTITY_DESCRIPTIONS[ + ApplicationType(firmware_type) + ] + except (KeyError, ValueError): + _LOGGER.debug( + "Unknown firmware type %r, using default entity description", firmware_type + ) + entity_description = FIRMWARE_ENTITY_DESCRIPTIONS[None] + + entity = FirmwareUpdateEntity( + device=config_entry.data["device"], + config_entry=config_entry, + update_coordinator=FirmwareUpdateCoordinator( + hass, + config_entry, + session, + NABU_CASA_FIRMWARE_RELEASES_URL, + ), + entity_description=entity_description, + ) + + def firmware_type_changed( + old_type: ApplicationType | None, new_type: ApplicationType | None + ) -> None: + """Replace the current entity when the firmware type changes.""" + er.async_get(hass).async_remove(entity.entity_id) + async_add_entities( + [ + _async_create_update_entity( + hass, config_entry, session, async_add_entities + ) + ] + ) + + entity.async_on_remove( + entity.add_firmware_type_changed_callback(firmware_type_changed) + ) + + return entity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the firmware update config entry.""" + session = async_get_clientsession(hass) + entity = _async_create_update_entity( + hass, config_entry, session, async_add_entities + ) + + async_add_entities([entity]) + + +class FirmwareUpdateEntity(BaseFirmwareUpdateEntity): + """Connect ZBT-2 firmware update entity.""" + + bootloader_reset_type = None + + def __init__( + self, + device: str, + config_entry: ConfigEntry, + update_coordinator: FirmwareUpdateCoordinator, + entity_description: FirmwareUpdateEntityDescription, + ) -> None: + """Initialize the Connect ZBT-2 firmware update entity.""" + super().__init__(device, config_entry, update_coordinator, entity_description) + + serial_number = self._config_entry.data[SERIAL_NUMBER] + + self._attr_unique_id = f"{serial_number}_{self.entity_description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, serial_number)}, + name=f"{HARDWARE_NAME} ({serial_number})", + model=HARDWARE_NAME, + manufacturer="Nabu Casa", + serial_number=serial_number, + ) + + # Use the cached firmware info if it exists + if self._config_entry.data[FIRMWARE] is not None: + self._current_firmware_info = FirmwareInfo( + device=device, + firmware_type=ApplicationType(self._config_entry.data[FIRMWARE]), + firmware_version=self._config_entry.data[FIRMWARE_VERSION], + owners=[], + source="homeassistant_connect_zbt2", + ) + + def _update_attributes(self) -> None: + """Recompute the attributes of the entity.""" + super()._update_attributes() + + assert self.device_entry is not None + device_registry = dr.async_get(self.hass) + device_registry.async_update_device( + device_id=self.device_entry.id, + sw_version=f"{self.entity_description.firmware_name} {self._attr_installed_version}", + ) + + @callback + def _firmware_info_callback(self, firmware_info: FirmwareInfo) -> None: + """Handle updated firmware info being pushed by an integration.""" + self.hass.config_entries.async_update_entry( + self._config_entry, + data={ + **self._config_entry.data, + FIRMWARE: firmware_info.firmware_type, + FIRMWARE_VERSION: firmware_info.firmware_version, + }, + ) + super()._firmware_info_callback(firmware_info) diff --git a/homeassistant/components/homeassistant_connect_zbt2/util.py b/homeassistant/components/homeassistant_connect_zbt2/util.py new file mode 100644 index 00000000000000..ebd6f33a8a895b --- /dev/null +++ b/homeassistant/components/homeassistant_connect_zbt2/util.py @@ -0,0 +1,22 @@ +"""Utility functions for Home Assistant Connect ZBT-2 integration.""" + +from __future__ import annotations + +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.service_info.usb import UsbServiceInfo + +_LOGGER = logging.getLogger(__name__) + + +def get_usb_service_info(config_entry: ConfigEntry) -> UsbServiceInfo: + """Return UsbServiceInfo.""" + return UsbServiceInfo( + device=config_entry.data["device"], + vid=config_entry.data["vid"], + pid=config_entry.data["pid"], + serial_number=config_entry.data["serial_number"], + manufacturer=config_entry.data["manufacturer"], + description=config_entry.data["product"], + ) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 114e8230596c23..d636fce1d3cbfa 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -265,6 +265,7 @@ "hlk_sw16", "holiday", "home_connect", + "homeassistant_connect_zbt2", "homeassistant_sky_connect", "homee", "homekit", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 7effcc500bb147..183c7956275310 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2712,6 +2712,11 @@ "integration_type": "virtual", "supported_by": "netatmo" }, + "homeassistant_connect_zbt2": { + "name": "Home Assistant Connect ZBT-2", + "integration_type": "hardware", + "config_flow": true + }, "homeassistant_green": { "name": "Home Assistant Green", "integration_type": "hardware", diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index dee0367de2476d..96cf67524054e2 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -4,6 +4,12 @@ """ USB = [ + { + "description": "*zbt-2*", + "domain": "homeassistant_connect_zbt2", + "pid": "4001", + "vid": "303A", + }, { "description": "*skyconnect v1.0*", "domain": "homeassistant_sky_connect", diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 02c96930bf5625..74aad78dc6a46f 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -79,6 +79,7 @@ class NonScaledQualityScaleTiers(StrEnum): "history", "homeassistant", "homeassistant_alerts", + "homeassistant_connect_zbt2", "homeassistant_green", "homeassistant_hardware", "homeassistant_sky_connect", diff --git a/tests/components/homeassistant_connect_zbt2/__init__.py b/tests/components/homeassistant_connect_zbt2/__init__.py new file mode 100644 index 00000000000000..298f21ce3f762f --- /dev/null +++ b/tests/components/homeassistant_connect_zbt2/__init__.py @@ -0,0 +1 @@ +"""Tests for the Home Assistant Connect ZBT-2 integration.""" diff --git a/tests/components/homeassistant_connect_zbt2/common.py b/tests/components/homeassistant_connect_zbt2/common.py new file mode 100644 index 00000000000000..78a4b754479439 --- /dev/null +++ b/tests/components/homeassistant_connect_zbt2/common.py @@ -0,0 +1,12 @@ +"""Common constants for the Connect ZBT-2 integration tests.""" + +from homeassistant.helpers.service_info.usb import UsbServiceInfo + +USB_DATA_ZBT2 = UsbServiceInfo( + device="/dev/serial/by-id/usb-Nabu_Casa_ZBT-2_80B54EEFAE18-if01-port0", + vid="303A", + pid="4001", + serial_number="80B54EEFAE18", + manufacturer="Nabu Casa", + description="ZBT-2", +) diff --git a/tests/components/homeassistant_connect_zbt2/conftest.py b/tests/components/homeassistant_connect_zbt2/conftest.py new file mode 100644 index 00000000000000..d6b8fa09a3f532 --- /dev/null +++ b/tests/components/homeassistant_connect_zbt2/conftest.py @@ -0,0 +1,59 @@ +"""Test fixtures for the Home Assistant Connect ZBT-2 integration.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +@pytest.fixture(name="mock_usb_serial_by_id", autouse=True) +def mock_usb_serial_by_id_fixture() -> Generator[MagicMock]: + """Mock usb serial by id.""" + with patch( + "homeassistant.components.zha.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 + + +@pytest.fixture(autouse=True) +def mock_zha(): + """Mock the zha integration.""" + mock_connect_app = MagicMock() + mock_connect_app.__aenter__.return_value.backups.backups = [MagicMock()] + mock_connect_app.__aenter__.return_value.backups.create_backup.return_value = ( + MagicMock() + ) + + with ( + patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app", + return_value=mock_connect_app, + ), + patch( + "homeassistant.components.zha.async_setup_entry", + return_value=True, + ), + ): + yield + + +@pytest.fixture(autouse=True) +def mock_zha_get_last_network_settings() -> Generator[None]: + """Mock zha.api.async_get_last_network_settings.""" + + with patch( + "homeassistant.components.zha.api.async_get_last_network_settings", + AsyncMock(return_value=None), + ): + yield + + +@pytest.fixture(autouse=True) +def mock_usb_path_exists() -> Generator[None]: + """Mock os.path.exists to allow the Connect ZBT-2 integration to load.""" + with patch( + "homeassistant.components.homeassistant_connect_zbt2.os.path.exists", + return_value=True, + ): + yield diff --git a/tests/components/homeassistant_connect_zbt2/test_config_flow.py b/tests/components/homeassistant_connect_zbt2/test_config_flow.py new file mode 100644 index 00000000000000..7a1a1875bd08d7 --- /dev/null +++ b/tests/components/homeassistant_connect_zbt2/test_config_flow.py @@ -0,0 +1,269 @@ +"""Test the Home Assistant Connect ZBT-2 config flow.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.homeassistant_connect_zbt2.const import DOMAIN +from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( + STEP_PICK_FIRMWARE_THREAD, + STEP_PICK_FIRMWARE_ZIGBEE, +) +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, +) +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.usb import UsbServiceInfo + +from .common import USB_DATA_ZBT2 + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("step", "usb_data", "model", "fw_type", "fw_version"), + [ + ( + STEP_PICK_FIRMWARE_ZIGBEE, + USB_DATA_ZBT2, + "Home Assistant Connect ZBT-2", + ApplicationType.EZSP, + "7.4.4.0 build 0", + ), + ( + STEP_PICK_FIRMWARE_THREAD, + USB_DATA_ZBT2, + "Home Assistant Connect ZBT-2", + ApplicationType.SPINEL, + "2.4.4.0", + ), + ], +) +async def test_config_flow( + step: str, + usb_data: UsbServiceInfo, + model: str, + fw_type: ApplicationType, + fw_version: str, + hass: HomeAssistant, +) -> None: + """Test the config flow for Connect ZBT-2.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "usb"}, data=usb_data + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "pick_firmware" + assert result["description_placeholders"]["model"] == model + + async def mock_install_firmware_step( + self, + fw_update_url: str, + fw_type: str, + firmware_name: str, + expected_installed_firmware_type: ApplicationType, + step_id: str, + next_step_id: str, + ) -> ConfigFlowResult: + if next_step_id == "start_otbr_addon": + next_step_id = "pre_confirm_otbr" + + return await getattr(self, f"async_step_{next_step_id}")(user_input={}) + + with ( + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareConfigFlow._ensure_thread_addon_setup", + return_value=None, + ), + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareConfigFlow._install_firmware_step", + autospec=True, + side_effect=mock_install_firmware_step, + ), + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", + return_value=FirmwareInfo( + device=usb_data.device, + firmware_type=fw_type, + firmware_version=fw_version, + owners=[], + source="probe", + ), + ), + ): + confirm_result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"next_step_id": step}, + ) + + assert confirm_result["type"] is FlowResultType.FORM + assert confirm_result["step_id"] == ( + "confirm_zigbee" if step == STEP_PICK_FIRMWARE_ZIGBEE else "confirm_otbr" + ) + + create_result = await hass.config_entries.flow.async_configure( + confirm_result["flow_id"], user_input={} + ) + + assert create_result["type"] is FlowResultType.CREATE_ENTRY + config_entry = create_result["result"] + assert config_entry.data == { + "firmware": fw_type.value, + "firmware_version": fw_version, + "device": usb_data.device, + "manufacturer": usb_data.manufacturer, + "pid": usb_data.pid, + "product": usb_data.description, + "serial_number": usb_data.serial_number, + "vid": usb_data.vid, + } + + flows = hass.config_entries.flow.async_progress() + + if step == STEP_PICK_FIRMWARE_ZIGBEE: + # Ensure a ZHA discovery flow has been created + assert len(flows) == 1 + zha_flow = flows[0] + assert zha_flow["handler"] == "zha" + assert zha_flow["context"]["source"] == "hardware" + assert zha_flow["step_id"] == "confirm" + else: + assert len(flows) == 0 + + +@pytest.mark.parametrize( + ("usb_data", "model"), + [ + (USB_DATA_ZBT2, "Home Assistant Connect ZBT-2"), + ], +) +async def test_options_flow( + usb_data: UsbServiceInfo, model: str, hass: HomeAssistant +) -> None: + """Test the options flow for Connect ZBT-2.""" + config_entry = MockConfigEntry( + domain="homeassistant_connect_zbt2", + data={ + "firmware": "spinel", + "firmware_version": "SL-OPENTHREAD/2.4.4.0_GitHub-7074a43e4", + "device": usb_data.device, + "manufacturer": usb_data.manufacturer, + "pid": usb_data.pid, + "product": usb_data.description, + "serial_number": usb_data.serial_number, + "vid": usb_data.vid, + }, + version=1, + minor_version=1, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + + # First step is confirmation + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "pick_firmware" + assert result["description_placeholders"]["firmware_type"] == "spinel" + assert result["description_placeholders"]["model"] == model + + async def mock_async_step_pick_firmware_zigbee(self, data): + return await self.async_step_pre_confirm_zigbee() + + with ( + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareOptionsFlow.async_step_pick_firmware_zigbee", + autospec=True, + side_effect=mock_async_step_pick_firmware_zigbee, + ), + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", + return_value=FirmwareInfo( + device=usb_data.device, + firmware_type=ApplicationType.EZSP, + firmware_version="7.4.4.0 build 0", + owners=[], + source="probe", + ), + ), + ): + confirm_result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, + ) + + assert confirm_result["type"] is FlowResultType.FORM + assert confirm_result["step_id"] == "confirm_zigbee" + + create_result = await hass.config_entries.options.async_configure( + confirm_result["flow_id"], user_input={} + ) + + assert create_result["type"] is FlowResultType.CREATE_ENTRY + + assert config_entry.data == { + "firmware": "ezsp", + "firmware_version": "7.4.4.0 build 0", + "device": usb_data.device, + "manufacturer": usb_data.manufacturer, + "pid": usb_data.pid, + "product": usb_data.description, + "serial_number": usb_data.serial_number, + "vid": usb_data.vid, + } + + +async def test_duplicate_discovery(hass: HomeAssistant) -> None: + """Test config flow unique_id deduplication.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "usb"}, data=USB_DATA_ZBT2 + ) + + assert result["type"] is FlowResultType.MENU + + result_duplicate = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "usb"}, data=USB_DATA_ZBT2 + ) + + assert result_duplicate["type"] is FlowResultType.ABORT + assert result_duplicate["reason"] == "already_in_progress" + + +async def test_duplicate_discovery_updates_usb_path(hass: HomeAssistant) -> None: + """Test config flow unique_id deduplication updates USB path.""" + config_entry = MockConfigEntry( + domain="homeassistant_connect_zbt2", + data={ + "firmware": "spinel", + "firmware_version": "SL-OPENTHREAD/2.4.4.0_GitHub-7074a43e4", + "device": "/dev/oldpath", + "manufacturer": USB_DATA_ZBT2.manufacturer, + "pid": USB_DATA_ZBT2.pid, + "product": USB_DATA_ZBT2.description, + "serial_number": USB_DATA_ZBT2.serial_number, + "vid": USB_DATA_ZBT2.vid, + }, + version=1, + minor_version=1, + unique_id=( + f"{USB_DATA_ZBT2.vid}:{USB_DATA_ZBT2.pid}_" + f"{USB_DATA_ZBT2.serial_number}_" + f"{USB_DATA_ZBT2.manufacturer}_" + f"{USB_DATA_ZBT2.description}" + ), + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "usb"}, data=USB_DATA_ZBT2 + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert config_entry.data["device"] == USB_DATA_ZBT2.device diff --git a/tests/components/homeassistant_connect_zbt2/test_hardware.py b/tests/components/homeassistant_connect_zbt2/test_hardware.py new file mode 100644 index 00000000000000..030a2610d647ff --- /dev/null +++ b/tests/components/homeassistant_connect_zbt2/test_hardware.py @@ -0,0 +1,66 @@ +"""Test the Home Assistant Connect ZBT-2 hardware platform.""" + +from homeassistant.components.homeassistant_connect_zbt2.const import DOMAIN +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator + +CONFIG_ENTRY_DATA = { + "device": "/dev/serial/by-id/usb-Nabu_Casa_ZBT-2_80B54EEFAE18-if01-port0", + "vid": "303A", + "pid": "4001", + "serial_number": "80B54EEFAE18", + "manufacturer": "Nabu Casa", + "product": "ZBT-2", + "firmware": "ezsp", + "firmware_version": "7.4.4.0 build 0", +} + + +async def test_hardware_info( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, addon_store_info +) -> None: + """Test we can get the board info.""" + assert await async_setup_component(hass, "usb", {}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + + # Setup the config entry + config_entry = MockConfigEntry( + data=CONFIG_ENTRY_DATA, + domain=DOMAIN, + options={}, + title="Home Assistant Connect ZBT-2", + unique_id="unique_1", + version=1, + minor_version=1, + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + client = await hass_ws_client(hass) + + await client.send_json({"id": 1, "type": "hardware/info"}) + msg = await client.receive_json() + + assert msg["id"] == 1 + assert msg["success"] + assert msg["result"] == { + "hardware": [ + { + "board": None, + "config_entries": [config_entry.entry_id], + "dongle": { + "vid": "303A", + "pid": "4001", + "serial_number": "80B54EEFAE18", + "manufacturer": "Nabu Casa", + "description": "ZBT-2", + }, + "name": "Home Assistant Connect ZBT-2", + "url": "https://support.nabucasa.com/hc/en-us/categories/24734620813469-Home-Assistant-Connect-ZBT-1", + } + ] + } diff --git a/tests/components/homeassistant_connect_zbt2/test_init.py b/tests/components/homeassistant_connect_zbt2/test_init.py new file mode 100644 index 00000000000000..42f5f8ac5a5e45 --- /dev/null +++ b/tests/components/homeassistant_connect_zbt2/test_init.py @@ -0,0 +1,135 @@ +"""Test the Home Assistant Connect ZBT-2 integration.""" + +from datetime import timedelta +from unittest.mock import patch + +import pytest + +from homeassistant.components.homeassistant_connect_zbt2.const import DOMAIN +from homeassistant.components.usb import USBDevice +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.usb import ( + async_request_scan, + force_usb_polling_watcher, # noqa: F401 + patch_scanned_serial_ports, +) + + +async def test_setup_fails_on_missing_usb_port(hass: HomeAssistant) -> None: + """Test setup failing when the USB port is missing.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="some_unique_id", + data={ + "device": "/dev/serial/by-id/usb-Nabu_Casa_ZBT-2_80B54EEFAE18-if01-port0", + "vid": "303A", + "pid": "4001", + "serial_number": "80B54EEFAE18", + "manufacturer": "Nabu Casa", + "product": "ZBT-2", + "firmware": "ezsp", + "firmware_version": "7.4.4.0", + }, + version=1, + minor_version=1, + ) + + config_entry.add_to_hass(hass) + + # Set up the config entry + with patch( + "homeassistant.components.homeassistant_connect_zbt2.os.path.exists" + ) as mock_exists: + mock_exists.return_value = False + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Failed to set up, the device is missing + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + mock_exists.return_value = True + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=30)) + await hass.async_block_till_done(wait_background_tasks=True) + + # Now it's ready + assert config_entry.state is ConfigEntryState.LOADED + + +@pytest.mark.usefixtures("force_usb_polling_watcher") +async def test_usb_device_reactivity(hass: HomeAssistant) -> None: + """Test setting up USB monitoring.""" + assert await async_setup_component(hass, "usb", {"usb": {}}) + + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="some_unique_id", + data={ + "device": "/dev/serial/by-id/usb-Nabu_Casa_ZBT-2_80B54EEFAE18-if01-port0", + "vid": "303A", + "pid": "4001", + "serial_number": "80B54EEFAE18", + "manufacturer": "Nabu Casa", + "product": "ZBT-2", + "firmware": "ezsp", + "firmware_version": "7.4.4.0", + }, + version=1, + minor_version=1, + ) + + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.homeassistant_connect_zbt2.os.path.exists" + ) as mock_exists: + mock_exists.return_value = False + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Failed to set up, the device is missing + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + # Now we make it available but do not wait + mock_exists.return_value = True + + with patch_scanned_serial_ports( + return_value=[ + USBDevice( + device="/dev/serial/by-id/usb-Nabu_Casa_ZBT-2_80B54EEFAE18-if01-port0", + vid="303A", + pid="4001", + serial_number="80B54EEFAE18", + manufacturer="Nabu Casa", + description="ZBT-2", + ) + ], + ): + await async_request_scan(hass) + + # It loads immediately + await hass.async_block_till_done(wait_background_tasks=True) + assert config_entry.state is ConfigEntryState.LOADED + + # Wait for a bit for the USB scan debouncer to cool off + async_fire_time_changed(hass, dt_util.now() + timedelta(minutes=5)) + + # Unplug the stick + mock_exists.return_value = False + + with patch_scanned_serial_ports(return_value=[]): + await async_request_scan(hass) + + # The integration has reloaded and is now in a failed state + await hass.async_block_till_done(wait_background_tasks=True) + assert config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/homeassistant_connect_zbt2/test_update.py b/tests/components/homeassistant_connect_zbt2/test_update.py new file mode 100644 index 00000000000000..463caf65686064 --- /dev/null +++ b/tests/components/homeassistant_connect_zbt2/test_update.py @@ -0,0 +1,131 @@ +"""Test Connect ZBT-2 firmware update entity.""" + +import pytest + +from homeassistant.components.homeassistant_hardware.helpers import ( + async_notify_firmware_info, +) +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .common import USB_DATA_ZBT2 + +from tests.common import MockConfigEntry + +UPDATE_ENTITY_ID = "update.home_assistant_connect_zbt_2_80b54eefae18_firmware" + + +async def test_zbt2_update_entity(hass: HomeAssistant) -> None: + """Test the ZBT-2 firmware update entity.""" + await async_setup_component(hass, "homeassistant", {}) + + # Set up the ZBT-2 integration + zbt2_config_entry = MockConfigEntry( + domain="homeassistant_connect_zbt2", + data={ + "firmware": "ezsp", + "firmware_version": "7.3.1.0 build 0", + "device": USB_DATA_ZBT2.device, + "manufacturer": USB_DATA_ZBT2.manufacturer, + "pid": USB_DATA_ZBT2.pid, + "product": USB_DATA_ZBT2.description, + "serial_number": USB_DATA_ZBT2.serial_number, + "vid": USB_DATA_ZBT2.vid, + }, + version=1, + minor_version=1, + ) + zbt2_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(zbt2_config_entry.entry_id) + await hass.async_block_till_done() + + # Pretend ZHA loaded and notified hardware of the running firmware + await async_notify_firmware_info( + hass, + "zha", + FirmwareInfo( + device=USB_DATA_ZBT2.device, + firmware_type=ApplicationType.EZSP, + firmware_version="7.3.1.0 build 0", + owners=[], + source="zha", + ), + ) + await hass.async_block_till_done() + + state_ezsp = hass.states.get(UPDATE_ENTITY_ID) + assert state_ezsp is not None + assert state_ezsp.state == "unknown" + assert state_ezsp.attributes["title"] == "EmberZNet Zigbee" + assert state_ezsp.attributes["installed_version"] == "7.3.1.0" + assert state_ezsp.attributes["latest_version"] is None + + # Now, have OTBR push some info + await async_notify_firmware_info( + hass, + "otbr", + FirmwareInfo( + device=USB_DATA_ZBT2.device, + firmware_type=ApplicationType.SPINEL, + firmware_version="SL-OPENTHREAD/2.4.4.0_GitHub-7074a43e4; EFR32; Oct 21 2024 14:40:57", + owners=[], + source="otbr", + ), + ) + await hass.async_block_till_done() + + # After the firmware update, the entity has the new version and the correct state + state_spinel = hass.states.get(UPDATE_ENTITY_ID) + assert state_spinel is not None + assert state_spinel.state == "unknown" + assert state_spinel.attributes["title"] == "OpenThread RCP" + assert state_spinel.attributes["installed_version"] == "2.4.4.0" + assert state_spinel.attributes["latest_version"] is None + + +@pytest.mark.parametrize( + ("firmware", "version", "expected"), + [ + ("ezsp", "7.3.1.0 build 0", "EmberZNet Zigbee 7.3.1.0"), + ("spinel", "SL-OPENTHREAD/2.4.4.0_GitHub-7074a43e4", "OpenThread RCP 2.4.4.0"), + ("bootloader", "2.4.2", "Gecko Bootloader 2.4.2"), + ("router", "1.2.3.4", "Unknown 1.2.3.4"), # Not supported but still shown + ], +) +async def test_zbt2_update_entity_state( + hass: HomeAssistant, firmware: str, version: str, expected: str +) -> None: + """Test the ZBT-2 firmware update entity with different firmware types.""" + await async_setup_component(hass, "homeassistant", {}) + + zbt2_config_entry = MockConfigEntry( + domain="homeassistant_connect_zbt2", + data={ + "firmware": firmware, + "firmware_version": version, + "device": USB_DATA_ZBT2.device, + "manufacturer": USB_DATA_ZBT2.manufacturer, + "pid": USB_DATA_ZBT2.pid, + "product": USB_DATA_ZBT2.description, + "serial_number": USB_DATA_ZBT2.serial_number, + "vid": USB_DATA_ZBT2.vid, + }, + version=1, + minor_version=1, + ) + zbt2_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(zbt2_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(UPDATE_ENTITY_ID) + assert state is not None + assert ( + f"{state.attributes['title']} {state.attributes['installed_version']}" + == expected + ) diff --git a/tests/components/homeassistant_connect_zbt2/test_util.py b/tests/components/homeassistant_connect_zbt2/test_util.py new file mode 100644 index 00000000000000..8541c880b007b1 --- /dev/null +++ b/tests/components/homeassistant_connect_zbt2/test_util.py @@ -0,0 +1,36 @@ +"""Test Connect ZBT-2 utilities.""" + +from homeassistant.components.homeassistant_connect_zbt2.const import DOMAIN +from homeassistant.components.homeassistant_connect_zbt2.util import ( + get_usb_service_info, +) +from homeassistant.helpers.service_info.usb import UsbServiceInfo + +from tests.common import MockConfigEntry + +CONNECT_ZBT2_CONFIG_ENTRY = MockConfigEntry( + domain=DOMAIN, + unique_id="some_unique_id", + data={ + "device": "/dev/serial/by-id/usb-Nabu_Casa_ZBT-2_80B54EEFAE18-if01-port0", + "vid": "303A", + "pid": "4001", + "serial_number": "80B54EEFAE18", + "manufacturer": "Nabu Casa", + "product": "ZBT-2", + "firmware": "ezsp", + }, + version=2, +) + + +def test_get_usb_service_info() -> None: + """Test `get_usb_service_info` conversion.""" + assert get_usb_service_info(CONNECT_ZBT2_CONFIG_ENTRY) == UsbServiceInfo( + device=CONNECT_ZBT2_CONFIG_ENTRY.data["device"], + vid=CONNECT_ZBT2_CONFIG_ENTRY.data["vid"], + pid=CONNECT_ZBT2_CONFIG_ENTRY.data["pid"], + serial_number=CONNECT_ZBT2_CONFIG_ENTRY.data["serial_number"], + manufacturer=CONNECT_ZBT2_CONFIG_ENTRY.data["manufacturer"], + description=CONNECT_ZBT2_CONFIG_ENTRY.data["product"], + )