diff --git a/homeassistant/components/smlight/__init__.py b/homeassistant/components/smlight/__init__.py index 58d5b7d343f..52db6c8770b 100644 --- a/homeassistant/components/smlight/__init__.py +++ b/homeassistant/components/smlight/__init__.py @@ -2,29 +2,55 @@ from __future__ import annotations +from dataclasses import dataclass + +from pysmlight import Api2 + from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .coordinator import SmDataUpdateCoordinator +from .coordinator import SmDataUpdateCoordinator, SmFirmwareUpdateCoordinator PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR, Platform.SWITCH, + Platform.UPDATE, ] -type SmConfigEntry = ConfigEntry[SmDataUpdateCoordinator] + + +@dataclass(kw_only=True) +class SmlightData: + """Coordinator data class.""" + + data: SmDataUpdateCoordinator + firmware: SmFirmwareUpdateCoordinator + + +type SmConfigEntry = ConfigEntry[SmlightData] async def async_setup_entry(hass: HomeAssistant, entry: SmConfigEntry) -> bool: """Set up SMLIGHT Zigbee from a config entry.""" - coordinator = SmDataUpdateCoordinator(hass, entry.data[CONF_HOST]) - await coordinator.async_config_entry_first_refresh() - entry.runtime_data = coordinator + client = Api2(host=entry.data[CONF_HOST], session=async_get_clientsession(hass)) + entry.async_create_background_task(hass, client.sse.client(), "smlight-sse-client") + + data_coordinator = SmDataUpdateCoordinator(hass, entry.data[CONF_HOST], client) + firmware_coordinator = SmFirmwareUpdateCoordinator( + hass, entry.data[CONF_HOST], client + ) + + await data_coordinator.async_config_entry_first_refresh() + await firmware_coordinator.async_config_entry_first_refresh() + + entry.runtime_data = SmlightData( + data=data_coordinator, firmware=firmware_coordinator + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - return True diff --git a/homeassistant/components/smlight/binary_sensor.py b/homeassistant/components/smlight/binary_sensor.py index b5c695617eb..d273460e206 100644 --- a/homeassistant/components/smlight/binary_sensor.py +++ b/homeassistant/components/smlight/binary_sensor.py @@ -54,7 +54,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up SMLIGHT sensor based on a config entry.""" - coordinator = entry.runtime_data + coordinator = entry.runtime_data.data async_add_entities( [ diff --git a/homeassistant/components/smlight/button.py b/homeassistant/components/smlight/button.py index b6a0c24c2ed..de19c57d1b1 100644 --- a/homeassistant/components/smlight/button.py +++ b/homeassistant/components/smlight/button.py @@ -60,7 +60,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up SMLIGHT buttons based on a config entry.""" - coordinator = entry.runtime_data + coordinator = entry.runtime_data.data async_add_entities(SmButton(coordinator, button) for button in BUTTONS) @@ -68,6 +68,7 @@ async def async_setup_entry( class SmButton(SmEntity, ButtonEntity): """Defines a SLZB-06 button.""" + coordinator: SmDataUpdateCoordinator entity_description: SmButtonDescription _attr_entity_category = EntityCategory.CONFIG diff --git a/homeassistant/components/smlight/const.py b/homeassistant/components/smlight/const.py index a49ac009a50..669094b2441 100644 --- a/homeassistant/components/smlight/const.py +++ b/homeassistant/components/smlight/const.py @@ -6,7 +6,10 @@ import logging DOMAIN = "smlight" ATTR_MANUFACTURER = "SMLIGHT" +DATA_COORDINATOR = "data" +FIRMWARE_COORDINATOR = "firmware" +SCAN_FIRMWARE_INTERVAL = timedelta(hours=6) LOGGER = logging.getLogger(__package__) SCAN_INTERVAL = timedelta(seconds=300) SCAN_INTERNET_INTERVAL = timedelta(minutes=15) diff --git a/homeassistant/components/smlight/coordinator.py b/homeassistant/components/smlight/coordinator.py index 396a89ef4b0..e5ef21bd531 100644 --- a/homeassistant/components/smlight/coordinator.py +++ b/homeassistant/components/smlight/coordinator.py @@ -1,22 +1,28 @@ """DataUpdateCoordinator for Smlight.""" +from __future__ import annotations + +from abc import abstractmethod from dataclasses import dataclass +from typing import TYPE_CHECKING from pysmlight import Api2, Info, Sensors from pysmlight.const import Settings, SettingsProp from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError +from pysmlight.web import Firmware -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.issue_registry import IssueSeverity from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, LOGGER, SCAN_INTERVAL +from .const import DOMAIN, LOGGER, SCAN_FIRMWARE_INTERVAL, SCAN_INTERVAL + +if TYPE_CHECKING: + from . import SmConfigEntry @dataclass @@ -27,12 +33,21 @@ class SmData: info: Info -class SmDataUpdateCoordinator(DataUpdateCoordinator[SmData]): - """Class to manage fetching SMLIGHT data.""" +@dataclass +class SmFwData: + """SMLIGHT firmware data stored in the FirmwareUpdateCoordinator.""" - config_entry: ConfigEntry + info: Info + esp_firmware: list[Firmware] | None + zb_firmware: list[Firmware] | None - def __init__(self, hass: HomeAssistant, host: str) -> None: + +class SmBaseDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): + """Base Coordinator for SMLIGHT.""" + + config_entry: SmConfigEntry + + def __init__(self, hass: HomeAssistant, host: str, client: Api2) -> None: """Initialize the coordinator.""" super().__init__( hass, @@ -41,14 +56,10 @@ class SmDataUpdateCoordinator(DataUpdateCoordinator[SmData]): update_interval=SCAN_INTERVAL, ) + self.client = client self.unique_id: str | None = None - self.client = Api2(host=host, session=async_get_clientsession(hass)) self.legacy_api: int = 0 - self.config_entry.async_create_background_task( - hass, self.client.sse.client(), "smlight-sse-client" - ) - async def _async_setup(self) -> None: """Authenticate if needed during initial setup.""" if await self.client.check_auth_needed(): @@ -83,26 +94,62 @@ class SmDataUpdateCoordinator(DataUpdateCoordinator[SmData]): translation_key="unsupported_firmware", ) - def update_setting(self, setting: Settings, value: bool | int) -> None: - """Update the sensor value from event.""" - prop = SettingsProp[setting.name].value - setattr(self.data.sensors, prop, value) - - self.async_set_updated_data(self.data) - - async def _async_update_data(self) -> SmData: - """Fetch data from the SMLIGHT device.""" + async def _async_update_data(self) -> _DataT: try: - sensors = Sensors() - if not self.legacy_api: - sensors = await self.client.get_sensors() - - return SmData( - sensors=sensors, - info=await self.client.get_info(), - ) + return await self._internal_update_data() except SmlightAuthError as err: raise ConfigEntryAuthFailed from err except SmlightConnectionError as err: raise UpdateFailed(err) from err + + @abstractmethod + async def _internal_update_data(self) -> _DataT: + """Update coordinator data.""" + + +class SmDataUpdateCoordinator(SmBaseDataUpdateCoordinator[SmData]): + """Class to manage fetching SMLIGHT sensor data.""" + + def update_setting(self, setting: Settings, value: bool | int) -> None: + """Update the sensor value from event.""" + + prop = SettingsProp[setting.name].value + setattr(self.data.sensors, prop, value) + + self.async_set_updated_data(self.data) + + async def _internal_update_data(self) -> SmData: + """Fetch sensor data from the SMLIGHT device.""" + sensors = Sensors() + if not self.legacy_api: + sensors = await self.client.get_sensors() + + return SmData( + sensors=sensors, + info=await self.client.get_info(), + ) + + +class SmFirmwareUpdateCoordinator(SmBaseDataUpdateCoordinator[SmFwData]): + """Class to manage fetching SMLIGHT firmware update data from cloud.""" + + def __init__(self, hass: HomeAssistant, host: str, client: Api2) -> None: + """Initialize the coordinator.""" + super().__init__(hass, host, client) + + self.update_interval = SCAN_FIRMWARE_INTERVAL + # only one update can run at a time (core or zibgee) + self.in_progress = False + + async def _internal_update_data(self) -> SmFwData: + """Fetch data from the SMLIGHT device.""" + info = await self.client.get_info() + + return SmFwData( + info=info, + esp_firmware=await self.client.get_firmware_version(info.fw_channel), + zb_firmware=await self.client.get_firmware_version( + info.fw_channel, device=info.model, mode="zigbee" + ), + ) diff --git a/homeassistant/components/smlight/entity.py b/homeassistant/components/smlight/entity.py index 50767d3bf74..7e6213cbdf1 100644 --- a/homeassistant/components/smlight/entity.py +++ b/homeassistant/components/smlight/entity.py @@ -10,15 +10,15 @@ from homeassistant.helpers.device_registry import ( from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTR_MANUFACTURER -from .coordinator import SmDataUpdateCoordinator +from .coordinator import SmBaseDataUpdateCoordinator -class SmEntity(CoordinatorEntity[SmDataUpdateCoordinator]): +class SmEntity(CoordinatorEntity[SmBaseDataUpdateCoordinator]): """Base class for all SMLight entities.""" _attr_has_entity_name = True - def __init__(self, coordinator: SmDataUpdateCoordinator) -> None: + def __init__(self, coordinator: SmBaseDataUpdateCoordinator) -> None: """Initialize entity with device.""" super().__init__(coordinator) mac = format_mac(coordinator.data.info.MAC) diff --git a/homeassistant/components/smlight/sensor.py b/homeassistant/components/smlight/sensor.py index 8da6e354fd7..1116b99f8c1 100644 --- a/homeassistant/components/smlight/sensor.py +++ b/homeassistant/components/smlight/sensor.py @@ -127,7 +127,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up SMLIGHT sensor based on a config entry.""" - coordinator = entry.runtime_data + coordinator = entry.runtime_data.data async_add_entities( chain( @@ -141,6 +141,7 @@ async def async_setup_entry( class SmSensorEntity(SmEntity, SensorEntity): """Representation of a slzb sensor.""" + coordinator: SmDataUpdateCoordinator entity_description: SmSensorEntityDescription _attr_entity_category = EntityCategory.DIAGNOSTIC @@ -164,6 +165,7 @@ class SmSensorEntity(SmEntity, SensorEntity): class SmInfoSensorEntity(SmEntity, SensorEntity): """Representation of a slzb info sensor.""" + coordinator: SmDataUpdateCoordinator entity_description: SmInfoEntityDescription _attr_entity_category = EntityCategory.DIAGNOSTIC diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json index 425815f68f0..812218287a9 100644 --- a/homeassistant/components/smlight/strings.json +++ b/homeassistant/components/smlight/strings.json @@ -117,6 +117,14 @@ "night_mode": { "name": "LED night mode" } + }, + "update": { + "core_update": { + "name": "Core firmware" + }, + "zigbee_update": { + "name": "Zigbee firmware" + } } }, "issues": { diff --git a/homeassistant/components/smlight/switch.py b/homeassistant/components/smlight/switch.py index 38d94580d4d..930875335d1 100644 --- a/homeassistant/components/smlight/switch.py +++ b/homeassistant/components/smlight/switch.py @@ -63,7 +63,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Initialize switches for SLZB-06 device.""" - coordinator = entry.runtime_data + coordinator = entry.runtime_data.data async_add_entities(SmSwitch(coordinator, switch) for switch in SWITCHES) @@ -71,6 +71,7 @@ async def async_setup_entry( class SmSwitch(SmEntity, SwitchEntity): """Representation of a SLZB-06 switch.""" + coordinator: SmDataUpdateCoordinator entity_description: SmSwitchEntityDescription _attr_device_class = SwitchDeviceClass.SWITCH diff --git a/homeassistant/components/smlight/update.py b/homeassistant/components/smlight/update.py new file mode 100644 index 00000000000..e00499760b1 --- /dev/null +++ b/homeassistant/components/smlight/update.py @@ -0,0 +1,189 @@ +"""Support updates for SLZB-06 ESP32 and Zigbee firmwares.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Final + +from pysmlight.const import Events as SmEvents +from pysmlight.models import Firmware, Info +from pysmlight.sse import MessageEvent + +from homeassistant.components.update import ( + UpdateDeviceClass, + UpdateEntity, + UpdateEntityDescription, + UpdateEntityFeature, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import SmConfigEntry +from .coordinator import SmFirmwareUpdateCoordinator, SmFwData +from .entity import SmEntity + + +@dataclass(frozen=True, kw_only=True) +class SmUpdateEntityDescription(UpdateEntityDescription): + """Describes SMLIGHT SLZB-06 update entity.""" + + installed_version: Callable[[Info], str | None] + fw_list: Callable[[SmFwData], list[Firmware] | None] + + +UPDATE_ENTITIES: Final = [ + SmUpdateEntityDescription( + key="core_update", + translation_key="core_update", + installed_version=lambda x: x.sw_version, + fw_list=lambda x: x.esp_firmware, + ), + SmUpdateEntityDescription( + key="zigbee_update", + translation_key="zigbee_update", + installed_version=lambda x: x.zb_version, + fw_list=lambda x: x.zb_firmware, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, entry: SmConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the SMLIGHT update entities.""" + coordinator = entry.runtime_data.firmware + + async_add_entities( + SmUpdateEntity(coordinator, description) for description in UPDATE_ENTITIES + ) + + +class SmUpdateEntity(SmEntity, UpdateEntity): + """Representation for SLZB-06 update entities.""" + + coordinator: SmFirmwareUpdateCoordinator + entity_description: SmUpdateEntityDescription + _attr_entity_category = EntityCategory.CONFIG + _attr_device_class = UpdateDeviceClass.FIRMWARE + _attr_supported_features = ( + UpdateEntityFeature.INSTALL + | UpdateEntityFeature.PROGRESS + | UpdateEntityFeature.RELEASE_NOTES + ) + + def __init__( + self, + coordinator: SmFirmwareUpdateCoordinator, + description: SmUpdateEntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + + self.entity_description = description + self._attr_unique_id = f"{coordinator.unique_id}-{description.key}" + + self._finished_event = asyncio.Event() + self._firmware: Firmware | None = None + self._unload: list[Callable] = [] + + @property + def installed_version(self) -> str | None: + """Version installed..""" + data = self.coordinator.data + + version = self.entity_description.installed_version(data.info) + return version if version != "-1" else None + + @property + def latest_version(self) -> str | None: + """Latest version available for install.""" + data = self.coordinator.data + + fw = self.entity_description.fw_list(data) + + if fw and self.entity_description.key == "zigbee_update": + fw = [f for f in fw if f.type == data.info.zb_type] + + if fw: + self._firmware = fw[0] + return self._firmware.ver + + return None + + def register_callbacks(self) -> None: + """Register callbacks for SSE update events.""" + self._unload.append( + self.coordinator.client.sse.register_callback( + SmEvents.ZB_FW_prgs, self._update_progress + ) + ) + self._unload.append( + self.coordinator.client.sse.register_callback( + SmEvents.FW_UPD_done, self._update_finished + ) + ) + self._unload.append( + self.coordinator.client.sse.register_callback( + SmEvents.ZB_FW_err, self._update_failed + ) + ) + + def release_notes(self) -> str | None: + """Return release notes for firmware.""" + + if self._firmware and self._firmware.notes: + return self._firmware.notes + + return None + + @callback + def _update_progress(self, progress: MessageEvent) -> None: + """Update install progress on event.""" + + progress = int(progress.data) + if progress > 1: + self._attr_in_progress = progress + self.async_write_ha_state() + + def _update_done(self) -> None: + """Handle cleanup for update done.""" + self._finished_event.set() + self.coordinator.in_progress = False + + for remove_cb in self._unload: + remove_cb() + self._unload.clear() + + @callback + def _update_finished(self, event: MessageEvent) -> None: + """Handle event for update finished.""" + + self._update_done() + + @callback + def _update_failed(self, event: MessageEvent) -> None: + self._update_done() + + raise HomeAssistantError(f"Update failed for {self.name}") + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install firmware update.""" + + if not self.coordinator.in_progress and self._firmware: + self.coordinator.in_progress = True + self._attr_in_progress = True + self.register_callbacks() + + await self.coordinator.client.fw_update(self._firmware) + + # block until update finished event received + await self._finished_event.wait() + + await self.coordinator.async_refresh() + self._finished_event.clear() diff --git a/tests/components/smlight/conftest.py b/tests/components/smlight/conftest.py index cb7ac938774..665a55ba880 100644 --- a/tests/components/smlight/conftest.py +++ b/tests/components/smlight/conftest.py @@ -4,7 +4,7 @@ from collections.abc import AsyncGenerator, Generator from unittest.mock import AsyncMock, MagicMock, patch from pysmlight.sse import sseClient -from pysmlight.web import CmdWrapper, Info, Sensors +from pysmlight.web import CmdWrapper, Firmware, Info, Sensors import pytest from homeassistant.components.smlight import PLATFORMS @@ -12,7 +12,11 @@ from homeassistant.components.smlight.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_object_fixture, +) MOCK_HOST = "slzb-06.local" MOCK_USERNAME = "test-user" @@ -71,9 +75,7 @@ def mock_setup_entry() -> Generator[AsyncMock]: def mock_smlight_client(request: pytest.FixtureRequest) -> Generator[MagicMock]: """Mock the SMLIGHT API client.""" with ( - patch( - "homeassistant.components.smlight.coordinator.Api2", autospec=True - ) as smlight_mock, + patch("homeassistant.components.smlight.Api2", autospec=True) as smlight_mock, patch("homeassistant.components.smlight.config_flow.Api2", new=smlight_mock), ): api = smlight_mock.return_value @@ -85,6 +87,18 @@ def mock_smlight_client(request: pytest.FixtureRequest) -> Generator[MagicMock]: load_json_object_fixture("sensors.json", DOMAIN) ) + def get_firmware_side_effect(*args, **kwargs) -> list[Firmware]: + """Return the firmware version.""" + fw_list = [] + if kwargs.get("mode") == "zigbee": + fw_list = load_json_array_fixture("zb_firmware.json", DOMAIN) + else: + fw_list = load_json_array_fixture("esp_firmware.json", DOMAIN) + + return [Firmware.from_dict(fw) for fw in fw_list] + + api.get_firmware_version.side_effect = get_firmware_side_effect + api.check_auth_needed.return_value = False api.authenticate.return_value = True diff --git a/tests/components/smlight/fixtures/esp_firmware.json b/tests/components/smlight/fixtures/esp_firmware.json new file mode 100644 index 00000000000..6ea0e1a8b44 --- /dev/null +++ b/tests/components/smlight/fixtures/esp_firmware.json @@ -0,0 +1,35 @@ +[ + { + "mode": "ESP", + "type": null, + "notes": "CHANGELOG (Current 2.5.2 vs. Previous 2.3.6):\\r\\nFixed incorrect device type detection for some devices\\r\\nFixed web interface not working on some devices\\r\\nFixed disabled SSID/pass fields\\r\\n", + "rev": "20240830", + "link": "https://smlight.tech/flasher/firmware/bin/slzb06x/core/slzb-06-v2.5.2-ota.bin", + "ver": "v2.5.2", + "dev": false, + "prod": true, + "baud": null + }, + { + "mode": "ESP", + "type": null, + "notes": "Read/write IEEE for CC chips\\r\\nDefault black theme\\r\\nAdd device mac to MDNS ZeroConf\\r\\nBreaking change! socket_uptime in /ha_sensors and /metrics now in seconds\\r\\nNew 5 languages\\r\\nAdd manual ZB OTA for 06M\\r\\nAdd warning modal for ZB manual OTA\\r\\nWireGuard can now use hostname instead of IP\\r\\nWiFi AP fixes and improvements\\r\\nImproved management of socket clients\\r\\nFix \"Disable web server when socket is connected\"\\r\\nFix events tag for log\\r\\nFix ZB maual OTA header text\\r\\nFix feedback page stack overflow\\r\\nFix sta drop in AP mode after scan start", + "rev": "20240815", + "link": "https://smlight.tech/flasher/firmware/bin/slzb06x/core/slzb-06-v2.3.6-ota.bin", + "ver": "v2.3.6", + "dev": false, + "prod": true, + "baud": null + }, + { + "mode": "ESP", + "type": null, + "notes": "release of previous version", + "rev": "10112023", + "link": "https://smlight.tech/flasher/firmware/bin/slzb06x/core/slzb-06-0.9.9-ota.bin", + "ver": "0.9.9", + "dev": false, + "prod": true, + "baud": null + } +] diff --git a/tests/components/smlight/fixtures/info.json b/tests/components/smlight/fixtures/info.json index 070232512f3..8f1e718ca74 100644 --- a/tests/components/smlight/fixtures/info.json +++ b/tests/components/smlight/fixtures/info.json @@ -13,6 +13,6 @@ "zb_flash_size": 704, "zb_hw": "CC2652P7", "zb_ram_size": 152, - "zb_version": -1, - "zb_type": -1 + "zb_version": "20240314", + "zb_type": 0 } diff --git a/tests/components/smlight/fixtures/zb_firmware.json b/tests/components/smlight/fixtures/zb_firmware.json new file mode 100644 index 00000000000..ca9d10f87ac --- /dev/null +++ b/tests/components/smlight/fixtures/zb_firmware.json @@ -0,0 +1,46 @@ +[ + { + "mode": "ZB", + "type": 0, + "notes": "SMLIGHT latest Coordinator release for CC2674P10 chips [16-Jul-2024]:
- +20dB TRANSMIT POWER SUPPORT;
- SDK 7.41 based (latest);
", + "rev": "20240716", + "link": "https://smlight.tech/flasher/firmware/bin/slzb06x/zigbee/slzb06p10/znp-SLZB-06P10-20240716.bin", + "ver": "20240716", + "dev": false, + "prod": true, + "baud": 115200 + }, + { + "mode": "ZB", + "type": 1, + "notes": "SMLIGHT latest ROUTER release for CC2674P10 chips [16-Jul-2024]:
- SDK 7.41 based (latest);
Terms of use", + "rev": "20240716", + "link": "https://smlight.tech/flasher/firmware/bin/slzb06x/zigbee/slzb06p10/zr-ZR_SLZB-06P10-20240716.bin", + "ver": "20240716", + "dev": false, + "prod": true, + "baud": 0 + }, + { + "mode": "ZB", + "type": 0, + "notes": "SMLIGHT Coordinator release for CC2674P10 chips [15-Mar-2024]:
- Engineering (dev) version, not recommended (INT);
- SDK 7.40 based (latest);
- Baudrate: 115200;
Terms of use", + "rev": "20240315", + "link": "https://smlight.tech/flasher/firmware/bin/slzb06x/zigbee/slzb06p10/znp_LP_EM_CC2674P10_SM_tirtos7_ticlangNR.bin", + "ver": "20240315", + "dev": false, + "prod": false, + "baud": 115200 + }, + { + "mode": "ZB", + "type": 0, + "notes": "SMLIGHT Coordinator release for CC2674P10 chips [14-Mar-2024]:
- Factory flashed firmware (EXT);
- SDK 7.40 based (latest);
- Baudrate: 115200;
Terms of use", + "rev": "20240314", + "link": "https://smlight.tech/flasher/firmware/bin/slzb06x/zigbee/slzb06p10/znp_LP_EM_CC2674P10_SM_tirtos7_ticlangNP.bin", + "ver": "20240314", + "dev": false, + "prod": false, + "baud": 115200 + } +] diff --git a/tests/components/smlight/snapshots/test_init.ambr b/tests/components/smlight/snapshots/test_init.ambr index bb6a6c50f9b..598166e537b 100644 --- a/tests/components/smlight/snapshots/test_init.ambr +++ b/tests/components/smlight/snapshots/test_init.ambr @@ -27,7 +27,7 @@ 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'core: v2.3.6 / zigbee: -1', + 'sw_version': 'core: v2.3.6 / zigbee: 20240314', 'via_device_id': None, }) # --- diff --git a/tests/components/smlight/snapshots/test_sensor.ambr b/tests/components/smlight/snapshots/test_sensor.ambr index 7abc5ef4f64..262ecfe1544 100644 --- a/tests/components/smlight/snapshots/test_sensor.ambr +++ b/tests/components/smlight/snapshots/test_sensor.ambr @@ -419,7 +419,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'coordinator', }) # --- # name: test_sensors[sensor.mock_title_zigbee_uptime-entry] diff --git a/tests/components/smlight/snapshots/test_update.ambr b/tests/components/smlight/snapshots/test_update.ambr new file mode 100644 index 00000000000..755c9bc7312 --- /dev/null +++ b/tests/components/smlight/snapshots/test_update.ambr @@ -0,0 +1,115 @@ +# serializer version: 1 +# name: test_update_setup[update.mock_title_core_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.mock_title_core_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Core firmware', + 'platform': 'smlight', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'core_update', + 'unique_id': 'aa:bb:cc:dd:ee:ff-core_update', + 'unit_of_measurement': None, + }) +# --- +# name: test_update_setup[update.mock_title_core_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'entity_picture': 'https://brands.home-assistant.io/_/smlight/icon.png', + 'friendly_name': 'Mock Title Core firmware', + 'in_progress': False, + 'installed_version': 'v2.3.6', + 'latest_version': 'v2.5.2', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + }), + 'context': , + 'entity_id': 'update.mock_title_core_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_update_setup[update.mock_title_zigbee_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.mock_title_zigbee_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Zigbee firmware', + 'platform': 'smlight', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'zigbee_update', + 'unique_id': 'aa:bb:cc:dd:ee:ff-zigbee_update', + 'unit_of_measurement': None, + }) +# --- +# name: test_update_setup[update.mock_title_zigbee_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'entity_picture': 'https://brands.home-assistant.io/_/smlight/icon.png', + 'friendly_name': 'Mock Title Zigbee firmware', + 'in_progress': False, + 'installed_version': '20240314', + 'latest_version': '20240716', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + }), + 'context': , + 'entity_id': 'update.mock_title_zigbee_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/smlight/test_update.py b/tests/components/smlight/test_update.py new file mode 100644 index 00000000000..b8b8de8a09b --- /dev/null +++ b/tests/components/smlight/test_update.py @@ -0,0 +1,234 @@ +"""Tests for the SMLIGHT update platform.""" + +from collections.abc import Callable +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +from pysmlight import Firmware, Info +from pysmlight.const import Events as SmEvents +from pysmlight.sse import MessageEvent +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.smlight.const import SCAN_FIRMWARE_INTERVAL +from homeassistant.components.update import ( + ATTR_IN_PROGRESS, + ATTR_INSTALLED_VERSION, + ATTR_LATEST_VERSION, + DOMAIN as PLATFORM, + SERVICE_INSTALL, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from .conftest import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +from tests.typing import WebSocketGenerator + +pytestmark = [ + pytest.mark.usefixtures( + "mock_smlight_client", + ) +] + +MOCK_FIRMWARE_DONE = MessageEvent( + type="FW_UPD_done", + message="FW_UPD_done", + data="", + origin="http://slzb-06p10.local", + last_event_id="", +) + +MOCK_FIRMWARE_PROGRESS = MessageEvent( + type="ZB_FW_prgs", + message="ZB_FW_prgs", + data="50", + origin="http://slzb-06p10.local", + last_event_id="", +) + +MOCK_FIRMWARE_FAIL = MessageEvent( + type="ZB_FW_err", + message="ZB_FW_err", + data="", + origin="http://slzb-06p10.local", + last_event_id="", +) + +MOCK_FIRMWARE_NOTES = [ + Firmware( + ver="v2.3.6", + mode="ESP", + notes=None, + ) +] + + +def get_callback_function(mock: MagicMock, trigger: SmEvents): + """Extract the callback function for a given trigger.""" + return next( + ( + call_args[0][1] + for call_args in mock.sse.register_callback.call_args_list + if trigger == call_args[0][0] + ), + None, + ) + + +@pytest.fixture +def platforms() -> list[Platform]: + """Platforms, which should be loaded during the test.""" + return [Platform.UPDATE] + + +async def test_update_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test setup of SMLIGHT switches.""" + entry = await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + await hass.config_entries.async_unload(entry.entry_id) + + +async def test_update_firmware( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test firmware updates.""" + await setup_integration(hass, mock_config_entry) + entity_id = "update.mock_title_core_firmware" + state = hass.states.get(entity_id) + assert state.state == STATE_ON + assert state.attributes[ATTR_INSTALLED_VERSION] == "v2.3.6" + assert state.attributes[ATTR_LATEST_VERSION] == "v2.5.2" + + await hass.services.async_call( + PLATFORM, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: entity_id}, + blocking=False, + ) + + assert len(mock_smlight_client.fw_update.mock_calls) == 1 + + event_function: Callable[[MessageEvent], None] = get_callback_function( + mock_smlight_client, SmEvents.ZB_FW_prgs + ) + + async def _call_event_function(event: MessageEvent): + event_function(event) + + await _call_event_function(MOCK_FIRMWARE_PROGRESS) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_IN_PROGRESS] == 50 + + event_function: Callable[[MessageEvent], None] = get_callback_function( + mock_smlight_client, SmEvents.FW_UPD_done + ) + + await _call_event_function(MOCK_FIRMWARE_DONE) + + mock_smlight_client.get_info.return_value = Info( + sw_version="v2.5.2", + ) + + freezer.tick(SCAN_FIRMWARE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + assert state.attributes[ATTR_INSTALLED_VERSION] == "v2.5.2" + assert state.attributes[ATTR_LATEST_VERSION] == "v2.5.2" + + +async def test_update_firmware_failed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test firmware updates.""" + await setup_integration(hass, mock_config_entry) + entity_id = "update.mock_title_core_firmware" + state = hass.states.get(entity_id) + assert state.state == STATE_ON + assert state.attributes[ATTR_INSTALLED_VERSION] == "v2.3.6" + assert state.attributes[ATTR_LATEST_VERSION] == "v2.5.2" + + await hass.services.async_call( + PLATFORM, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: entity_id}, + blocking=False, + ) + + assert len(mock_smlight_client.fw_update.mock_calls) == 1 + + event_function: Callable[[MessageEvent], None] = get_callback_function( + mock_smlight_client, SmEvents.ZB_FW_err + ) + + async def _call_event_function(event: MessageEvent): + event_function(event) + + with pytest.raises(HomeAssistantError): + await _call_event_function(MOCK_FIRMWARE_FAIL) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_IN_PROGRESS] is False + + +async def test_update_release_notes( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test firmware release notes.""" + await setup_integration(hass, mock_config_entry) + ws_client = await hass_ws_client(hass) + await hass.async_block_till_done() + entity_id = "update.mock_title_core_firmware" + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON + + await ws_client.send_json( + { + "id": 1, + "type": "update/release_notes", + "entity_id": entity_id, + } + ) + result = await ws_client.receive_json() + assert result["result"] is not None + + mock_smlight_client.get_firmware_version.side_effect = None + mock_smlight_client.get_firmware_version.return_value = MOCK_FIRMWARE_NOTES + + freezer.tick(SCAN_FIRMWARE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + await ws_client.send_json( + { + "id": 2, + "type": "update/release_notes", + "entity_id": entity_id, + } + ) + result = await ws_client.receive_json() + await hass.async_block_till_done() + assert result["result"] is None