Added smarla integration

This commit is contained in:
Robin Lintermann
2025-04-14 17:49:54 +02:00
parent 917b467b85
commit d19aabfcd0
17 changed files with 726 additions and 0 deletions

2
CODEOWNERS generated
View File

@@ -1417,6 +1417,8 @@ build.json @home-assistant/supervisor
/tests/components/sma/ @kellerza @rklomp @erwindouna /tests/components/sma/ @kellerza @rklomp @erwindouna
/homeassistant/components/smappee/ @bsmappee /homeassistant/components/smappee/ @bsmappee
/tests/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 /homeassistant/components/smart_meter_texas/ @grahamwetzler
/tests/components/smart_meter_texas/ @grahamwetzler /tests/components/smart_meter_texas/ @grahamwetzler
/homeassistant/components/smartthings/ @joostlek /homeassistant/components/smartthings/ @joostlek

View File

@@ -0,0 +1,91 @@
"""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, ConfigEntryError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, HOST, PLATFORMS
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
type FederwiegeConfigEntry = ConfigEntry[Federwiege]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up this integration using YAML is not supported."""
return True
async def async_setup_entry(hass: HomeAssistant, entry: FederwiegeConfigEntry) -> bool:
"""Set up this integration using UI."""
if hass.data.get(DOMAIN) is None:
hass.data.setdefault(DOMAIN, {})
try:
connection = Connection(HOST, token_str=entry.data.get(CONF_ACCESS_TOKEN, None))
except ValueError as e:
raise ConfigEntryError("Invalid token") from e
if not await connection.get_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,
list(PLATFORMS),
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: FederwiegeConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(
entry,
list(PLATFORMS),
)
if unload_ok:
federwiege = entry.runtime_data
federwiege.disconnect()
return unload_ok
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Reload config entry."""
await async_unload_entry(hass, entry)
await async_setup_entry(hass, entry)
class SmarlaBaseEntity(Entity):
"""Common Base Entity class for defining Smarla device."""
def __init__(
self,
federwiege: Federwiege,
) -> None:
"""Initialise the entity."""
super().__init__()
self._attr_has_entity_name = True
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, federwiege.serial_number)},
name="Federwiege",
model="Smarla",
manufacturer="Swing2Sleep",
serial_number=federwiege.serial_number,
)

View File

@@ -0,0 +1,69 @@
"""Config flow for Swing2Sleep Smarla integration."""
from __future__ import annotations
from pysmarlaapi import Connection
import voluptuous as vol
from homeassistant import config_entries, exceptions
from homeassistant.const import CONF_ACCESS_TOKEN
from .const import DOMAIN, HOST
STEP_USER_DATA_SCHEMA = vol.Schema({CONF_ACCESS_TOKEN: str})
class SmarlaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Swing2Sleep Smarla."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH
async def async_step_user(self, user_input=None) -> config_entries.ConfigFlowResult:
"""Handle the initial step."""
if user_input is None:
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
)
errors = {}
try:
info = await self.validate_input(user_input)
return self.async_create_entry(
title=info["title"],
data={CONF_ACCESS_TOKEN: info.get(CONF_ACCESS_TOKEN)},
)
except InvalidAuth:
errors["base"] = "invalid_auth"
except ValueError:
errors["base"] = "invalid_token"
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)
async def validate_input(self, data):
"""Validate the user input allows us to connect.
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
"""
conn = Connection(url=HOST, token_b64=data[CONF_ACCESS_TOKEN])
await self.async_set_unique_id(conn.token.serialNumber)
self._abort_if_unique_id_configured()
if not await conn.get_token():
raise InvalidAuth
return {
"title": conn.token.serialNumber,
CONF_ACCESS_TOKEN: conn.token.get_string(),
}
class InvalidAuth(exceptions.HomeAssistantError):
"""Error to indicate there is invalid auth."""

View File

@@ -0,0 +1,7 @@
"""Constants for the Swing2Sleep Smarla integration."""
DOMAIN = "smarla"
HOST = "https://devices.swing2sleep.de"
PLATFORMS = ["number", "sensor", "switch"]

View File

@@ -0,0 +1,28 @@
{
"entity": {
"switch": {
"smartmode": {
"default": "mdi:refresh-auto"
}
},
"number": {
"intensity": {
"default": "mdi:sine-wave"
}
},
"sensor": {
"amplitude": {
"default": "mdi:sine-wave"
},
"period": {
"default": "mdi:sine-wave"
},
"activity": {
"default": "mdi:baby-face"
},
"swing_count": {
"default": "mdi:counter"
}
}
}
}

View File

@@ -0,0 +1,13 @@
{
"domain": "smarla",
"name": "Swing2Sleep Smarla",
"codeowners": ["@explicatis", "@rlint-explicatis"],
"config_flow": true,
"dependencies": [],
"documentation": "https://www.home-assistant.io/integrations/smarla",
"integration_type": "device",
"iot_class": "cloud_push",
"loggers": ["pysmarlaapi", "pysignalr"],
"quality_scale": "bronze",
"requirements": ["pysmarlaapi==0.4.0"]
}

View File

@@ -0,0 +1,93 @@
"""Support for the Swing2Sleep Smarla number entities."""
from dataclasses import dataclass
from pysmarlaapi import Federwiege
from homeassistant.components.number import (
NumberEntity,
NumberEntityDescription,
NumberMode,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import FederwiegeConfigEntry, SmarlaBaseEntity
@dataclass(frozen=True, kw_only=True)
class SmarlaNumberEntityDescription(NumberEntityDescription):
"""Class describing Swing2Sleep Smarla number entities."""
service: str
property: str
NUMBER_TYPES: list[SmarlaNumberEntityDescription] = [
SmarlaNumberEntityDescription(
key="intensity",
translation_key="intensity",
service="babywiege",
property="intensity",
native_max_value=100,
native_min_value=0,
native_step=1,
mode=NumberMode.SLIDER,
),
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: FederwiegeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Smarla numbers from config entry."""
federwiege = config_entry.runtime_data
entities: list[SmarlaNumber] = []
for desc in NUMBER_TYPES:
entity = SmarlaNumber(federwiege, desc)
entities.append(entity)
async_add_entities(entities)
class SmarlaNumber(SmarlaBaseEntity, NumberEntity):
"""Representation of Smarla number."""
async def on_change(self, value):
"""Notify ha when state changes."""
self.async_write_ha_state()
def __init__(
self,
federwiege: Federwiege,
description: SmarlaNumberEntityDescription,
) -> None:
"""Initialize a Smarla number."""
super().__init__(federwiege)
self.property = federwiege.get_service(description.service).get_property(
description.property
)
self.entity_description = description
self._attr_should_poll = False
self._attr_unique_id = f"{federwiege.serial_number}-{description.key}"
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)
@property
def native_value(self) -> float:
"""Return the entity value to represent the entity state."""
return self.property.get()
def set_native_value(self, value: float) -> None:
"""Update to the smarla device."""
self.property.set(int(value))

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: todo
config-entry-unloading: todo
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: todo
integration-owner: todo
log-when-unavailable: todo
parallel-updates: todo
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: todo
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo

