mirror of
https://github.com/home-assistant/core.git
synced 2025-07-21 12:17:07 +00:00
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:
parent
5660d1e48e
commit
e9364f4c3a
@ -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
|
||||
|
||||
|
||||
|
@ -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(
|
||||
[
|
||||
|
@ -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
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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"
|
||||
),
|
||||
)
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
||||
|
@ -117,6 +117,14 @@
|
||||
"night_mode": {
|
||||
"name": "LED night mode"
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
"core_update": {
|
||||
"name": "Core firmware"
|
||||
},
|
||||
"zigbee_update": {
|
||||
"name": "Zigbee firmware"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
|
@ -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
|
||||
|
||||
|
189
homeassistant/components/smlight/update.py
Normal file
189
homeassistant/components/smlight/update.py
Normal 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()
|
@ -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
|
||||
|
||||
|
35
tests/components/smlight/fixtures/esp_firmware.json
Normal file
35
tests/components/smlight/fixtures/esp_firmware.json
Normal 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
|
||||
}
|
||||
]
|
@ -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
|
||||
}
|
||||
|
46
tests/components/smlight/fixtures/zb_firmware.json
Normal file
46
tests/components/smlight/fixtures/zb_firmware.json
Normal 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
|
||||
}
|
||||
]
|
@ -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,
|
||||
})
|
||||
# ---
|
||||
|
@ -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]
|
||||
|
115
tests/components/smlight/snapshots/test_update.ambr
Normal file
115
tests/components/smlight/snapshots/test_update.ambr
Normal 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',
|
||||
})
|
||||
# ---
|
234
tests/components/smlight/test_update.py
Normal file
234
tests/components/smlight/test_update.py
Normal 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
|
Loading…
x
Reference in New Issue
Block a user