mirror of
https://github.com/home-assistant/core.git
synced 2025-06-25 01:21:51 +02:00
Add smarla integration (#143081)
* Added smarla integration * Apply suggested changes * Bump pysmarlaapi version and reevaluate quality scale * Focus on switch platform * Bump pysmarlaapi version * Change default name of device * Code refactoring * Removed obsolete reload function * Code refactoring and clean up * Bump pysmarlaapi version * Refactoring and changed access token format * Fix tests for smarla config_flow * Update quality_scale * Major rework of tests and refactoring * Bump pysmarlaapi version * Use object equality operator when applicable * Refactoring * Patch both connection objects * Refactor tests * Fix leaking tests * Implemented full test coverage * Bump pysmarlaapi version * Fix tests * Improve tests --------- Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@ -1419,6 +1419,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/sma/ @kellerza @rklomp @erwindouna
|
||||
/homeassistant/components/smappee/ @bsmappee
|
||||
/tests/components/smappee/ @bsmappee
|
||||
/homeassistant/components/smarla/ @explicatis @rlint-explicatis
|
||||
/tests/components/smarla/ @explicatis @rlint-explicatis
|
||||
/homeassistant/components/smart_meter_texas/ @grahamwetzler
|
||||
/tests/components/smart_meter_texas/ @grahamwetzler
|
||||
/homeassistant/components/smartthings/ @joostlek
|
||||
|
39
homeassistant/components/smarla/__init__.py
Normal file
39
homeassistant/components/smarla/__init__.py
Normal file
@ -0,0 +1,39 @@
|
||||
"""The Swing2Sleep Smarla integration."""
|
||||
|
||||
from pysmarlaapi import Connection, Federwiege
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
|
||||
from .const import HOST, PLATFORMS
|
||||
|
||||
type FederwiegeConfigEntry = ConfigEntry[Federwiege]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: FederwiegeConfigEntry) -> bool:
|
||||
"""Set up this integration using UI."""
|
||||
connection = Connection(HOST, token_b64=entry.data[CONF_ACCESS_TOKEN])
|
||||
|
||||
# Check if token still has access
|
||||
if not await connection.refresh_token():
|
||||
raise ConfigEntryAuthFailed("Invalid authentication")
|
||||
|
||||
federwiege = Federwiege(hass.loop, connection)
|
||||
federwiege.register()
|
||||
federwiege.connect()
|
||||
|
||||
entry.runtime_data = federwiege
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: FederwiegeConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
entry.runtime_data.disconnect()
|
||||
|
||||
return unload_ok
|
62
homeassistant/components/smarla/config_flow.py
Normal file
62
homeassistant/components/smarla/config_flow.py
Normal file
@ -0,0 +1,62 @@
|
||||
"""Config flow for Swing2Sleep Smarla integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pysmarlaapi import Connection
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||
|
||||
from .const import DOMAIN, HOST
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema({CONF_ACCESS_TOKEN: str})
|
||||
|
||||
|
||||
class SmarlaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Swing2Sleep Smarla."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def _handle_token(self, token: str) -> tuple[dict[str, str], str | None]:
|
||||
"""Handle the token input."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
try:
|
||||
conn = Connection(url=HOST, token_b64=token)
|
||||
except ValueError:
|
||||
errors["base"] = "malformed_token"
|
||||
return errors, None
|
||||
|
||||
if not await conn.refresh_token():
|
||||
errors["base"] = "invalid_auth"
|
||||
return errors, None
|
||||
|
||||
return errors, conn.token.serialNumber
|
||||
|
||||
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:
|
||||
raw_token = user_input[CONF_ACCESS_TOKEN]
|
||||
errors, serial_number = await self._handle_token(token=raw_token)
|
||||
|
||||
if not errors and serial_number is not None:
|
||||
await self.async_set_unique_id(serial_number)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(
|
||||
title=serial_number,
|
||||
data={CONF_ACCESS_TOKEN: raw_token},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
12
homeassistant/components/smarla/const.py
Normal file
12
homeassistant/components/smarla/const.py
Normal file
@ -0,0 +1,12 @@
|
||||
"""Constants for the Swing2Sleep Smarla integration."""
|
||||
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "smarla"
|
||||
|
||||
HOST = "https://devices.swing2sleep.de"
|
||||
|
||||
PLATFORMS = [Platform.SWITCH]
|
||||
|
||||
DEVICE_MODEL_NAME = "Smarla"
|
||||
MANUFACTURER_NAME = "Swing2Sleep"
|
41
homeassistant/components/smarla/entity.py
Normal file
41
homeassistant/components/smarla/entity.py
Normal file
@ -0,0 +1,41 @@
|
||||
"""Common base for entities."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pysmarlaapi import Federwiege
|
||||
from pysmarlaapi.federwiege.classes import Property
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .const import DEVICE_MODEL_NAME, DOMAIN, MANUFACTURER_NAME
|
||||
|
||||
|
||||
class SmarlaBaseEntity(Entity):
|
||||
"""Common Base Entity class for defining Smarla device."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, federwiege: Federwiege, prop: Property) -> None:
|
||||
"""Initialise the entity."""
|
||||
self._property = prop
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, federwiege.serial_number)},
|
||||
name=DEVICE_MODEL_NAME,
|
||||
model=DEVICE_MODEL_NAME,
|
||||
manufacturer=MANUFACTURER_NAME,
|
||||
serial_number=federwiege.serial_number,
|
||||
)
|
||||
|
||||
async def on_change(self, value: Any):
|
||||
"""Notify ha when state changes."""
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when this Entity has been added to HA."""
|
||||
await self._property.add_listener(self.on_change)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Entity being removed from hass."""
|
||||
await self._property.remove_listener(self.on_change)
|
9
homeassistant/components/smarla/icons.json
Normal file
9
homeassistant/components/smarla/icons.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"entity": {
|
||||
"switch": {
|
||||
"smart_mode": {
|
||||
"default": "mdi:refresh-auto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
12
homeassistant/components/smarla/manifest.json
Normal file
12
homeassistant/components/smarla/manifest.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"domain": "smarla",
|
||||
"name": "Swing2Sleep Smarla",
|
||||
"codeowners": ["@explicatis", "@rlint-explicatis"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/smarla",
|
||||
"integration_type": "device",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pysmarlaapi", "pysignalr"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pysmarlaapi==0.8.2"]
|
||||
}
|
60
homeassistant/components/smarla/quality_scale.yaml
Normal file
60
homeassistant/components/smarla/quality_scale.yaml
Normal file
@ -0,0 +1,60 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup: done
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions: done
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: todo
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
28
homeassistant/components/smarla/strings.json
Normal file
28
homeassistant/components/smarla/strings.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"config": {
|
||||
"error": {
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"malformed_token": "Malformed access token"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"access_token": "[%key:common::config_flow::data::access_token%]"
|
||||
},
|
||||
"data_description": {
|
||||
"access_token": "The access token generated by the Swing2Sleep app."
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"switch": {
|
||||
"smart_mode": {
|
||||
"name": "Smart Mode"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
80
homeassistant/components/smarla/switch.py
Normal file
80
homeassistant/components/smarla/switch.py
Normal file
@ -0,0 +1,80 @@
|
||||
"""Support for the Swing2Sleep Smarla switch entities."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from pysmarlaapi import Federwiege
|
||||
from pysmarlaapi.federwiege.classes import Property
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import FederwiegeConfigEntry
|
||||
from .entity import SmarlaBaseEntity
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class SmarlaSwitchEntityDescription(SwitchEntityDescription):
|
||||
"""Class describing Swing2Sleep Smarla switch entity."""
|
||||
|
||||
service: str
|
||||
property: str
|
||||
|
||||
|
||||
SWITCHES: list[SmarlaSwitchEntityDescription] = [
|
||||
SmarlaSwitchEntityDescription(
|
||||
key="swing_active",
|
||||
name=None,
|
||||
service="babywiege",
|
||||
property="swing_active",
|
||||
),
|
||||
SmarlaSwitchEntityDescription(
|
||||
key="smart_mode",
|
||||
translation_key="smart_mode",
|
||||
service="babywiege",
|
||||
property="smart_mode",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: FederwiegeConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Smarla switches from config entry."""
|
||||
federwiege = config_entry.runtime_data
|
||||
async_add_entities(SmarlaSwitch(federwiege, desc) for desc in SWITCHES)
|
||||
|
||||
|
||||
class SmarlaSwitch(SmarlaBaseEntity, SwitchEntity):
|
||||
"""Representation of Smarla switch."""
|
||||
|
||||
entity_description: SmarlaSwitchEntityDescription
|
||||
|
||||
_property: Property[bool]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
federwiege: Federwiege,
|
||||
desc: SmarlaSwitchEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize a Smarla switch."""
|
||||
prop = federwiege.get_property(desc.service, desc.property)
|
||||
super().__init__(federwiege, prop)
|
||||
self.entity_description = desc
|
||||
self._attr_unique_id = f"{federwiege.serial_number}-{desc.key}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return the entity value to represent the entity state."""
|
||||
return self._property.get()
|
||||
|
||||
def turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
self._property.set(True)
|
||||
|
||||
def turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch off."""
|
||||
self._property.set(False)
|
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@ -578,6 +578,7 @@ FLOWS = {
|
||||
"slimproto",
|
||||
"sma",
|
||||
"smappee",
|
||||
"smarla",
|
||||
"smart_meter_texas",
|
||||
"smartthings",
|
||||
"smarttub",
|
||||
|
@ -6028,6 +6028,12 @@
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling"
|
||||
},
|
||||
"smarla": {
|
||||
"name": "Swing2Sleep Smarla",
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_push"
|
||||
},
|
||||
"smart_blinds": {
|
||||
"name": "Smartblinds",
|
||||
"integration_type": "virtual",
|
||||
|
3
requirements_all.txt
generated
3
requirements_all.txt
generated
@ -2337,6 +2337,9 @@ pysma==0.7.5
|
||||
# homeassistant.components.smappee
|
||||
pysmappee==0.2.29
|
||||
|
||||
# homeassistant.components.smarla
|
||||
pysmarlaapi==0.8.2
|
||||
|
||||
# homeassistant.components.smartthings
|
||||
pysmartthings==3.2.3
|
||||
|
||||
|
3
requirements_test_all.txt
generated
3
requirements_test_all.txt
generated
@ -1910,6 +1910,9 @@ pysma==0.7.5
|
||||
# homeassistant.components.smappee
|
||||
pysmappee==0.2.29
|
||||
|
||||
# homeassistant.components.smarla
|
||||
pysmarlaapi==0.8.2
|
||||
|
||||
# homeassistant.components.smartthings
|
||||
pysmartthings==3.2.3
|
||||
|
||||
|
22
tests/components/smarla/__init__.py
Normal file
22
tests/components/smarla/__init__.py
Normal file
@ -0,0 +1,22 @@
|
||||
"""Tests for the Smarla integration."""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> bool:
|
||||
"""Set up the component."""
|
||||
config_entry.add_to_hass(hass)
|
||||
if success := await hass.config_entries.async_setup(config_entry.entry_id):
|
||||
await hass.async_block_till_done()
|
||||
return success
|
||||
|
||||
|
||||
async def update_property_listeners(mock: AsyncMock, value: Any = None) -> None:
|
||||
"""Update the property listeners for the mock object."""
|
||||
for call in mock.add_listener.call_args_list:
|
||||
await call[0][0](value)
|
63
tests/components/smarla/conftest.py
Normal file
63
tests/components/smarla/conftest.py
Normal file
@ -0,0 +1,63 @@
|
||||
"""Configuration for smarla tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from pysmarlaapi.classes import AuthToken
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.smarla.const import DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_USER
|
||||
|
||||
from .const import MOCK_ACCESS_TOKEN_JSON, MOCK_SERIAL_NUMBER, MOCK_USER_INPUT
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry() -> MockConfigEntry:
|
||||
"""Create a mock config entry."""
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id=MOCK_SERIAL_NUMBER,
|
||||
source=SOURCE_USER,
|
||||
data=MOCK_USER_INPUT,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_setup_entry() -> Generator:
|
||||
"""Override async_setup_entry."""
|
||||
with patch("homeassistant.components.smarla.async_setup_entry", return_value=True):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_connection() -> Generator[MagicMock]:
|
||||
"""Patch Connection object."""
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.smarla.config_flow.Connection", autospec=True
|
||||
) as mock_connection,
|
||||
patch(
|
||||
"homeassistant.components.smarla.Connection",
|
||||
mock_connection,
|
||||
),
|
||||
):
|
||||
connection = mock_connection.return_value
|
||||
connection.token = AuthToken.from_json(MOCK_ACCESS_TOKEN_JSON)
|
||||
connection.refresh_token.return_value = True
|
||||
yield connection
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_federwiege(mock_connection: MagicMock) -> Generator[MagicMock]:
|
||||
"""Mock the Federwiege instance."""
|
||||
with patch(
|
||||
"homeassistant.components.smarla.Federwiege", autospec=True
|
||||
) as mock_federwiege:
|
||||
federwiege = mock_federwiege.return_value
|
||||
federwiege.serial_number = MOCK_SERIAL_NUMBER
|
||||
yield federwiege
|
20
tests/components/smarla/const.py
Normal file
20
tests/components/smarla/const.py
Normal file
@ -0,0 +1,20 @@
|
||||
"""Constants for the Smarla integration tests."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||
|
||||
MOCK_ACCESS_TOKEN_JSON = {
|
||||
"refreshToken": "test",
|
||||
"appIdentifier": "HA-test",
|
||||
"serialNumber": "ABCD",
|
||||
}
|
||||
|
||||
MOCK_SERIAL_NUMBER = MOCK_ACCESS_TOKEN_JSON["serialNumber"]
|
||||
|
||||
MOCK_ACCESS_TOKEN = base64.b64encode(
|
||||
json.dumps(MOCK_ACCESS_TOKEN_JSON).encode()
|
||||
).decode()
|
||||
|
||||
MOCK_USER_INPUT = {CONF_ACCESS_TOKEN: MOCK_ACCESS_TOKEN}
|
95
tests/components/smarla/snapshots/test_switch.ambr
Normal file
95
tests/components/smarla/snapshots/test_switch.ambr
Normal file
@ -0,0 +1,95 @@
|
||||
# serializer version: 1
|
||||
# name: test_entities[switch.smarla-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'switch',
|
||||
'entity_category': None,
|
||||
'entity_id': 'switch.smarla',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': None,
|
||||
'platform': 'smarla',
|
||||
'previous_unique_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': 'ABCD-swing_active',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entities[switch.smarla-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Smarla',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'switch.smarla',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities[switch.smarla_smart_mode-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'switch',
|
||||
'entity_category': None,
|
||||
'entity_id': 'switch.smarla_smart_mode',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Smart Mode',
|
||||
'platform': 'smarla',
|
||||
'previous_unique_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'smart_mode',
|
||||
'unique_id': 'ABCD-smart_mode',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entities[switch.smarla_smart_mode-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Smarla Smart Mode',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'switch.smarla_smart_mode',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
102
tests/components/smarla/test_config_flow.py
Normal file
102
tests/components/smarla/test_config_flow.py
Normal file
@ -0,0 +1,102 @@
|
||||
"""Test config flow for Swing2Sleep Smarla integration."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from homeassistant.components.smarla.const import DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_USER
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from .const import MOCK_SERIAL_NUMBER, MOCK_USER_INPUT
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_config_flow(
|
||||
hass: HomeAssistant, mock_setup_entry, mock_connection: MagicMock
|
||||
) -> None:
|
||||
"""Test creating a config entry."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_USER},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input=MOCK_USER_INPUT,
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == MOCK_SERIAL_NUMBER
|
||||
assert result["data"] == MOCK_USER_INPUT
|
||||
assert result["result"].unique_id == MOCK_SERIAL_NUMBER
|
||||
|
||||
|
||||
async def test_malformed_token(
|
||||
hass: HomeAssistant, mock_setup_entry, mock_connection: MagicMock
|
||||
) -> None:
|
||||
"""Test we show user form on malformed token input."""
|
||||
with patch(
|
||||
"homeassistant.components.smarla.config_flow.Connection", side_effect=ValueError
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_USER},
|
||||
data=MOCK_USER_INPUT,
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {"base": "malformed_token"}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input=MOCK_USER_INPUT,
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
|
||||
|
||||
async def test_invalid_auth(
|
||||
hass: HomeAssistant, mock_setup_entry, mock_connection: MagicMock
|
||||
) -> None:
|
||||
"""Test we show user form on invalid auth."""
|
||||
with patch.object(
|
||||
mock_connection, "refresh_token", new=AsyncMock(return_value=False)
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_USER},
|
||||
data=MOCK_USER_INPUT,
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {"base": "invalid_auth"}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input=MOCK_USER_INPUT,
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
|
||||
|
||||
async def test_device_exists_abort(
|
||||
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_connection: MagicMock
|
||||
) -> None:
|
||||
"""Test we abort config flow if Smarla device already configured."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_USER},
|
||||
data=MOCK_USER_INPUT,
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
21
tests/components/smarla/test_init.py
Normal file
21
tests/components/smarla/test_init.py
Normal file
@ -0,0 +1,21 @@
|
||||
"""Test switch platform for Swing2Sleep Smarla integration."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import setup_integration
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_init_invalid_auth(
|
||||
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_connection: MagicMock
|
||||
) -> None:
|
||||
"""Test init invalid authentication behavior."""
|
||||
mock_connection.refresh_token.return_value = False
|
||||
|
||||
assert not await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
|
103
tests/components/smarla/test_switch.py
Normal file
103
tests/components/smarla/test_switch.py
Normal file
@ -0,0 +1,103 @@
|
||||
"""Test switch platform for Swing2Sleep Smarla integration."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from pysmarlaapi.federwiege.classes import Property
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
SERVICE_TURN_OFF,
|
||||
SERVICE_TURN_ON,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from . import setup_integration, update_property_listeners
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_switch_property() -> MagicMock:
|
||||
"""Mock a switch property."""
|
||||
mock = MagicMock(spec=Property)
|
||||
mock.get.return_value = False
|
||||
return mock
|
||||
|
||||
|
||||
async def test_entities(
|
||||
hass: HomeAssistant,
|
||||
mock_federwiege: MagicMock,
|
||||
mock_switch_property: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test the Smarla entities."""
|
||||
mock_federwiege.get_property.return_value = mock_switch_property
|
||||
|
||||
with (
|
||||
patch("homeassistant.components.smarla.PLATFORMS", [Platform.SWITCH]),
|
||||
):
|
||||
assert await setup_integration(hass, mock_config_entry)
|
||||
|
||||
await snapshot_platform(
|
||||
hass, entity_registry, snapshot, mock_config_entry.entry_id
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("service", "parameter"),
|
||||
[
|
||||
(SERVICE_TURN_ON, True),
|
||||
(SERVICE_TURN_OFF, False),
|
||||
],
|
||||
)
|
||||
async def test_switch_action(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_federwiege: MagicMock,
|
||||
mock_switch_property: MagicMock,
|
||||
service: str,
|
||||
parameter: bool,
|
||||
) -> None:
|
||||
"""Test Smarla Switch on/off behavior."""
|
||||
mock_federwiege.get_property.return_value = mock_switch_property
|
||||
|
||||
assert await setup_integration(hass, mock_config_entry)
|
||||
|
||||
# Turn on
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
service,
|
||||
{ATTR_ENTITY_ID: "switch.smarla"},
|
||||
blocking=True,
|
||||
)
|
||||
mock_switch_property.set.assert_called_once_with(parameter)
|
||||
|
||||
|
||||
async def test_switch_state_update(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_federwiege: MagicMock,
|
||||
mock_switch_property: MagicMock,
|
||||
) -> None:
|
||||
"""Test Smarla Switch callback."""
|
||||
mock_federwiege.get_property.return_value = mock_switch_property
|
||||
|
||||
assert await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert hass.states.get("switch.smarla").state == STATE_OFF
|
||||
|
||||
mock_switch_property.get.return_value = True
|
||||
|
||||
await update_property_listeners(mock_switch_property)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get("switch.smarla").state == STATE_ON
|
Reference in New Issue
Block a user