New Integration: SMLIGHT SLZB-06 Adapters Integration (#118675)

* Initial SMLIGHT integration

Signed-off-by: Tim Lunn <tl@smlight.tech>

* Generated content

Signed-off-by: Tim Lunn <tl@smlight.tech>

* Cleanup LOGGING

* Use runtime data

* Call super first

* coordinator instance attributes

* Move coordinatorEntity and attr to base class

* cleanup sensors

* update strings to use sentence case

* Improve reauth flow on incorrect credentials

* Use fixture for config_flow tests and test to completion

* Split uptime hndling into a new uptime sensor entity

* Drop server side events and internet callback

will bring this back with binary sensor Platform

* consolidate coordinator setup

* entity always include connections

* get_hostname tweak

* Add tests for init, coordinator and sensor

* Use custom type SmConfigEntry

* update sensor snapshot

* Drop reauth flow for later PR

* Use _async_setup for initial setup

* drop internet to be set later

* sensor fixes

* config flow re

* typing fixes

* Bump pysmlight dependency to 0.0.12

* dont trigger invalid auth message when first loading auth step

* Merge uptime sensors back into main sensor class

* clarify uptime handling

* Apply suggestions from code review

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

* address review comments

* pass host as parameter to the dataCoordinator

* drop uptime sensors for a later PR

* update sensor test snapshot

* move coordinator unique_id to _async_setup

* fix CI

* Apply suggestions from code review

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

* drop invalid_auth test tag

* use snapshot_platform, update fixtures

* Finish all tests with abort or create entry

* drop coordinator tests and remove hostname support

* add test for update failure on connection error

* use freezer for update_failed test

* fix pysmlight imports

---------

Signed-off-by: Tim Lunn <tl@smlight.tech>
Co-authored-by: Tim Lunn <tim@feathertop.org>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
TimL 2024-08-20 18:44:06 +10:00 committed by GitHub
parent b4648136c5
commit 98a007cb2f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1871 additions and 0 deletions

View file

@ -1329,6 +1329,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/smarty/ @z0mbieprocess /homeassistant/components/smarty/ @z0mbieprocess
/homeassistant/components/smhi/ @gjohansson-ST /homeassistant/components/smhi/ @gjohansson-ST
/tests/components/smhi/ @gjohansson-ST /tests/components/smhi/ @gjohansson-ST
/homeassistant/components/smlight/ @tl-sl
/tests/components/smlight/ @tl-sl
/homeassistant/components/sms/ @ocalvo /homeassistant/components/sms/ @ocalvo
/tests/components/sms/ @ocalvo /tests/components/sms/ @ocalvo
/homeassistant/components/snapcast/ @luar123 /homeassistant/components/snapcast/ @luar123

View file

@ -0,0 +1,30 @@
"""SMLIGHT SLZB Zigbee device integration."""
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from .coordinator import SmDataUpdateCoordinator
PLATFORMS: list[Platform] = [
Platform.SENSOR,
]
type SmConfigEntry = ConfigEntry[SmDataUpdateCoordinator]
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
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: SmConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View file

@ -0,0 +1,151 @@
"""Config flow for SMLIGHT Zigbee integration."""
from __future__ import annotations
from typing import Any
from pysmlight import Api2
from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError
import voluptuous as vol
from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
from .const import DOMAIN
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
}
)
STEP_AUTH_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
)
class SmlightConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for SMLIGHT Zigbee."""
def __init__(self) -> None:
"""Initialize the config flow."""
self.client: Api2
self.host: str | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
host = user_input[CONF_HOST]
self.client = Api2(host, session=async_get_clientsession(self.hass))
self.host = host
try:
if not await self._async_check_auth_required(user_input):
return await self._async_complete_entry(user_input)
except SmlightConnectionError:
errors["base"] = "cannot_connect"
except SmlightAuthError:
return await self.async_step_auth()
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_auth(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle authentication to SLZB-06 device."""
errors: dict[str, str] = {}
if user_input is not None:
try:
if not await self._async_check_auth_required(user_input):
return await self._async_complete_entry(user_input)
except SmlightConnectionError:
return self.async_abort(reason="cannot_connect")
except SmlightAuthError:
errors["base"] = "invalid_auth"
return self.async_show_form(
step_id="auth", data_schema=STEP_AUTH_DATA_SCHEMA, errors=errors
)
async def async_step_zeroconf(
self, discovery_info: zeroconf.ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle a discovered Lan coordinator."""
local_name = discovery_info.hostname[:-1]
node_name = local_name.removesuffix(".local")
self.host = local_name
self.context["title_placeholders"] = {CONF_NAME: node_name}
self.client = Api2(self.host, session=async_get_clientsession(self.hass))
mac = discovery_info.properties.get("mac")
# fallback for legacy firmware
if mac is None:
info = await self.client.get_info()
mac = info.MAC
await self.async_set_unique_id(format_mac(mac))
self._abort_if_unique_id_configured()
return await self.async_step_confirm_discovery()
async def async_step_confirm_discovery(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle discovery confirm."""
errors: dict[str, str] = {}
if user_input is not None:
user_input[CONF_HOST] = self.host
try:
if not await self._async_check_auth_required(user_input):
return await self._async_complete_entry(user_input)
except SmlightConnectionError:
return self.async_abort(reason="cannot_connect")
except SmlightAuthError:
return await self.async_step_auth()
self._set_confirm_only()
return self.async_show_form(
step_id="confirm_discovery",
description_placeholders={"host": self.host},
errors=errors,
)
async def _async_check_auth_required(self, user_input: dict[str, Any]) -> bool:
"""Check if auth required and attempt to authenticate."""
if await self.client.check_auth_needed():
if user_input.get(CONF_USERNAME) and user_input.get(CONF_PASSWORD):
return not await self.client.authenticate(
user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
)
raise SmlightAuthError
return False
async def _async_complete_entry(
self, user_input: dict[str, Any]
) -> ConfigFlowResult:
info = await self.client.get_info()
await self.async_set_unique_id(format_mac(info.MAC))
self._abort_if_unique_id_configured()
if user_input.get(CONF_HOST) is None:
user_input[CONF_HOST] = self.host
assert info.model is not None
return self.async_create_entry(title=info.model, data=user_input)

View file

@ -0,0 +1,11 @@
"""Constants for the SMLIGHT Zigbee integration."""
from datetime import timedelta
import logging
DOMAIN = "smlight"
ATTR_MANUFACTURER = "SMLIGHT"
LOGGER = logging.getLogger(__package__)
SCAN_INTERVAL = timedelta(seconds=300)

View file

@ -0,0 +1,71 @@
"""DataUpdateCoordinator for Smlight."""
from dataclasses import dataclass
from pysmlight import Api2, Info, Sensors
from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, LOGGER, SCAN_INTERVAL
@dataclass
class SmData:
"""SMLIGHT data stored in the DataUpdateCoordinator."""
sensors: Sensors
info: Info
class SmDataUpdateCoordinator(DataUpdateCoordinator[SmData]):
"""Class to manage fetching SMLIGHT data."""
config_entry: ConfigEntry
def __init__(self, hass: HomeAssistant, host: str) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
LOGGER,
name=f"{DOMAIN}_{host}",
update_interval=SCAN_INTERVAL,
)
self.unique_id: str | None = None
self.client = Api2(host=host, session=async_get_clientsession(hass))
async def _async_setup(self) -> None:
"""Authenticate if needed during initial setup."""
if await self.client.check_auth_needed():
if (
CONF_USERNAME in self.config_entry.data
and CONF_PASSWORD in self.config_entry.data
):
try:
await self.client.authenticate(
self.config_entry.data[CONF_USERNAME],
self.config_entry.data[CONF_PASSWORD],
)
except SmlightAuthError as err:
LOGGER.error("Failed to authenticate: %s", err)
raise ConfigEntryError from err
info = await self.client.get_info()
self.unique_id = format_mac(info.MAC)
async def _async_update_data(self) -> SmData:
"""Fetch data from the SMLIGHT device."""
try:
return SmData(
sensors=await self.client.get_sensors(),
info=await self.client.get_info(),
)
except SmlightConnectionError as err:
raise UpdateFailed(err) from err

