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:
Robin Lintermann
2025-05-26 15:21:23 +02:00
committed by GitHub
parent 68a4e1a112
commit dafda420e5
21 changed files with 784 additions and 0 deletions

2
CODEOWNERS generated
View File

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

View 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

View 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,
)

View 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"

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

View File

@ -0,0 +1,9 @@
{
"entity": {
"switch": {
"smart_mode": {
"default": "mdi:refresh-auto"
}
}
}
}

View 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"]
}

View 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

View 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"
}
}
}
}

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

View File

@ -578,6 +578,7 @@ FLOWS = {
"slimproto",
"sma",
"smappee",
"smarla",
"smart_meter_texas",
"smartthings",
"smarttub",

View File

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

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

View File

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

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

View 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

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

View 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',
})
# ---

View 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

View 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

View 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