Add update platform for Smlight integration (#125943)

* Create update coordinator for update entities

* fix type errors

* update info fixture with zigbee version

* Add fixtures for Firmware objects

* mock get_firmware_version function

* Add update platform for Smlight integration

* Add strings for update platform

* Add tests for update platform

* add snapshot for update tests

* Split out base coordinator

* Update homeassistant/components/smlight/strings.json

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* overwrite coordinator types

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
TimL 2024-09-16 22:14:15 +10:00 committed by GitHub
parent 5660d1e48e
commit e9364f4c3a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 772 additions and 51 deletions

View File

@ -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

View File

@ -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(
[

View File

@ -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

View File

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

View File

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

View File

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

View File

@ -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

View File

@ -117,6 +117,14 @@
"night_mode": {
"name": "LED night mode"
}
},
"update": {
"core_update": {
"name": "Core firmware"
},
"zigbee_update": {
"name": "Zigbee firmware"
}
}
},
"issues": {

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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
}
]

View File

@ -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
}

View File

@ -0,0 +1,46 @@
[
{
"mode": "ZB",
"type": 0,
"notes": "<b>SMLIGHT latest Coordinator release for CC2674P10 chips [16-Jul-2024]</b>:<br>- +20dB TRANSMIT POWER SUPPORT;<br>- SDK 7.41 based (latest);<br>",
"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": "<b>SMLIGHT latest ROUTER release for CC2674P10 chips [16-Jul-2024]</b>:<br>- SDK 7.41 based (latest);<br><a href='https://smlight.tech/legal/licenses/restrictive-smlight-slzb06-v20240328.txt' target='_blank' aria-current='true'><span>Terms of use</span></a><span>",
"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": "<b>SMLIGHT Coordinator release for CC2674P10 chips [15-Mar-2024]</b>:<br>- Engineering (dev) version, not recommended (INT);<br>- SDK 7.40 based (latest);<br>- Baudrate: 115200;<br><a href='https://smlight.tech/legal/licenses/restrictive-smlight-slzb06-v20240328.txt' target='_blank' aria-current='true'><span>Terms of use</span></a><span>",
"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": "<b>SMLIGHT Coordinator release for CC2674P10 chips [14-Mar-2024]</b>:<br>- Factory flashed firmware (EXT);<br>- SDK 7.40 based (latest);<br>- Baudrate: 115200;<br><a href='https://smlight.tech/legal/licenses/restrictive-smlight-slzb06-v20240328.txt' target='_blank' aria-current='true'><span>Terms of use</span></a><span>",
"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
}
]

View File

@ -27,7 +27,7 @@
'primary_config_entry': <ANY>,
'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,
})
# ---

View File

@ -419,7 +419,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
'state': 'coordinator',
})
# ---
# name: test_sensors[sensor.mock_title_zigbee_uptime-entry]

View File

@ -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': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'update',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'update.mock_title_core_firmware',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <UpdateDeviceClass.FIRMWARE: 'firmware'>,
'original_icon': None,
'original_name': 'Core firmware',
'platform': 'smlight',
'previous_unique_id': None,
'supported_features': <UpdateEntityFeature: 21>,
'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': <UpdateEntityFeature: 21>,
'title': None,
}),
'context': <ANY>,
'entity_id': 'update.mock_title_core_firmware',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_update_setup[update.mock_title_zigbee_firmware-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'update',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'update.mock_title_zigbee_firmware',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <UpdateDeviceClass.FIRMWARE: 'firmware'>,
'original_icon': None,
'original_name': 'Zigbee firmware',
'platform': 'smlight',
'previous_unique_id': None,
'supported_features': <UpdateEntityFeature: 21>,
'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': <UpdateEntityFeature: 21>,
'title': None,
}),
'context': <ANY>,
'entity_id': 'update.mock_title_zigbee_firmware',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---

View File

@ -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