View File

@@ -0,0 +1,120 @@
"""Support for the Swing2Sleep Smarla sensor entities."""
from dataclasses import dataclass
from pysmarlaapi import Federwiege
from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import UnitOfLength, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import FederwiegeConfigEntry, SmarlaBaseEntity
@dataclass(frozen=True, kw_only=True)
class SmarlaSensorEntityDescription(SensorEntityDescription):
"""Class describing Swing2Sleep Smarla sensor entities."""
service: str
property: str
multiple: bool = False
value_pos: int = 0
NUMBER_TYPES: list[SmarlaSensorEntityDescription] = [
SmarlaSensorEntityDescription(
key="amplitude",
translation_key="amplitude",
service="analyser",
property="oscillation",
multiple=True,
value_pos=0,
native_unit_of_measurement=UnitOfLength.MILLIMETERS,
state_class=SensorStateClass.MEASUREMENT,
),
SmarlaSensorEntityDescription(
key="period",
translation_key="period",
service="analyser",
property="oscillation",
multiple=True,
value_pos=1,
native_unit_of_measurement=UnitOfTime.MILLISECONDS,
state_class=SensorStateClass.MEASUREMENT,
),
SmarlaSensorEntityDescription(
key="activity",
translation_key="activity",
service="analyser",
property="activity",
state_class=SensorStateClass.MEASUREMENT,
),
SmarlaSensorEntityDescription(
key="swing_count",
translation_key="swing_count",
service="analyser",
property="swing_count",
state_class=SensorStateClass.MEASUREMENT,
),
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: FederwiegeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Smarla sensors from config entry."""
federwiege = config_entry.runtime_data
entities: list[SmarlaSensor] = []
for desc in NUMBER_TYPES:
entity = SmarlaSensor(federwiege, desc)
entities.append(entity)
async_add_entities(entities)
class SmarlaSensor(SmarlaBaseEntity, SensorEntity):
"""Representation of Smarla sensor."""
async def on_change(self, value):
"""Notify ha when state changes."""
self.async_write_ha_state()
def __init__(
self,
federwiege: Federwiege,
description: SmarlaSensorEntityDescription,
) -> None:
"""Initialize a Smarla sensor."""
super().__init__(federwiege)
self.property = federwiege.get_service(description.service).get_property(
description.property
)
self.entity_description = description
self.multiple = description.multiple
self.pos = description.value_pos
self._attr_should_poll = False
self._attr_unique_id = f"{federwiege.serial_number}-{description.key}"
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)
@property
def native_value(self) -> int:
"""Return the entity value to represent the entity state."""
return (
self.property.get() if not self.multiple else self.property.get()[self.pos]
)

View File

@@ -0,0 +1,47 @@
{
"config": {
"error": {
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_token": "Invalid access token"
},
"step": {
"user": {
"data": {
"access_token": "[%key:common::config_flow::data::access_token%]"
},
"data_description": {
"access_token": "The access token generated from the App."
}
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"entity": {
"sensor": {
"amplitude": {
"name": "Amplitude"
},
"period": {
"name": "Period"
},
"activity": {
"name": "Activity"
},
"swing_count": {
"name": "Swing count"
}
},
"number": {
"intensity": {
"name": "Intensity"
}
},
"switch": {
"smartmode": {
"name": "Smart mode"
}
}
}
}

View File

@@ -0,0 +1,96 @@
"""Support for the Swing2Sleep Smarla switch entities."""
from dataclasses import dataclass
from typing import Any
from pysmarlaapi import Federwiege
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import FederwiegeConfigEntry, SmarlaBaseEntity
@dataclass(frozen=True, kw_only=True)
class SmarlaSwitchEntityDescription(SwitchEntityDescription):
"""Class describing Swing2Sleep Smarla switch entities."""
service: str
property: str
NUMBER_TYPES: list[SmarlaSwitchEntityDescription] = [
SmarlaSwitchEntityDescription(
key="cradle",
name=None,
service="babywiege",
property="swing_active",
),
SmarlaSwitchEntityDescription(
key="smartmode",
translation_key="smartmode",
service="babywiege",
property="smartmode",
),
]
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
entities: list[SmarlaSwitch] = []
for desc in NUMBER_TYPES:
entity = SmarlaSwitch(federwiege, desc)
entities.append(entity)
async_add_entities(entities)
class SmarlaSwitch(SmarlaBaseEntity, SwitchEntity):
"""Representation of Smarla switch."""
async def on_change(self, value):
"""Notify ha when state changes."""
self.async_write_ha_state()
def __init__(
self,
federwiege: Federwiege,
description: SmarlaSwitchEntityDescription,
) -> None:
"""Initialize a Smarla switch."""
super().__init__(federwiege)
self.property = federwiege.get_service(description.service).get_property(
description.property
)
self.entity_description = description
self._attr_should_poll = False
self._attr_unique_id = f"{federwiege.serial_number}-{description.key}"
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)
@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

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

View File

@@ -6022,6 +6022,12 @@
"config_flow": true, "config_flow": true,
"iot_class": "cloud_polling" "iot_class": "cloud_polling"
}, },
"smarla": {
"name": "Swing2Sleep Smarla",
"integration_type": "device",
"config_flow": true,
"iot_class": "cloud_push"
},
"smart_blinds": { "smart_blinds": {
"name": "Smartblinds", "name": "Smartblinds",
"integration_type": "virtual", "integration_type": "virtual",

3
requirements_all.txt generated
View File

@@ -2334,6 +2334,9 @@ pysma==0.7.5
# homeassistant.components.smappee # homeassistant.components.smappee
pysmappee==0.2.29 pysmappee==0.2.29
# homeassistant.components.smarla
pysmarlaapi==0.4.0
# homeassistant.components.smartthings # homeassistant.components.smartthings
pysmartthings==3.2.2 pysmartthings==3.2.2

View File

@@ -1907,6 +1907,9 @@ pysma==0.7.5
# homeassistant.components.smappee # homeassistant.components.smappee
pysmappee==0.2.29 pysmappee==0.2.29
# homeassistant.components.smarla
pysmarlaapi==0.4.0
# homeassistant.components.smartthings # homeassistant.components.smartthings
pysmartthings==3.2.2 pysmartthings==3.2.2

View File

@@ -0,0 +1 @@
"""Tests for the Smarla integration."""

View File

@@ -0,0 +1,86 @@
"""Test config flow for Swing2Sleep Smarla integration."""
from unittest.mock import AsyncMock, patch
from homeassistant.components.smarla.config_flow import Connection
from homeassistant.components.smarla.const import DOMAIN
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
MOCK_ACCESS_TOKEN = "eyJyZWZyZXNoVG9rZW4iOiJ0ZXN0IiwidG9rZW4iOiJ0ZXN0IiwiZGF0ZUNyZWF0ZWQiOiIyMDI1LTAxLTAxVDIzOjU5OjU5Ljk5OTk5OVoiLCJhcHBJZGVudGlmaWVyIjoiSEEtaG9tZWFzc2lzdGFudHRlc3QiLCJzZXJpYWxOdW1iZXIiOiJBQkNELUFCQ0QiLCJhcHBWZXJzaW9uIjoidW5rbm93biIsImFwcEN1bHR1cmUiOiJkZSJ9"
MOCK_ACCESS_TOKEN_JSON = '{"refreshToken": "test", "token": "test", "dateCreated": "2025-01-01T23:59:59.999999Z", "appIdentifier": "HA-homeassistanttest", "serialNumber": "ABCD-ABCD", "appVersion": "unknown", "appCulture": "de"}'
MOCK_SERIAL = "ABCD-ABCD"
async def test_show_form(hass: HomeAssistant) -> None:
"""Test that the form is shown initially."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "user"}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
async def test_create_entry(hass: HomeAssistant) -> None:
"""Test creating a config entry."""
with patch.object(Connection, "get_token", new=AsyncMock(return_value=True)):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": "user"},
data={CONF_ACCESS_TOKEN: MOCK_ACCESS_TOKEN},
)
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == MOCK_SERIAL
assert result["data"] == {CONF_ACCESS_TOKEN: MOCK_ACCESS_TOKEN_JSON}
async def test_invalid_auth(hass: HomeAssistant) -> None:
"""Test we show user form on invalid auth."""
with patch.object(Connection, "get_token", new=AsyncMock(return_value=None)):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": "user"},
data={CONF_ACCESS_TOKEN: MOCK_ACCESS_TOKEN},
)
assert result["type"] == FlowResultType.FORM
assert result["errors"] == {"base": "invalid_auth"}
async def test_invalid_token(hass: HomeAssistant) -> None:
"""Test we handle invalid/malformed tokens."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": "user"},
data={CONF_ACCESS_TOKEN: "invalid_token"},
)
assert result["type"] == FlowResultType.FORM
assert result["errors"] == {"base": "invalid_token"}
async def test_device_exists_abort(hass: HomeAssistant) -> None:
"""Test we abort config flow if Smarla device already configured."""
config_entry = MockConfigEntry(
domain=DOMAIN,
unique_id=MOCK_SERIAL,
source="user",
)
config_entry.add_to_hass(hass)
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": "user"},
data={CONF_ACCESS_TOKEN: MOCK_ACCESS_TOKEN},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert len(hass.config_entries.async_entries(DOMAIN)) == 1