From aa5b970102ce65fb76028fc57d6d6fabd7cedf16 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 29 Oct 2025 09:45:56 -0400 Subject: [PATCH] Beta firmware update switch for Connect integrations (#155370) --- .../homeassistant_connect_zbt2/__init__.py | 40 ++- .../homeassistant_connect_zbt2/const.py | 2 +- .../homeassistant_connect_zbt2/icons.json | 9 + .../homeassistant_connect_zbt2/strings.json | 7 + .../homeassistant_connect_zbt2/switch.py | 54 ++++ .../homeassistant_connect_zbt2/update.py | 39 +-- .../homeassistant_hardware/manifest.json | 2 +- .../homeassistant_hardware/switch.py | 64 +++++ .../homeassistant_sky_connect/__init__.py | 43 ++- .../homeassistant_sky_connect/const.py | 2 +- .../homeassistant_sky_connect/icons.json | 9 + .../homeassistant_sky_connect/strings.json | 7 + .../homeassistant_sky_connect/switch.py | 57 ++++ .../homeassistant_sky_connect/update.py | 31 +- .../homeassistant_yellow/__init__.py | 51 +++- .../components/homeassistant_yellow/const.py | 2 +- .../homeassistant_yellow/icons.json | 9 + .../homeassistant_yellow/strings.json | 5 + .../components/homeassistant_yellow/switch.py | 50 ++++ .../components/homeassistant_yellow/update.py | 40 +-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../homeassistant_connect_zbt2/test_switch.py | 136 +++++++++ .../homeassistant_hardware/common.py | 31 ++ .../homeassistant_hardware/test_switch.py | 271 ++++++++++++++++++ .../homeassistant_hardware/test_update.py | 29 +- .../homeassistant_sky_connect/test_switch.py | 134 +++++++++ .../homeassistant_yellow/test_switch.py | 135 +++++++++ 28 files changed, 1124 insertions(+), 139 deletions(-) create mode 100644 homeassistant/components/homeassistant_connect_zbt2/icons.json create mode 100644 homeassistant/components/homeassistant_connect_zbt2/switch.py create mode 100644 homeassistant/components/homeassistant_hardware/switch.py create mode 100644 homeassistant/components/homeassistant_sky_connect/icons.json create mode 100644 homeassistant/components/homeassistant_sky_connect/switch.py create mode 100644 homeassistant/components/homeassistant_yellow/icons.json create mode 100644 homeassistant/components/homeassistant_yellow/switch.py create mode 100644 tests/components/homeassistant_connect_zbt2/test_switch.py create mode 100644 tests/components/homeassistant_hardware/common.py create mode 100644 tests/components/homeassistant_hardware/test_switch.py create mode 100644 tests/components/homeassistant_sky_connect/test_switch.py create mode 100644 tests/components/homeassistant_yellow/test_switch.py diff --git a/homeassistant/components/homeassistant_connect_zbt2/__init__.py b/homeassistant/components/homeassistant_connect_zbt2/__init__.py index 7862f1b3422..cbd88114e66 100644 --- a/homeassistant/components/homeassistant_connect_zbt2/__init__.py +++ b/homeassistant/components/homeassistant_connect_zbt2/__init__.py @@ -2,20 +2,35 @@ from __future__ import annotations +from dataclasses import dataclass import logging import os.path +from homeassistant.components.homeassistant_hardware.coordinator import ( + FirmwareUpdateCoordinator, +) 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.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType -from .const import DEVICE, DOMAIN +from .const import DEVICE, DOMAIN, NABU_CASA_FIRMWARE_RELEASES_URL _LOGGER = logging.getLogger(__name__) +type HomeAssistantConnectZBT2ConfigEntry = ConfigEntry[HomeAssistantConnectZBT2Data] + + +@dataclass +class HomeAssistantConnectZBT2Data: + """Runtime data definition.""" + + coordinator: FirmwareUpdateCoordinator + + CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) @@ -49,7 +64,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: HomeAssistantConnectZBT2ConfigEntry +) -> bool: """Set up a Home Assistant Connect ZBT-2 config entry.""" # Postpone loading the config entry if the device is missing @@ -60,12 +77,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: translation_key="device_disconnected", ) - await hass.config_entries.async_forward_entry_setups(entry, ["update"]) + # Create and store the firmware update coordinator in runtime_data + session = async_get_clientsession(hass) + coordinator = FirmwareUpdateCoordinator( + hass, + entry, + session, + NABU_CASA_FIRMWARE_RELEASES_URL, + ) + entry.runtime_data = HomeAssistantConnectZBT2Data(coordinator) + + await hass.config_entries.async_forward_entry_setups(entry, ["switch", "update"]) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: HomeAssistantConnectZBT2ConfigEntry +) -> bool: """Unload a config entry.""" - await hass.config_entries.async_unload_platforms(entry, ["update"]) - return True + return await hass.config_entries.async_unload_platforms(entry, ["switch", "update"]) diff --git a/homeassistant/components/homeassistant_connect_zbt2/const.py b/homeassistant/components/homeassistant_connect_zbt2/const.py index c0b07a88687..556c2a5b80d 100644 --- a/homeassistant/components/homeassistant_connect_zbt2/const.py +++ b/homeassistant/components/homeassistant_connect_zbt2/const.py @@ -3,7 +3,7 @@ DOMAIN = "homeassistant_connect_zbt2" NABU_CASA_FIRMWARE_RELEASES_URL = ( - "https://api.github.com/repos/NabuCasa/silabs-firmware-builder/releases/latest" + "https://api.github.com/repos/NabuCasa/silabs-firmware-builder/releases" ) FIRMWARE = "firmware" diff --git a/homeassistant/components/homeassistant_connect_zbt2/icons.json b/homeassistant/components/homeassistant_connect_zbt2/icons.json new file mode 100644 index 00000000000..88c4a7becd3 --- /dev/null +++ b/homeassistant/components/homeassistant_connect_zbt2/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "switch": { + "beta_firmware": { + "default": "mdi:test-tube" + } + } + } +} diff --git a/homeassistant/components/homeassistant_connect_zbt2/strings.json b/homeassistant/components/homeassistant_connect_zbt2/strings.json index f0bcd0d3cc7..f1656c3febb 100644 --- a/homeassistant/components/homeassistant_connect_zbt2/strings.json +++ b/homeassistant/components/homeassistant_connect_zbt2/strings.json @@ -90,6 +90,13 @@ } } }, + "entity": { + "switch": { + "beta_firmware": { + "name": "Beta firmware updates" + } + } + }, "exceptions": { "device_disconnected": { "message": "The device is not plugged in" diff --git a/homeassistant/components/homeassistant_connect_zbt2/switch.py b/homeassistant/components/homeassistant_connect_zbt2/switch.py new file mode 100644 index 00000000000..07d6677e999 --- /dev/null +++ b/homeassistant/components/homeassistant_connect_zbt2/switch.py @@ -0,0 +1,54 @@ +"""Home Assistant Connect ZBT-2 switch entities.""" + +from __future__ import annotations + +import logging + +from homeassistant.components.homeassistant_hardware.coordinator import ( + FirmwareUpdateCoordinator, +) +from homeassistant.components.homeassistant_hardware.switch import ( + BaseBetaFirmwareSwitch, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import HomeAssistantConnectZBT2ConfigEntry +from .const import DOMAIN, HARDWARE_NAME, SERIAL_NUMBER + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomeAssistantConnectZBT2ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the switch platform for Home Assistant Connect ZBT-2.""" + async_add_entities( + [BetaFirmwareSwitch(config_entry.runtime_data.coordinator, config_entry)] + ) + + +class BetaFirmwareSwitch(BaseBetaFirmwareSwitch): + """Home Assistant Connect ZBT-2 beta firmware switch.""" + + def __init__( + self, + coordinator: FirmwareUpdateCoordinator, + config_entry: HomeAssistantConnectZBT2ConfigEntry, + ) -> None: + """Initialize the beta firmware switch.""" + super().__init__(coordinator, config_entry) + + serial_number = self._config_entry.data[SERIAL_NUMBER] + + self._attr_unique_id = f"{serial_number}_beta_firmware" + 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, + ) diff --git a/homeassistant/components/homeassistant_connect_zbt2/update.py b/homeassistant/components/homeassistant_connect_zbt2/update.py index e6d66ca822d..db1993217ce 100644 --- a/homeassistant/components/homeassistant_connect_zbt2/update.py +++ b/homeassistant/components/homeassistant_connect_zbt2/update.py @@ -4,8 +4,6 @@ from __future__ import annotations import logging -import aiohttp - from homeassistant.components.homeassistant_hardware.coordinator import ( FirmwareUpdateCoordinator, ) @@ -19,22 +17,14 @@ from homeassistant.components.homeassistant_hardware.util import ( ResetTarget, ) 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, -) +from . import HomeAssistantConnectZBT2ConfigEntry +from .const import DOMAIN, FIRMWARE, FIRMWARE_VERSION, HARDWARE_NAME, SERIAL_NUMBER _LOGGER = logging.getLogger(__name__) @@ -91,8 +81,7 @@ FIRMWARE_ENTITY_DESCRIPTIONS: dict[ def _async_create_update_entity( hass: HomeAssistant, - config_entry: ConfigEntry, - session: aiohttp.ClientSession, + config_entry: HomeAssistantConnectZBT2ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> FirmwareUpdateEntity: """Create an update entity that handles firmware type changes.""" @@ -111,12 +100,7 @@ def _async_create_update_entity( entity = FirmwareUpdateEntity( device=config_entry.data["device"], config_entry=config_entry, - update_coordinator=FirmwareUpdateCoordinator( - hass, - config_entry, - session, - NABU_CASA_FIRMWARE_RELEASES_URL, - ), + update_coordinator=config_entry.runtime_data.coordinator, entity_description=entity_description, ) @@ -126,11 +110,7 @@ def _async_create_update_entity( """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 - ) - ] + [_async_create_update_entity(hass, config_entry, async_add_entities)] ) entity.async_on_remove( @@ -142,14 +122,11 @@ def _async_create_update_entity( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HomeAssistantConnectZBT2ConfigEntry, 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 - ) + entity = _async_create_update_entity(hass, config_entry, async_add_entities) async_add_entities([entity]) @@ -162,7 +139,7 @@ class FirmwareUpdateEntity(BaseFirmwareUpdateEntity): def __init__( self, device: str, - config_entry: ConfigEntry, + config_entry: HomeAssistantConnectZBT2ConfigEntry, update_coordinator: FirmwareUpdateCoordinator, entity_description: FirmwareUpdateEntityDescription, ) -> None: diff --git a/homeassistant/components/homeassistant_hardware/manifest.json b/homeassistant/components/homeassistant_hardware/manifest.json index 01c27300d71..202ce76f48c 100644 --- a/homeassistant/components/homeassistant_hardware/manifest.json +++ b/homeassistant/components/homeassistant_hardware/manifest.json @@ -8,6 +8,6 @@ "integration_type": "system", "requirements": [ "universal-silabs-flasher==0.0.35", - "ha-silabs-firmware-client==0.2.0" + "ha-silabs-firmware-client==0.3.0" ] } diff --git a/homeassistant/components/homeassistant_hardware/switch.py b/homeassistant/components/homeassistant_hardware/switch.py new file mode 100644 index 00000000000..6da4964da39 --- /dev/null +++ b/homeassistant/components/homeassistant_hardware/switch.py @@ -0,0 +1,64 @@ +"""Home Assistant Hardware base beta firmware switch entity.""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.helpers.restore_state import RestoreEntity + +from .coordinator import FirmwareUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class BaseBetaFirmwareSwitch(SwitchEntity, RestoreEntity): + """Base switch to enable beta firmware updates.""" + + _attr_has_entity_name = True + _attr_entity_category = EntityCategory.CONFIG + _attr_entity_registry_enabled_default = False + _attr_translation_key = "beta_firmware" + + def __init__( + self, + coordinator: FirmwareUpdateCoordinator, + config_entry: ConfigEntry, + ) -> None: + """Initialize the beta firmware switch.""" + self._coordinator = coordinator + self._config_entry = config_entry + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added to hass.""" + await super().async_added_to_hass() + + # Restore the last state + last_state = await self.async_get_last_state() + if last_state is not None: + self._attr_is_on = last_state.state == "on" + else: + self._attr_is_on = False + + # Apply the restored state to the coordinator + await self._update_coordinator_prerelease() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on beta firmware updates.""" + self._attr_is_on = True + self.async_write_ha_state() + await self._update_coordinator_prerelease() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off beta firmware updates.""" + self._attr_is_on = False + self.async_write_ha_state() + await self._update_coordinator_prerelease() + + async def _update_coordinator_prerelease(self) -> None: + """Update the coordinator with the current prerelease setting.""" + self._coordinator.client.update_prerelease(bool(self._attr_is_on)) + await self._coordinator.async_refresh() diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index dfc129ddc75..943892fc910 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -2,9 +2,13 @@ from __future__ import annotations +from dataclasses import dataclass import logging import os.path +from homeassistant.components.homeassistant_hardware.coordinator import ( + FirmwareUpdateCoordinator, +) from homeassistant.components.homeassistant_hardware.util import guess_firmware_info from homeassistant.components.usb import ( USBDevice, @@ -15,6 +19,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType from .const import ( @@ -24,6 +29,7 @@ from .const import ( FIRMWARE, FIRMWARE_VERSION, MANUFACTURER, + NABU_CASA_FIRMWARE_RELEASES_URL, PID, PRODUCT, SERIAL_NUMBER, @@ -32,6 +38,16 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +type HomeAssistantSkyConnectConfigEntry = ConfigEntry[HomeAssistantSkyConnectData] + + +@dataclass +class HomeAssistantSkyConnectData: + """Runtime data definition.""" + + coordinator: FirmwareUpdateCoordinator + + CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) @@ -65,7 +81,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: HomeAssistantSkyConnectConfigEntry +) -> bool: """Set up a Home Assistant SkyConnect config entry.""" # Postpone loading the config entry if the device is missing @@ -76,18 +94,31 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: translation_key="device_disconnected", ) - await hass.config_entries.async_forward_entry_setups(entry, ["update"]) + # Create and store the firmware update coordinator in runtime_data + session = async_get_clientsession(hass) + coordinator = FirmwareUpdateCoordinator( + hass, + entry, + session, + NABU_CASA_FIRMWARE_RELEASES_URL, + ) + entry.runtime_data = HomeAssistantSkyConnectData(coordinator) + + await hass.config_entries.async_forward_entry_setups(entry, ["switch", "update"]) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: HomeAssistantSkyConnectConfigEntry +) -> bool: """Unload a config entry.""" - await hass.config_entries.async_unload_platforms(entry, ["update"]) - return True + return await hass.config_entries.async_unload_platforms(entry, ["switch", "update"]) -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: HomeAssistantSkyConnectConfigEntry +) -> bool: """Migrate old entry.""" _LOGGER.debug( diff --git a/homeassistant/components/homeassistant_sky_connect/const.py b/homeassistant/components/homeassistant_sky_connect/const.py index 70ff047366d..2be9dd68957 100644 --- a/homeassistant/components/homeassistant_sky_connect/const.py +++ b/homeassistant/components/homeassistant_sky_connect/const.py @@ -8,7 +8,7 @@ DOMAIN = "homeassistant_sky_connect" DOCS_WEB_FLASHER_URL = "https://skyconnect.home-assistant.io/firmware-update/" NABU_CASA_FIRMWARE_RELEASES_URL = ( - "https://api.github.com/repos/NabuCasa/silabs-firmware-builder/releases/latest" + "https://api.github.com/repos/NabuCasa/silabs-firmware-builder/releases" ) FIRMWARE = "firmware" diff --git a/homeassistant/components/homeassistant_sky_connect/icons.json b/homeassistant/components/homeassistant_sky_connect/icons.json new file mode 100644 index 00000000000..88c4a7becd3 --- /dev/null +++ b/homeassistant/components/homeassistant_sky_connect/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "switch": { + "beta_firmware": { + "default": "mdi:test-tube" + } + } + } +} diff --git a/homeassistant/components/homeassistant_sky_connect/strings.json b/homeassistant/components/homeassistant_sky_connect/strings.json index f0bcd0d3cc7..f1656c3febb 100644 --- a/homeassistant/components/homeassistant_sky_connect/strings.json +++ b/homeassistant/components/homeassistant_sky_connect/strings.json @@ -90,6 +90,13 @@ } } }, + "entity": { + "switch": { + "beta_firmware": { + "name": "Beta firmware updates" + } + } + }, "exceptions": { "device_disconnected": { "message": "The device is not plugged in" diff --git a/homeassistant/components/homeassistant_sky_connect/switch.py b/homeassistant/components/homeassistant_sky_connect/switch.py new file mode 100644 index 00000000000..249e744fe87 --- /dev/null +++ b/homeassistant/components/homeassistant_sky_connect/switch.py @@ -0,0 +1,57 @@ +"""Home Assistant SkyConnect switch entities.""" + +from __future__ import annotations + +import logging + +from homeassistant.components.homeassistant_hardware.coordinator import ( + FirmwareUpdateCoordinator, +) +from homeassistant.components.homeassistant_hardware.switch import ( + BaseBetaFirmwareSwitch, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import HomeAssistantSkyConnectConfigEntry +from .const import DOMAIN, PRODUCT, SERIAL_NUMBER, HardwareVariant + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomeAssistantSkyConnectConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the switch platform for Home Assistant SkyConnect.""" + async_add_entities( + [BetaFirmwareSwitch(config_entry.runtime_data.coordinator, config_entry)] + ) + + +class BetaFirmwareSwitch(BaseBetaFirmwareSwitch): + """Home Assistant SkyConnect beta firmware switch.""" + + def __init__( + self, + coordinator: FirmwareUpdateCoordinator, + config_entry: HomeAssistantSkyConnectConfigEntry, + ) -> None: + """Initialize the beta firmware switch.""" + super().__init__(coordinator, config_entry) + + variant = HardwareVariant.from_usb_product_name( + self._config_entry.data[PRODUCT] + ) + serial_number = self._config_entry.data[SERIAL_NUMBER] + + self._attr_unique_id = f"{serial_number}_beta_firmware" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, serial_number)}, + name=f"{variant.full_name} ({serial_number[:8]})", + model=variant.full_name, + manufacturer="Nabu Casa", + serial_number=serial_number, + ) diff --git a/homeassistant/components/homeassistant_sky_connect/update.py b/homeassistant/components/homeassistant_sky_connect/update.py index eab9fc232a4..58177249b62 100644 --- a/homeassistant/components/homeassistant_sky_connect/update.py +++ b/homeassistant/components/homeassistant_sky_connect/update.py @@ -4,8 +4,6 @@ from __future__ import annotations import logging -import aiohttp - from homeassistant.components.homeassistant_hardware.coordinator import ( FirmwareUpdateCoordinator, ) @@ -18,19 +16,17 @@ from homeassistant.components.homeassistant_hardware.util import ( 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 . import HomeAssistantSkyConnectConfigEntry from .const import ( DOMAIN, FIRMWARE, FIRMWARE_VERSION, - NABU_CASA_FIRMWARE_RELEASES_URL, PRODUCT, SERIAL_NUMBER, HardwareVariant, @@ -102,8 +98,7 @@ FIRMWARE_ENTITY_DESCRIPTIONS: dict[ def _async_create_update_entity( hass: HomeAssistant, - config_entry: ConfigEntry, - session: aiohttp.ClientSession, + config_entry: HomeAssistantSkyConnectConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> FirmwareUpdateEntity: """Create an update entity that handles firmware type changes.""" @@ -122,12 +117,7 @@ def _async_create_update_entity( entity = FirmwareUpdateEntity( device=config_entry.data["device"], config_entry=config_entry, - update_coordinator=FirmwareUpdateCoordinator( - hass, - config_entry, - session, - NABU_CASA_FIRMWARE_RELEASES_URL, - ), + update_coordinator=config_entry.runtime_data.coordinator, entity_description=entity_description, ) @@ -137,11 +127,7 @@ def _async_create_update_entity( """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 - ) - ] + [_async_create_update_entity(hass, config_entry, async_add_entities)] ) entity.async_on_remove( @@ -153,14 +139,11 @@ def _async_create_update_entity( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HomeAssistantSkyConnectConfigEntry, 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 - ) + entity = _async_create_update_entity(hass, config_entry, async_add_entities) async_add_entities([entity]) @@ -174,7 +157,7 @@ class FirmwareUpdateEntity(BaseFirmwareUpdateEntity): def __init__( self, device: str, - config_entry: ConfigEntry, + config_entry: HomeAssistantSkyConnectConfigEntry, update_coordinator: FirmwareUpdateCoordinator, entity_description: FirmwareUpdateEntityDescription, ) -> None: diff --git a/homeassistant/components/homeassistant_yellow/__init__.py b/homeassistant/components/homeassistant_yellow/__init__.py index 07fe496b049..e772c0fe7b3 100644 --- a/homeassistant/components/homeassistant_yellow/__init__.py +++ b/homeassistant/components/homeassistant_yellow/__init__.py @@ -2,9 +2,13 @@ from __future__ import annotations +from dataclasses import dataclass import logging from homeassistant.components.hassio import get_os_info +from homeassistant.components.homeassistant_hardware.coordinator import ( + FirmwareUpdateCoordinator, +) from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( check_multi_pan_addon, ) @@ -16,14 +20,34 @@ from homeassistant.config_entries import SOURCE_HARDWARE, ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import discovery_flow +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.hassio import is_hassio -from .const import FIRMWARE, FIRMWARE_VERSION, RADIO_DEVICE, ZHA_HW_DISCOVERY_DATA +from .const import ( + FIRMWARE, + FIRMWARE_VERSION, + NABU_CASA_FIRMWARE_RELEASES_URL, + RADIO_DEVICE, + ZHA_HW_DISCOVERY_DATA, +) _LOGGER = logging.getLogger(__name__) +type HomeAssistantYellowConfigEntry = ConfigEntry[HomeAssistantYellowData] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +@dataclass +class HomeAssistantYellowData: + """Runtime data definition.""" + + coordinator: ( + FirmwareUpdateCoordinator # Type from homeassistant_hardware.coordinator + ) + + +async def async_setup_entry( + hass: HomeAssistant, entry: HomeAssistantYellowConfigEntry +) -> bool: """Set up a Home Assistant Yellow config entry.""" if not is_hassio(hass): # Not running under supervisor, Home Assistant may have been migrated @@ -56,18 +80,31 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: data=ZHA_HW_DISCOVERY_DATA, ) - await hass.config_entries.async_forward_entry_setups(entry, ["update"]) + # Create and store the firmware update coordinator in runtime_data + session = async_get_clientsession(hass) + coordinator = FirmwareUpdateCoordinator( + hass, + entry, + session, + NABU_CASA_FIRMWARE_RELEASES_URL, + ) + entry.runtime_data = HomeAssistantYellowData(coordinator) + + await hass.config_entries.async_forward_entry_setups(entry, ["switch", "update"]) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: HomeAssistantYellowConfigEntry +) -> bool: """Unload a config entry.""" - await hass.config_entries.async_unload_platforms(entry, ["update"]) - return True + return await hass.config_entries.async_unload_platforms(entry, ["switch", "update"]) -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: HomeAssistantYellowConfigEntry +) -> bool: """Migrate old entry.""" _LOGGER.debug( diff --git a/homeassistant/components/homeassistant_yellow/const.py b/homeassistant/components/homeassistant_yellow/const.py index b8bf17391f9..cc3c7ae62e9 100644 --- a/homeassistant/components/homeassistant_yellow/const.py +++ b/homeassistant/components/homeassistant_yellow/const.py @@ -22,5 +22,5 @@ FIRMWARE_VERSION = "firmware_version" ZHA_DOMAIN = "zha" NABU_CASA_FIRMWARE_RELEASES_URL = ( - "https://api.github.com/repos/NabuCasa/silabs-firmware-builder/releases/latest" + "https://api.github.com/repos/NabuCasa/silabs-firmware-builder/releases" ) diff --git a/homeassistant/components/homeassistant_yellow/icons.json b/homeassistant/components/homeassistant_yellow/icons.json new file mode 100644 index 00000000000..88c4a7becd3 --- /dev/null +++ b/homeassistant/components/homeassistant_yellow/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "switch": { + "beta_firmware": { + "default": "mdi:test-tube" + } + } + } +} diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index 8be5b8ad134..14de1de4aa4 100644 --- a/homeassistant/components/homeassistant_yellow/strings.json +++ b/homeassistant/components/homeassistant_yellow/strings.json @@ -1,5 +1,10 @@ { "entity": { + "switch": { + "beta_firmware": { + "name": "Radio beta firmware updates" + } + }, "update": { "radio_firmware": { "name": "Radio firmware" diff --git a/homeassistant/components/homeassistant_yellow/switch.py b/homeassistant/components/homeassistant_yellow/switch.py new file mode 100644 index 00000000000..3e4cf01c370 --- /dev/null +++ b/homeassistant/components/homeassistant_yellow/switch.py @@ -0,0 +1,50 @@ +"""Home Assistant Yellow switch entities.""" + +from __future__ import annotations + +import logging + +from homeassistant.components.homeassistant_hardware.coordinator import ( + FirmwareUpdateCoordinator, +) +from homeassistant.components.homeassistant_hardware.switch import ( + BaseBetaFirmwareSwitch, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import HomeAssistantYellowConfigEntry +from .const import DOMAIN, MANUFACTURER, MODEL + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomeAssistantYellowConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the switch platform for Home Assistant Yellow.""" + async_add_entities( + [BetaFirmwareSwitch(config_entry.runtime_data.coordinator, config_entry)] + ) + + +class BetaFirmwareSwitch(BaseBetaFirmwareSwitch): + """Home Assistant Yellow beta firmware switch.""" + + def __init__( + self, + coordinator: FirmwareUpdateCoordinator, + config_entry: HomeAssistantYellowConfigEntry, + ) -> None: + """Initialize the beta firmware switch.""" + super().__init__(coordinator, config_entry) + self._attr_unique_id = "beta_firmware" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, "yellow")}, + name=MODEL, + model=MODEL, + manufacturer=MANUFACTURER, + ) diff --git a/homeassistant/components/homeassistant_yellow/update.py b/homeassistant/components/homeassistant_yellow/update.py index d86ac93a848..f62ecb06aa5 100644 --- a/homeassistant/components/homeassistant_yellow/update.py +++ b/homeassistant/components/homeassistant_yellow/update.py @@ -4,8 +4,6 @@ from __future__ import annotations import logging -import aiohttp - from homeassistant.components.homeassistant_hardware.coordinator import ( FirmwareUpdateCoordinator, ) @@ -19,23 +17,14 @@ from homeassistant.components.homeassistant_hardware.util import ( ResetTarget, ) 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, - MANUFACTURER, - MODEL, - NABU_CASA_FIRMWARE_RELEASES_URL, - RADIO_DEVICE, -) +from . import HomeAssistantYellowConfigEntry +from .const import DOMAIN, FIRMWARE, FIRMWARE_VERSION, MANUFACTURER, MODEL, RADIO_DEVICE _LOGGER = logging.getLogger(__name__) @@ -108,8 +97,7 @@ FIRMWARE_ENTITY_DESCRIPTIONS: dict[ def _async_create_update_entity( hass: HomeAssistant, - config_entry: ConfigEntry, - session: aiohttp.ClientSession, + config_entry: HomeAssistantYellowConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> FirmwareUpdateEntity: """Create an update entity that handles firmware type changes.""" @@ -128,12 +116,7 @@ def _async_create_update_entity( entity = FirmwareUpdateEntity( device=RADIO_DEVICE, config_entry=config_entry, - update_coordinator=FirmwareUpdateCoordinator( - hass, - config_entry, - session, - NABU_CASA_FIRMWARE_RELEASES_URL, - ), + update_coordinator=config_entry.runtime_data.coordinator, entity_description=entity_description, ) @@ -143,11 +126,7 @@ def _async_create_update_entity( """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 - ) - ] + [_async_create_update_entity(hass, config_entry, async_add_entities)] ) entity.async_on_remove( @@ -159,14 +138,11 @@ def _async_create_update_entity( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HomeAssistantYellowConfigEntry, 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 - ) + entity = _async_create_update_entity(hass, config_entry, async_add_entities) async_add_entities([entity]) @@ -179,7 +155,7 @@ class FirmwareUpdateEntity(BaseFirmwareUpdateEntity): def __init__( self, device: str, - config_entry: ConfigEntry, + config_entry: HomeAssistantYellowConfigEntry, update_coordinator: FirmwareUpdateCoordinator, entity_description: FirmwareUpdateEntityDescription, ) -> None: diff --git a/requirements_all.txt b/requirements_all.txt index 5b0a9372b21..0e6c4aa649a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1151,7 +1151,7 @@ ha-iotawattpy==0.1.2 ha-philipsjs==3.2.4 # homeassistant.components.homeassistant_hardware -ha-silabs-firmware-client==0.2.0 +ha-silabs-firmware-client==0.3.0 # homeassistant.components.habitica habiticalib==0.4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 56d2b47f6bc..8ae9b08ce40 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1012,7 +1012,7 @@ ha-iotawattpy==0.1.2 ha-philipsjs==3.2.4 # homeassistant.components.homeassistant_hardware -ha-silabs-firmware-client==0.2.0 +ha-silabs-firmware-client==0.3.0 # homeassistant.components.habitica habiticalib==0.4.6 diff --git a/tests/components/homeassistant_connect_zbt2/test_switch.py b/tests/components/homeassistant_connect_zbt2/test_switch.py new file mode 100644 index 00000000000..0b2f57689bc --- /dev/null +++ b/tests/components/homeassistant_connect_zbt2/test_switch.py @@ -0,0 +1,136 @@ +"""Test Connect ZBT-2 beta firmware switch entity.""" + +from unittest.mock import Mock, call, patch + +from ha_silabs_firmware_client import FirmwareManifest, FirmwareMetadata +import pytest +from yarl import URL + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import HomeAssistant, State +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from .common import USB_DATA_ZBT2 + +from tests.common import MockConfigEntry, mock_restore_cache + +SWITCH_ENTITY_ID = ( + "switch.home_assistant_connect_zbt_2_80b54eefae18_beta_firmware_updates" +) + +TEST_MANIFEST = FirmwareManifest( + url=URL("https://example.org/firmware"), + html_url=URL("https://example.org/release_notes"), + created_at=dt_util.utcnow(), + firmwares=( + FirmwareMetadata( + filename="skyconnect_zigbee_ncp_test.gbl", + checksum="aaa", + size=123, + release_notes="Some release notes go here", + metadata={ + "baudrate": 115200, + "ezsp_version": "7.4.4.0", + "fw_type": "zigbee_ncp", + "fw_variant": None, + "metadata_version": 2, + "sdk_version": "4.4.4", + }, + url=URL("https://example.org/firmwares/skyconnect_zigbee_ncp_test.gbl"), + ), + ), +) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("service", "target_state", "expected_prerelease"), + [ + (SERVICE_TURN_ON, STATE_ON, True), + (SERVICE_TURN_OFF, STATE_OFF, False), + ], +) +async def test_switch_turn_on_off( + hass: HomeAssistant, + service: str, + target_state: str, + expected_prerelease: bool, +) -> None: + """Test turning switch on/off updates state and coordinator.""" + await async_setup_component(hass, "homeassistant", {}) + + # Start with opposite state + mock_restore_cache( + hass, + [ + State( + SWITCH_ENTITY_ID, + STATE_ON if service == SERVICE_TURN_OFF else STATE_OFF, + ) + ], + ) + + # Set up the ZBT-2 integration + zbt2_config_entry = MockConfigEntry( + title="Home Assistant Connect ZBT-2", + 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) + + with ( + patch( + "homeassistant.components.homeassistant_hardware.coordinator.FirmwareUpdateClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.homeassistant_hardware.coordinator.FirmwareUpdateCoordinator.async_refresh" + ) as mock_refresh, + ): + mock_client.return_value.async_update_data.return_value = TEST_MANIFEST + mock_client.return_value.update_prerelease = Mock() + + assert await hass.config_entries.async_setup(zbt2_config_entry.entry_id) + await hass.async_block_till_done() + + # Reset mocks after setup + mock_client.return_value.update_prerelease.reset_mock() + mock_refresh.reset_mock() + + # Call the service + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: SWITCH_ENTITY_ID}, + blocking=True, + ) + + # Verify state changed + state = hass.states.get(SWITCH_ENTITY_ID) + assert state is not None + assert state.state == target_state + + # Verify coordinator methods were called + assert mock_client.return_value.update_prerelease.mock_calls == [ + call(expected_prerelease) + ] + assert len(mock_refresh.mock_calls) == 1 diff --git a/tests/components/homeassistant_hardware/common.py b/tests/components/homeassistant_hardware/common.py new file mode 100644 index 00000000000..fc0c68dbab4 --- /dev/null +++ b/tests/components/homeassistant_hardware/common.py @@ -0,0 +1,31 @@ +"""Common test constants for homeassistant_hardware tests.""" + +from ha_silabs_firmware_client import FirmwareManifest, FirmwareMetadata +from yarl import URL + +from homeassistant.util import dt as dt_util + +TEST_DOMAIN = "test" +TEST_FIRMWARE_RELEASES_URL = "https://example.org/firmware" +TEST_MANIFEST = FirmwareManifest( + url=URL("https://example.org/firmware"), + html_url=URL("https://example.org/release_notes"), + created_at=dt_util.utcnow(), + firmwares=( + FirmwareMetadata( + filename="skyconnect_zigbee_ncp_test.gbl", + checksum="aaa", + size=123, + release_notes="Some release notes go here", + metadata={ + "baudrate": 115200, + "ezsp_version": "7.4.4.0", + "fw_type": "zigbee_ncp", + "fw_variant": None, + "metadata_version": 2, + "sdk_version": "4.4.4", + }, + url=URL("https://example.org/firmwares/skyconnect_zigbee_ncp_test.gbl"), + ), + ), +) diff --git a/tests/components/homeassistant_hardware/test_switch.py b/tests/components/homeassistant_hardware/test_switch.py new file mode 100644 index 00000000000..a856aa33f0f --- /dev/null +++ b/tests/components/homeassistant_hardware/test_switch.py @@ -0,0 +1,271 @@ +"""Test Home Assistant Hardware beta firmware switch entity.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator +from unittest.mock import Mock, call, patch + +import pytest + +from homeassistant.components.homeassistant_hardware.coordinator import ( + FirmwareUpdateCoordinator, +) +from homeassistant.components.homeassistant_hardware.switch import ( + BaseBetaFirmwareSwitch, +) +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + EntityCategory, + Platform, +) +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import 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 homeassistant.setup import async_setup_component + +from .common import TEST_DOMAIN, TEST_FIRMWARE_RELEASES_URL, TEST_MANIFEST + +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + mock_config_flow, + mock_integration, + mock_platform, + mock_restore_cache, +) + +TEST_DEVICE = "/dev/serial/by-id/test-device-12345" +TEST_SWITCH_ENTITY_ID = "switch.mock_device_beta_firmware_updates" + + +class MockBetaFirmwareSwitch(BaseBetaFirmwareSwitch): + """Mock beta firmware switch for testing.""" + + def __init__( + self, + coordinator: FirmwareUpdateCoordinator, + config_entry: ConfigEntry, + ) -> None: + """Initialize the mock beta firmware switch.""" + super().__init__(coordinator, config_entry) + self._attr_unique_id = "beta_firmware" + self._attr_name = "Beta firmware updates" + self._attr_device_info = DeviceInfo( + identifiers={(TEST_DOMAIN, "test_device")}, + name="Mock Device", + model="Mock Model", + manufacturer="Mock Manufacturer", + ) + + +def _mock_async_create_switch_entity( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> MockBetaFirmwareSwitch: + """Create a mock switch entity.""" + session = async_get_clientsession(hass) + coordinator = FirmwareUpdateCoordinator( + hass, + config_entry, + session, + TEST_FIRMWARE_RELEASES_URL, + ) + entity = MockBetaFirmwareSwitch(coordinator, config_entry) + async_add_entities([entity]) + return entity + + +async def mock_async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry +) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups( + config_entry, [Platform.SWITCH] + ) + return True + + +async def mock_async_setup_switch_entities( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the beta firmware switch config entry.""" + _mock_async_create_switch_entity(hass, config_entry, async_add_entities) + + +@pytest.fixture(name="mock_firmware_client") +def mock_firmware_client_fixture(): + """Create a mock firmware update client.""" + with patch( + "homeassistant.components.homeassistant_hardware.coordinator.FirmwareUpdateClient", + autospec=True, + ) as mock_client: + mock_client.return_value.async_update_data.return_value = TEST_MANIFEST + mock_client.return_value.update_prerelease = Mock() + yield mock_client.return_value + + +@pytest.fixture(name="switch_config_entry") +async def mock_switch_config_entry( + hass: HomeAssistant, + mock_firmware_client, +) -> AsyncGenerator[ConfigEntry]: + """Set up a mock config entry for testing.""" + await async_setup_component(hass, "homeassistant", {}) + await async_setup_component(hass, "homeassistant_hardware", {}) + + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=mock_async_setup_entry, + ), + built_in=False, + ) + mock_platform(hass, "test.config_flow") + mock_platform( + hass, + "test.switch", + MockPlatform(async_setup_entry=mock_async_setup_switch_entities), + ) + + # Set up a mock integration using the hardware switch entity + config_entry = MockConfigEntry( + domain=TEST_DOMAIN, + data={ + "device": TEST_DEVICE, + }, + ) + config_entry.add_to_hass(hass) + + with mock_config_flow(TEST_DOMAIN, ConfigFlow): + yield config_entry + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_switch_default_off_state( + hass: HomeAssistant, + switch_config_entry: ConfigEntry, + mock_firmware_client, +) -> None: + """Test switch defaults to off when no previous state.""" + assert await hass.config_entries.async_setup(switch_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(TEST_SWITCH_ENTITY_ID) + assert state is not None + assert state.state == STATE_OFF + + # Verify coordinator was called with False during setup + assert mock_firmware_client.update_prerelease.mock_calls == [call(False)] + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("initial_state", "expected_state", "expected_prerelease"), + [ + (STATE_ON, STATE_ON, True), + (STATE_OFF, STATE_OFF, False), + ], +) +async def test_switch_restore_state( + hass: HomeAssistant, + switch_config_entry: ConfigEntry, + mock_firmware_client, + initial_state: str, + expected_state: str, + expected_prerelease: bool, +) -> None: + """Test switch restores previous state and has correct entity attributes.""" + mock_restore_cache(hass, [State(TEST_SWITCH_ENTITY_ID, initial_state)]) + + assert await hass.config_entries.async_setup(switch_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(TEST_SWITCH_ENTITY_ID) + assert state is not None + assert state.state == expected_state + assert state.attributes.get("friendly_name") == "Mock Device Beta firmware updates" + + # Verify coordinator was called with correct value during setup + assert mock_firmware_client.update_prerelease.mock_calls == [ + call(expected_prerelease) + ] + + # Verify entity registry attributes + entity_registry = er.async_get(hass) + entity_entry = entity_registry.async_get(TEST_SWITCH_ENTITY_ID) + assert entity_entry is not None + assert entity_entry.entity_category == EntityCategory.CONFIG + assert entity_entry.translation_key == "beta_firmware" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("service", "target_state", "expected_prerelease"), + [ + (SERVICE_TURN_ON, STATE_ON, True), + (SERVICE_TURN_OFF, STATE_OFF, False), + ], +) +async def test_switch_turn_on_off( + hass: HomeAssistant, + switch_config_entry: ConfigEntry, + mock_firmware_client, + service: str, + target_state: str, + expected_prerelease: bool, +) -> None: + """Test turning switch on/off updates state and coordinator.""" + + # Start with opposite state + mock_restore_cache( + hass, + [ + State( + TEST_SWITCH_ENTITY_ID, + STATE_ON if service == SERVICE_TURN_OFF else STATE_OFF, + ) + ], + ) + + # Track async_refresh calls + with patch( + "homeassistant.components.homeassistant_hardware.coordinator.FirmwareUpdateCoordinator.async_refresh" + ) as mock_refresh: + assert await hass.config_entries.async_setup(switch_config_entry.entry_id) + await hass.async_block_till_done() + + # Reset mocks after setup + mock_firmware_client.update_prerelease.reset_mock() + mock_refresh.reset_mock() + + # Call the service + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: TEST_SWITCH_ENTITY_ID}, + blocking=True, + ) + + # Verify state changed + state = hass.states.get(TEST_SWITCH_ENTITY_ID) + assert state is not None + assert state.state == target_state + + # Verify coordinator methods were called + assert mock_firmware_client.update_prerelease.mock_calls == [ + call(expected_prerelease) + ] + assert len(mock_refresh.mock_calls) == 1 diff --git a/tests/components/homeassistant_hardware/test_update.py b/tests/components/homeassistant_hardware/test_update.py index 53b6d2b85bb..3a463362533 100644 --- a/tests/components/homeassistant_hardware/test_update.py +++ b/tests/components/homeassistant_hardware/test_update.py @@ -9,9 +9,7 @@ import logging from unittest.mock import Mock, patch import aiohttp -from ha_silabs_firmware_client import FirmwareManifest, FirmwareMetadata import pytest -from yarl import URL from homeassistant.components.homeassistant_hardware.coordinator import ( FirmwareUpdateCoordinator, @@ -47,7 +45,8 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util + +from .common import TEST_DOMAIN, TEST_FIRMWARE_RELEASES_URL, TEST_MANIFEST from tests.common import ( MockConfigEntry, @@ -60,32 +59,8 @@ from tests.common import ( mock_restore_cache_with_extra_data, ) -TEST_DOMAIN = "test" TEST_DEVICE = "/dev/serial/by-id/some-unique-serial-device-12345" -TEST_FIRMWARE_RELEASES_URL = "https://example.org/firmware" TEST_UPDATE_ENTITY_ID = "update.mock_name_firmware" -TEST_MANIFEST = FirmwareManifest( - url=URL("https://example.org/firmware"), - html_url=URL("https://example.org/release_notes"), - created_at=dt_util.utcnow(), - firmwares=( - FirmwareMetadata( - filename="skyconnect_zigbee_ncp_test.gbl", - checksum="aaa", - size=123, - release_notes="Some release notes go here", - metadata={ - "baudrate": 115200, - "ezsp_version": "7.4.4.0", - "fw_type": "zigbee_ncp", - "fw_variant": None, - "metadata_version": 2, - "sdk_version": "4.4.4", - }, - url=URL("https://example.org/firmwares/skyconnect_zigbee_ncp_test.gbl"), - ), - ), -) TEST_FIRMWARE_ENTITY_DESCRIPTIONS: dict[ diff --git a/tests/components/homeassistant_sky_connect/test_switch.py b/tests/components/homeassistant_sky_connect/test_switch.py new file mode 100644 index 00000000000..eab7e077352 --- /dev/null +++ b/tests/components/homeassistant_sky_connect/test_switch.py @@ -0,0 +1,134 @@ +"""Test SkyConnect beta firmware switch entity.""" + +from unittest.mock import Mock, call, patch + +from ha_silabs_firmware_client import FirmwareManifest, FirmwareMetadata +import pytest +from yarl import URL + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import HomeAssistant, State +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from .common import USB_DATA_ZBT1 + +from tests.common import MockConfigEntry, mock_restore_cache + +SWITCH_ENTITY_ID = "switch.home_assistant_connect_zbt_1_9e2adbd7_beta_firmware_updates" + +TEST_MANIFEST = FirmwareManifest( + url=URL("https://example.org/firmware"), + html_url=URL("https://example.org/release_notes"), + created_at=dt_util.utcnow(), + firmwares=( + FirmwareMetadata( + filename="skyconnect_zigbee_ncp_test.gbl", + checksum="aaa", + size=123, + release_notes="Some release notes go here", + metadata={ + "baudrate": 115200, + "ezsp_version": "7.4.4.0", + "fw_type": "zigbee_ncp", + "fw_variant": None, + "metadata_version": 2, + "sdk_version": "4.4.4", + }, + url=URL("https://example.org/firmwares/skyconnect_zigbee_ncp_test.gbl"), + ), + ), +) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("service", "target_state", "expected_prerelease"), + [ + (SERVICE_TURN_ON, STATE_ON, True), + (SERVICE_TURN_OFF, STATE_OFF, False), + ], +) +async def test_switch_turn_on_off( + hass: HomeAssistant, + service: str, + target_state: str, + expected_prerelease: bool, +) -> None: + """Test turning switch on/off updates state and coordinator.""" + await async_setup_component(hass, "homeassistant", {}) + + # Start with opposite state + mock_restore_cache( + hass, + [ + State( + SWITCH_ENTITY_ID, + STATE_ON if service == SERVICE_TURN_OFF else STATE_OFF, + ) + ], + ) + + # Set up the ZBT-1 integration + zbt1_config_entry = MockConfigEntry( + title="Home Assistant Connect ZBT-1", + domain="homeassistant_sky_connect", + data={ + "firmware": "ezsp", + "firmware_version": "7.3.1.0 build 0", + "device": USB_DATA_ZBT1.device, + "manufacturer": USB_DATA_ZBT1.manufacturer, + "pid": USB_DATA_ZBT1.pid, + "product": USB_DATA_ZBT1.description, + "serial_number": USB_DATA_ZBT1.serial_number, + "vid": USB_DATA_ZBT1.vid, + }, + version=1, + minor_version=3, + ) + zbt1_config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.homeassistant_hardware.coordinator.FirmwareUpdateClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.homeassistant_hardware.coordinator.FirmwareUpdateCoordinator.async_refresh" + ) as mock_refresh, + ): + mock_client.return_value.async_update_data.return_value = TEST_MANIFEST + mock_client.return_value.update_prerelease = Mock() + + assert await hass.config_entries.async_setup(zbt1_config_entry.entry_id) + await hass.async_block_till_done() + + # Reset mocks after setup + mock_client.return_value.update_prerelease.reset_mock() + mock_refresh.reset_mock() + + # Call the service + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: SWITCH_ENTITY_ID}, + blocking=True, + ) + + # Verify state changed + state = hass.states.get(SWITCH_ENTITY_ID) + assert state is not None + assert state.state == target_state + + # Verify coordinator methods were called + assert mock_client.return_value.update_prerelease.mock_calls == [ + call(expected_prerelease) + ] + assert len(mock_refresh.mock_calls) == 1 diff --git a/tests/components/homeassistant_yellow/test_switch.py b/tests/components/homeassistant_yellow/test_switch.py new file mode 100644 index 00000000000..25f78d442fa --- /dev/null +++ b/tests/components/homeassistant_yellow/test_switch.py @@ -0,0 +1,135 @@ +"""Test Yellow beta firmware switch entity.""" + +from unittest.mock import Mock, call, patch + +from ha_silabs_firmware_client import FirmwareManifest, FirmwareMetadata +import pytest +from yarl import URL + +from homeassistant.components.homeassistant_yellow.const import RADIO_DEVICE +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import HomeAssistant, State +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry, mock_restore_cache + +SWITCH_ENTITY_ID = "switch.home_assistant_yellow_radio_beta_firmware_updates" + +TEST_MANIFEST = FirmwareManifest( + url=URL("https://example.org/firmware"), + html_url=URL("https://example.org/release_notes"), + created_at=dt_util.utcnow(), + firmwares=( + FirmwareMetadata( + filename="skyconnect_zigbee_ncp_test.gbl", + checksum="aaa", + size=123, + release_notes="Some release notes go here", + metadata={ + "baudrate": 115200, + "ezsp_version": "7.4.4.0", + "fw_type": "zigbee_ncp", + "fw_variant": None, + "metadata_version": 2, + "sdk_version": "4.4.4", + }, + url=URL("https://example.org/firmwares/skyconnect_zigbee_ncp_test.gbl"), + ), + ), +) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("service", "target_state", "expected_prerelease"), + [ + (SERVICE_TURN_ON, STATE_ON, True), + (SERVICE_TURN_OFF, STATE_OFF, False), + ], +) +async def test_switch_turn_on_off( + hass: HomeAssistant, + service: str, + target_state: str, + expected_prerelease: bool, +) -> None: + """Test turning switch on/off updates state and coordinator.""" + await async_setup_component(hass, "homeassistant", {}) + + # Start with opposite state + mock_restore_cache( + hass, + [ + State( + SWITCH_ENTITY_ID, + STATE_ON if service == SERVICE_TURN_OFF else STATE_OFF, + ) + ], + ) + + # Set up the Yellow integration + yellow_config_entry = MockConfigEntry( + title="Home Assistant Yellow", + domain="homeassistant_yellow", + data={ + "firmware": "ezsp", + "firmware_version": "7.3.1.0 build 0", + "device": RADIO_DEVICE, + }, + version=1, + minor_version=3, + ) + yellow_config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.homeassistant_yellow.is_hassio", return_value=True + ), + patch( + "homeassistant.components.homeassistant_yellow.get_os_info", + return_value={"board": "yellow"}, + ), + patch( + "homeassistant.components.homeassistant_hardware.coordinator.FirmwareUpdateClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.homeassistant_hardware.coordinator.FirmwareUpdateCoordinator.async_refresh" + ) as mock_refresh, + ): + mock_client.return_value.async_update_data.return_value = TEST_MANIFEST + mock_client.return_value.update_prerelease = Mock() + + assert await hass.config_entries.async_setup(yellow_config_entry.entry_id) + await hass.async_block_till_done() + + # Reset mocks after setup + mock_client.return_value.update_prerelease.reset_mock() + mock_refresh.reset_mock() + + # Call the service + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: SWITCH_ENTITY_ID}, + blocking=True, + ) + + # Verify state changed + state = hass.states.get(SWITCH_ENTITY_ID) + assert state is not None + assert state.state == target_state + + # Verify coordinator methods were called + assert mock_client.return_value.update_prerelease.mock_calls == [ + call(expected_prerelease) + ] + assert len(mock_refresh.mock_calls) == 1