View file

@ -0,0 +1,31 @@
"""Base class for all SMLIGHT entities."""
from __future__ import annotations
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ATTR_MANUFACTURER
from .coordinator import SmDataUpdateCoordinator
class SmEntity(CoordinatorEntity[SmDataUpdateCoordinator]):
"""Base class for all SMLight entities."""
_attr_has_entity_name = True
def __init__(self, coordinator: SmDataUpdateCoordinator) -> None:
"""Initialize entity with device."""
super().__init__(coordinator)
mac = format_mac(coordinator.data.info.MAC)
self._attr_device_info = DeviceInfo(
configuration_url=f"http://{coordinator.client.host}",
connections={(CONNECTION_NETWORK_MAC, mac)},
manufacturer=ATTR_MANUFACTURER,
model=coordinator.data.info.model,
sw_version=f"core: {coordinator.data.info.sw_version} / zigbee: {coordinator.data.info.zb_version}",
)

View file

@ -0,0 +1,15 @@
{
"domain": "smlight",
"name": "SMLIGHT SLZB",
"codeowners": ["@tl-sl"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/smlight",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["pysmlight==0.0.12"],
"zeroconf": [
{
"type": "_slzb-06._tcp.local."
}
]
}

View file

@ -0,0 +1,103 @@
"""Support for SLZB-06 sensors."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from pysmlight import Sensors
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import EntityCategory, UnitOfInformation, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import SmConfigEntry
from .coordinator import SmDataUpdateCoordinator
from .entity import SmEntity
@dataclass(frozen=True, kw_only=True)
class SmSensorEntityDescription(SensorEntityDescription):
"""Class describing SMLIGHT sensor entities."""
entity_category = EntityCategory.DIAGNOSTIC
value_fn: Callable[[Sensors], float | None]
SENSORS = [
SmSensorEntityDescription(
key="core_temperature",
translation_key="core_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda x: x.esp32_temp,
),
SmSensorEntityDescription(
key="zigbee_temperature",
translation_key="zigbee_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda x: x.zb_temp,
),
SmSensorEntityDescription(
key="ram_usage",
translation_key="ram_usage",
device_class=SensorDeviceClass.DATA_SIZE,
native_unit_of_measurement=UnitOfInformation.KILOBYTES,
entity_registry_enabled_default=False,
value_fn=lambda x: x.ram_usage,
),
SmSensorEntityDescription(
key="fs_usage",
translation_key="fs_usage",
device_class=SensorDeviceClass.DATA_SIZE,
native_unit_of_measurement=UnitOfInformation.KILOBYTES,
entity_registry_enabled_default=False,
value_fn=lambda x: x.fs_used,
),
]
async def async_setup_entry(
hass: HomeAssistant,
entry: SmConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up SMLIGHT sensor based on a config entry."""
coordinator = entry.runtime_data
async_add_entities(
SmSensorEntity(coordinator, description) for description in SENSORS
)
class SmSensorEntity(SmEntity, SensorEntity):
"""Representation of a slzb sensor."""
entity_description: SmSensorEntityDescription
def __init__(
self,
coordinator: SmDataUpdateCoordinator,
description: SmSensorEntityDescription,
) -> None:
"""Initiate slzb sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.unique_id}_{description.key}"
@property
def native_value(self) -> float | None:
"""Return the sensor value."""
return self.entity_description.value_fn(self.coordinator.data.sensors)

View file

@ -0,0 +1,49 @@
{
"config": {
"step": {
"user": {
"description": "Set up SMLIGHT Zigbee Integration",
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "The hostname or IP address of the SMLIGHT SLZB-06x device"
}
},
"auth": {
"description": "Please enter the username and password",
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
}
},
"confirm_discovery": {
"description": "Do you want to set up SMLIGHT at {host}?"
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"entity": {
"sensor": {
"zigbee_temperature": {
"name": "Zigbee chip temp"
},
"core_temperature": {
"name": "Core chip temp"
},
"fs_usage": {
"name": "Filesystem usage"
},
"ram_usage": {
"name": "RAM usage"
}
}
}
}

View file

@ -525,6 +525,7 @@ FLOWS = {
"smartthings", "smartthings",
"smarttub", "smarttub",
"smhi", "smhi",
"smlight",
"sms", "sms",
"snapcast", "snapcast",
"snooz", "snooz",

View file

@ -5588,6 +5588,12 @@
"config_flow": true, "config_flow": true,
"iot_class": "cloud_polling" "iot_class": "cloud_polling"
}, },
"smlight": {
"name": "SMLIGHT SLZB",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_polling"
},
"sms": { "sms": {
"name": "SMS notifications via GSM-modem", "name": "SMS notifications via GSM-modem",
"integration_type": "hub", "integration_type": "hub",

View file

@ -747,6 +747,9 @@ ZEROCONF = {
}, },
], ],
"_slzb-06._tcp.local.": [ "_slzb-06._tcp.local.": [
{
"domain": "smlight",
},
{ {
"domain": "zha", "domain": "zha",
"name": "slzb-06*", "name": "slzb-06*",

View file

@ -2210,6 +2210,9 @@ pysmartthings==0.7.8
# homeassistant.components.edl21 # homeassistant.components.edl21
pysml==0.0.12 pysml==0.0.12
# homeassistant.components.smlight
pysmlight==0.0.12
# homeassistant.components.snmp # homeassistant.components.snmp
pysnmp==6.2.5 pysnmp==6.2.5

View file

@ -1764,6 +1764,9 @@ pysmartthings==0.7.8
# homeassistant.components.edl21 # homeassistant.components.edl21
pysml==0.0.12 pysml==0.0.12
# homeassistant.components.smlight
pysmlight==0.0.12
# homeassistant.components.snmp # homeassistant.components.snmp
pysnmp==6.2.5 pysnmp==6.2.5

View file

@ -0,0 +1 @@
"""Tests for the SMLIGHT Zigbee adapter integration."""

View file

@ -0,0 +1,74 @@
"""Common fixtures for the SMLIGHT Zigbee tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
from pysmlight.web import Info, Sensors
import pytest
from homeassistant.components.smlight.const import DOMAIN
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry, load_json_object_fixture
MOCK_HOST = "slzb-06.local"
MOCK_USERNAME = "test-user"
MOCK_PASSWORD = "test-pass"
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return the default mocked config entry."""
return MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: MOCK_HOST,
CONF_USERNAME: MOCK_USERNAME,
CONF_PASSWORD: MOCK_PASSWORD,
},
unique_id="aa:bb:cc:dd:ee:ff",
)
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock, None, None]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.smlight.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
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.config_flow.Api2", new=smlight_mock),
):
api = smlight_mock.return_value
api.host = MOCK_HOST
api.get_info.return_value = Info.from_dict(
load_json_object_fixture("info.json", DOMAIN)
)
api.get_sensors.return_value = Sensors.from_dict(
load_json_object_fixture("sensors.json", DOMAIN)
)
api.check_auth_needed.return_value = False
api.authenticate.return_value = True
yield api
async def setup_integration(hass: HomeAssistant, mock_config_entry: MockConfigEntry):
"""Set up the integration."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry

View file

@ -0,0 +1,16 @@
{
"coord_mode": 0,
"device_ip": "192.168.1.161",
"fs_total": 3456,
"fw_channel": "dev",
"MAC": "AA:BB:CC:DD:EE:FF",
"model": "SLZB-06p7",
"ram_total": 296,
"sw_version": "v2.3.1.dev",
"wifi_mode": 0,
"zb_flash_size": 704,
"zb_hw": "CC2652P7",
"zb_ram_size": 152,
"zb_version": -1,
"zb_type": -1
}

View file

@ -0,0 +1,14 @@
{
"esp32_temp": 35.0,
"zb_temp": 32.7,
"uptime": 508125,
"socket_uptime": 127,
"ram_usage": 99,
"fs_used": 188,
"ethernet": true,
"wifi_connected": false,
"wifi_status": 255,
"disable_leds": false,
"night_mode": false,
"auto_zigbee": false
}

View file

@ -0,0 +1,33 @@
# serializer version: 1
# name: test_device_info
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'configuration_url': 'http://slzb-06.local',
'connections': set({
tuple(
'mac',
'aa:bb:cc:dd:ee:ff',
),
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'SMLIGHT',
'model': 'SLZB-06p7',
'model_id': None,
'name': 'Mock Title',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'suggested_area': None,
'sw_version': 'core: v2.3.1.dev / zigbee: -1',
'via_device_id': None,
})
# ---

View file

@ -0,0 +1,741 @@
# serializer version: 1
# name: test_sensors[sensor.mock_title_core_chip_temp-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.mock_title_core_chip_temp',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Core chip temp',
'platform': 'smlight',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'core_temperature',
'unique_id': 'aa:bb:cc:dd:ee:ff_core_temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensors[sensor.mock_title_core_chip_temp-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Mock Title Core chip temp',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.mock_title_core_chip_temp',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '35.0',
})
# ---
# name: test_sensors[sensor.mock_title_filesystem_usage-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.mock_title_filesystem_usage',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.DATA_SIZE: 'data_size'>,
'original_icon': None,
'original_name': 'Filesystem usage',
'platform': 'smlight',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'fs_usage',
'unique_id': 'aa:bb:cc:dd:ee:ff_fs_usage',
'unit_of_measurement': <UnitOfInformation.KILOBYTES: 'kB'>,
})
# ---
# name: test_sensors[sensor.mock_title_filesystem_usage-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'data_size',
'friendly_name': 'Mock Title Filesystem usage',
'unit_of_measurement': <UnitOfInformation.KILOBYTES: 'kB'>,
}),
'context': <ANY>,
'entity_id': 'sensor.mock_title_filesystem_usage',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '188',
})
# ---
# name: test_sensors[sensor.mock_title_ram_usage-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.mock_title_ram_usage',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.DATA_SIZE: 'data_size'>,
'original_icon': None,
'original_name': 'RAM usage',
'platform': 'smlight',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'ram_usage',
'unique_id': 'aa:bb:cc:dd:ee:ff_ram_usage',
'unit_of_measurement': <UnitOfInformation.KILOBYTES: 'kB'>,
})
# ---
# name: test_sensors[sensor.mock_title_ram_usage-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'data_size',
'friendly_name': 'Mock Title RAM usage',
'unit_of_measurement': <UnitOfInformation.KILOBYTES: 'kB'>,
}),
'context': <ANY>,
'entity_id': 'sensor.mock_title_ram_usage',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '99',
})
# ---
# name: test_sensors[sensor.mock_title_zigbee_chip_temp-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.mock_title_zigbee_chip_temp',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Zigbee chip temp',
'platform': 'smlight',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'zigbee_temperature',
'unique_id': 'aa:bb:cc:dd:ee:ff_zigbee_temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensors[sensor.mock_title_zigbee_chip_temp-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Mock Title Zigbee chip temp',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.mock_title_zigbee_chip_temp',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '32.7',
})
# ---
# name: test_sensors[sensor.slzb_06_core_chip_temp-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.slzb_06_core_chip_temp',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Core chip temp',
'platform': 'smlight',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'core_temperature',
'unique_id': 'aa:bb:cc:dd:ee:ff_core_temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensors[sensor.slzb_06_core_chip_temp-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'slzb-06 Core chip temp',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.slzb_06_core_chip_temp',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '35.0',
})
# ---
# name: test_sensors[sensor.slzb_06_core_chip_temp]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'slzb-06 Core chip temp',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.slzb_06_core_chip_temp',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '35.0',
})
# ---
# name: test_sensors[sensor.slzb_06_core_chip_temp].1
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.slzb_06_core_chip_temp',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Core chip temp',
'platform': 'smlight',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'core_temperature',
'unique_id': 'aa:bb:cc:dd:ee:ff_core_temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensors[sensor.slzb_06_core_chip_temp].2
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'configuration_url': 'http://slzb-06.local',
'connections': set({
tuple(
'mac',
'aa:bb:cc:dd:ee:ff',
),
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'SMLIGHT',
'model': 'SLZB-06p7',
'model_id': None,
'name': 'slzb-06',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'suggested_area': None,
'sw_version': 'core: v2.3.1.dev / zigbee: -1',
'via_device_id': None,
})
# ---
# name: test_sensors[sensor.slzb_06_filesystem_usage-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.slzb_06_filesystem_usage',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.DATA_SIZE: 'data_size'>,
'original_icon': None,
'original_name': 'Filesystem usage',
'platform': 'smlight',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'fs_usage',
'unique_id': 'aa:bb:cc:dd:ee:ff_fs_usage',
'unit_of_measurement': <UnitOfInformation.KILOBYTES: 'kB'>,
})
# ---
# name: test_sensors[sensor.slzb_06_filesystem_usage-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'data_size',
'friendly_name': 'slzb-06 Filesystem usage',
'unit_of_measurement': <UnitOfInformation.KILOBYTES: 'kB'>,
}),
'context': <ANY>,
'entity_id': 'sensor.slzb_06_filesystem_usage',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '188',
})
# ---
# name: test_sensors[sensor.slzb_06_filesystem_usage]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'data_size',
'friendly_name': 'slzb-06 Filesystem usage',
'unit_of_measurement': <UnitOfInformation.KILOBYTES: 'kB'>,
}),
'context': <ANY>,
'entity_id': 'sensor.slzb_06_filesystem_usage',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '188',
})
# ---
# name: test_sensors[sensor.slzb_06_filesystem_usage].1
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.slzb_06_filesystem_usage',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.DATA_SIZE: 'data_size'>,
'original_icon': None,
'original_name': 'Filesystem usage',
'platform': 'smlight',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'fs_usage',
'unique_id': 'aa:bb:cc:dd:ee:ff_fs_usage',
'unit_of_measurement': <UnitOfInformation.KILOBYTES: 'kB'>,
})
# ---
# name: test_sensors[sensor.slzb_06_filesystem_usage].2
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'configuration_url': 'http://slzb-06.local',
'connections': set({
tuple(
'mac',
'aa:bb:cc:dd:ee:ff',
),
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'SMLIGHT',
'model': 'SLZB-06p7',
'model_id': None,
'name': 'slzb-06',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'suggested_area': None,
'sw_version': 'core: v2.3.1.dev / zigbee: -1',
'via_device_id': None,
})
# ---
# name: test_sensors[sensor.slzb_06_ram_usage-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.slzb_06_ram_usage',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.DATA_SIZE: 'data_size'>,
'original_icon': None,
'original_name': 'RAM usage',
'platform': 'smlight',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'ram_usage',
'unique_id': 'aa:bb:cc:dd:ee:ff_ram_usage',
'unit_of_measurement': <UnitOfInformation.KILOBYTES: 'kB'>,
})
# ---
# name: test_sensors[sensor.slzb_06_ram_usage-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'data_size',
'friendly_name': 'slzb-06 RAM usage',
'unit_of_measurement': <UnitOfInformation.KILOBYTES: 'kB'>,
}),
'context': <ANY>,
'entity_id': 'sensor.slzb_06_ram_usage',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '99',
})
# ---
# name: test_sensors[sensor.slzb_06_ram_usage]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'data_size',
'friendly_name': 'slzb-06 RAM usage',
'unit_of_measurement': <UnitOfInformation.KILOBYTES: 'kB'>,
}),
'context': <ANY>,
'entity_id': 'sensor.slzb_06_ram_usage',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '99',
})
# ---
# name: test_sensors[sensor.slzb_06_ram_usage].1
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.slzb_06_ram_usage',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.DATA_SIZE: 'data_size'>,
'original_icon': None,
'original_name': 'RAM usage',
'platform': 'smlight',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'ram_usage',
'unique_id': 'aa:bb:cc:dd:ee:ff_ram_usage',
'unit_of_measurement': <UnitOfInformation.KILOBYTES: 'kB'>,
})
# ---
# name: test_sensors[sensor.slzb_06_ram_usage].2
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'configuration_url': 'http://slzb-06.local',
'connections': set({
tuple(
'mac',
'aa:bb:cc:dd:ee:ff',
),
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'SMLIGHT',
'model': 'SLZB-06p7',
'model_id': None,
'name': 'slzb-06',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'suggested_area': None,
'sw_version': 'core: v2.3.1.dev / zigbee: -1',
'via_device_id': None,
})
# ---
# name: test_sensors[sensor.slzb_06_zigbee_chip_temp-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.slzb_06_zigbee_chip_temp',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Zigbee chip temp',
'platform': 'smlight',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'zigbee_temperature',
'unique_id': 'aa:bb:cc:dd:ee:ff_zigbee_temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensors[sensor.slzb_06_zigbee_chip_temp-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'slzb-06 Zigbee chip temp',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.slzb_06_zigbee_chip_temp',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '32.7',
})
# ---
# name: test_sensors[sensor.slzb_06_zigbee_chip_temp]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'slzb-06 Zigbee chip temp',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.slzb_06_zigbee_chip_temp',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '32.7',
})
# ---
# name: test_sensors[sensor.slzb_06_zigbee_chip_temp].1
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.slzb_06_zigbee_chip_temp',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Zigbee chip temp',
'platform': 'smlight',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'zigbee_temperature',
'unique_id': 'aa:bb:cc:dd:ee:ff_zigbee_temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensors[sensor.slzb_06_zigbee_chip_temp].2
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'configuration_url': 'http://slzb-06.local',
'connections': set({
tuple(
'mac',
'aa:bb:cc:dd:ee:ff',
),
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'SMLIGHT',
'model': 'SLZB-06p7',
'model_id': None,
'name': 'slzb-06',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'suggested_area': None,
'sw_version': 'core: v2.3.1.dev / zigbee: -1',
'via_device_id': None,
})
# ---

