diff --git a/CODEOWNERS b/CODEOWNERS index f8f4513ac06..6a5e4ea437b 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 00000000000..7862f1b3422 --- /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 00000000000..8f106a8669c --- /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 00000000000..c0b07a88687 --- /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 00000000000..8367df6501d --- /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 00000000000..5d5c2996e47 --- /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 00000000000..a52b5abf0f1 --- /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 00000000000..13775d1f1eb --- /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 00000000000..24ddf417180 --- /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 00000000000..ebd6f33a8a8 --- /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 114e8230596..d636fce1d3c 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -265,6 +265,7 @@ FLOWS = { "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 7effcc500bb..183c7956275 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 dee0367de24..96cf6752405 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -4,6 +4,12 @@ To update, run python3 -m script.hassfest """ 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 02c96930bf5..74aad78dc6a 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -79,6 +79,7 @@ NO_IOT_CLASS = [ "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 00000000000..298f21ce3f7 --- /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 00000000000..78a4b754479 --- /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 00000000000..d6b8fa09a3f --- /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 00000000000..7a1a1875bd0 --- /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 00000000000..030a2610d64 --- /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 00000000000..42f5f8ac5a5 --- /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 00000000000..463caf65686 --- /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 00000000000..8541c880b00 --- /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"], + )