View file

@ -0,0 +1,365 @@
"""Test the SMLIGHT SLZB config flow."""
from ipaddress import ip_address
from unittest.mock import AsyncMock, MagicMock
from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError
import pytest
from homeassistant.components import zeroconf
from homeassistant.components.smlight.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from .conftest import MOCK_HOST, MOCK_PASSWORD, MOCK_USERNAME
from tests.common import MockConfigEntry
DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname="slzb-06.local.",
name="mock_name",
port=6638,
properties={"mac": "AA:BB:CC:DD:EE:FF"},
type="mock_type",
)
DISCOVERY_INFO_LEGACY = zeroconf.ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname="slzb-06.local.",
name="mock_name",
port=6638,
properties={},
type="mock_type",
)
@pytest.mark.usefixtures("mock_smlight_client")
async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None:
"""Test the full manual user flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: MOCK_HOST,
},
)
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == "SLZB-06p7"
assert result2["data"] == {
CONF_HOST: MOCK_HOST,
}
assert result2["context"]["unique_id"] == "aa:bb:cc:dd:ee:ff"
assert len(mock_setup_entry.mock_calls) == 1
async def test_zeroconf_flow(
hass: HomeAssistant,
mock_smlight_client: MagicMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test the zeroconf flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO
)
assert result["description_placeholders"] == {"host": MOCK_HOST}
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm_discovery"
progress = hass.config_entries.flow.async_progress()
assert len(progress) == 1
assert progress[0]["flow_id"] == result["flow_id"]
assert progress[0]["context"]["confirm_only"] is True
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["context"]["source"] == "zeroconf"
assert result2["context"]["unique_id"] == "aa:bb:cc:dd:ee:ff"
assert result2["title"] == "SLZB-06p7"
assert result2["data"] == {
CONF_HOST: MOCK_HOST,
}
assert len(mock_setup_entry.mock_calls) == 1
assert len(mock_smlight_client.get_info.mock_calls) == 1
async def test_zeroconf_flow_auth(
hass: HomeAssistant,
mock_smlight_client: MagicMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test the full zeroconf flow including authentication."""
mock_smlight_client.check_auth_needed.return_value = True
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO
)
assert result["description_placeholders"] == {"host": MOCK_HOST}
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm_discovery"
progress = hass.config_entries.flow.async_progress()
assert len(progress) == 1
assert progress[0]["flow_id"] == result["flow_id"]
assert progress[0]["context"]["confirm_only"] is True
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
assert result2["type"] is FlowResultType.FORM
assert result2["step_id"] == "auth"
progress2 = hass.config_entries.flow.async_progress()
assert len(progress2) == 1
assert progress2[0]["flow_id"] == result["flow_id"]
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_USERNAME: MOCK_USERNAME,
CONF_PASSWORD: MOCK_PASSWORD,
},
)
assert result3["type"] is FlowResultType.CREATE_ENTRY
assert result3["context"]["source"] == "zeroconf"
assert result3["context"]["unique_id"] == "aa:bb:cc:dd:ee:ff"
assert result3["title"] == "SLZB-06p7"
assert result3["data"] == {
CONF_USERNAME: MOCK_USERNAME,
CONF_PASSWORD: MOCK_PASSWORD,
CONF_HOST: MOCK_HOST,
}
assert len(mock_setup_entry.mock_calls) == 1
assert len(mock_smlight_client.get_info.mock_calls) == 1
@pytest.mark.usefixtures("mock_smlight_client")
async def test_user_device_exists_abort(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test we abort user flow if device already configured."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
data={
CONF_HOST: MOCK_HOST,
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.usefixtures("mock_smlight_client")
async def test_zeroconf_device_exists_abort(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test we abort zeroconf flow if device already configured."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=DISCOVERY_INFO,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_user_invalid_auth(
hass: HomeAssistant, mock_smlight_client: MagicMock, mock_setup_entry: AsyncMock
) -> None:
"""Test we handle invalid auth."""
mock_smlight_client.check_auth_needed.return_value = True
mock_smlight_client.authenticate.side_effect = SmlightAuthError
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
data={
CONF_HOST: MOCK_HOST,
},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "auth"
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: "test",
CONF_PASSWORD: "bad",
},
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "invalid_auth"}
assert result2["step_id"] == "auth"
mock_smlight_client.authenticate.side_effect = None
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: "test",
CONF_PASSWORD: "good",
},
)
assert result3["type"] is FlowResultType.CREATE_ENTRY
assert result3["title"] == "SLZB-06p7"
assert result3["data"] == {
CONF_HOST: MOCK_HOST,
CONF_USERNAME: "test",
CONF_PASSWORD: "good",
}
assert len(mock_setup_entry.mock_calls) == 1
assert len(mock_smlight_client.get_info.mock_calls) == 1
async def test_user_cannot_connect(
hass: HomeAssistant, mock_smlight_client: MagicMock, mock_setup_entry: AsyncMock
) -> None:
"""Test we handle user cannot connect error."""
mock_smlight_client.check_auth_needed.side_effect = SmlightConnectionError
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: "unknown.local",
},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"}
assert result["step_id"] == "user"
mock_smlight_client.check_auth_needed.side_effect = None
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: MOCK_HOST,
},
)
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == "SLZB-06p7"
assert len(mock_setup_entry.mock_calls) == 1
assert len(mock_smlight_client.get_info.mock_calls) == 1
async def test_auth_cannot_connect(
hass: HomeAssistant, mock_smlight_client: MagicMock
) -> None:
"""Test we abort auth step on cannot connect error."""
mock_smlight_client.check_auth_needed.return_value = True
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: MOCK_HOST,
},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "auth"
mock_smlight_client.check_auth_needed.side_effect = SmlightConnectionError
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: MOCK_USERNAME,
CONF_PASSWORD: MOCK_PASSWORD,
},
)
assert result2["type"] is FlowResultType.ABORT
assert result2["reason"] == "cannot_connect"
async def test_zeroconf_cannot_connect(
hass: HomeAssistant, mock_smlight_client: MagicMock
) -> None:
"""Test we abort flow on zeroconf cannot connect error."""
mock_smlight_client.check_auth_needed.side_effect = SmlightConnectionError
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=DISCOVERY_INFO,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm_discovery"
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{},
)
assert result2["type"] is FlowResultType.ABORT
assert result2["reason"] == "cannot_connect"
@pytest.mark.usefixtures("mock_smlight_client")
async def test_zeroconf_legacy_mac(
hass: HomeAssistant, mock_smlight_client: MagicMock, mock_setup_entry: AsyncMock
) -> None:
"""Test we can get unique id MAC address for older firmwares."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=DISCOVERY_INFO_LEGACY,
)
assert result["description_placeholders"] == {"host": MOCK_HOST}
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["context"]["source"] == "zeroconf"
assert result2["context"]["unique_id"] == "aa:bb:cc:dd:ee:ff"
assert result2["title"] == "SLZB-06p7"
assert result2["data"] == {
CONF_HOST: MOCK_HOST,
}
assert len(mock_setup_entry.mock_calls) == 1
assert len(mock_smlight_client.get_info.mock_calls) == 2

View file

@ -0,0 +1,94 @@
"Test SMLIGHT SLZB device integration initialization."
from unittest.mock import MagicMock
from freezegun.api import FrozenDateTimeFactory
from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.smlight.const import SCAN_INTERVAL
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from .conftest import setup_integration
from tests.common import MockConfigEntry, async_fire_time_changed
pytestmark = [
pytest.mark.usefixtures(
"mock_smlight_client",
)
]
async def test_async_setup_entry(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test async_setup_entry."""
entry = await setup_integration(hass, mock_config_entry)
assert entry.state is ConfigEntryState.LOADED
assert entry.unique_id == "aa:bb:cc:dd:ee:ff"
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.NOT_LOADED
async def test_async_setup_auth_failed(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_smlight_client: MagicMock,
) -> None:
"""Test async_setup_entry when authentication fails."""
mock_smlight_client.check_auth_needed.return_value = True
mock_smlight_client.authenticate.side_effect = SmlightAuthError
entry = await setup_integration(hass, mock_config_entry)
assert entry.state is ConfigEntryState.SETUP_ERROR
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.NOT_LOADED
async def test_update_failed(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_smlight_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test update failed due to connection error."""
await setup_integration(hass, mock_config_entry)
entity = hass.states.get("sensor.mock_title_core_chip_temp")
assert entity.state is not STATE_UNAVAILABLE
mock_smlight_client.get_info.side_effect = SmlightConnectionError
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
entity = hass.states.get("sensor.mock_title_core_chip_temp")
assert entity is not None
assert entity.state == STATE_UNAVAILABLE
async def test_device_info(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test device registry information."""
entry = await setup_integration(hass, mock_config_entry)
device_entry = device_registry.async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)}
)
assert device_entry is not None
assert device_entry == snapshot

View file

@ -0,0 +1,54 @@
"""Tests for the SMLIGHT sensor platform."""
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from .conftest import setup_integration
from tests.common import MockConfigEntry, snapshot_platform
pytestmark = [
pytest.mark.usefixtures(
"mock_smlight_client",
)
]
@pytest.fixture
def platforms() -> Platform | list[Platform]:
"""Platforms, which should be loaded during the test."""
return Platform.SENSOR
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_sensors(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Test the SMLIGHT sensors."""
entry = await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id)
async def test_disabled_by_default_sensors(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the disabled by default SMLIGHT sensors."""
await setup_integration(hass, mock_config_entry)
for sensor in ("ram_usage", "filesystem_usage"):
assert not hass.states.get(f"sensor.mock_title_{sensor}")
assert (entry := entity_registry.async_get(f"sensor.mock_title_{sensor}"))
assert entry.disabled
assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION