From d19aabfcd0f4362d3633e6272dfa744969f5e33e Mon Sep 17 00:00:00 2001 From: Robin Lintermann Date: Mon, 14 Apr 2025 17:49:54 +0200 Subject: [PATCH] Added smarla integration --- CODEOWNERS | 2 + homeassistant/components/smarla/__init__.py | 91 +++++++++++++ .../components/smarla/config_flow.py | 69 ++++++++++ homeassistant/components/smarla/const.py | 7 + homeassistant/components/smarla/icons.json | 28 ++++ homeassistant/components/smarla/manifest.json | 13 ++ homeassistant/components/smarla/number.py | 93 ++++++++++++++ .../components/smarla/quality_scale.yaml | 60 +++++++++ homeassistant/components/smarla/sensor.py | 120 ++++++++++++++++++ homeassistant/components/smarla/strings.json | 47 +++++++ homeassistant/components/smarla/switch.py | 96 ++++++++++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/smarla/__init__.py | 1 + tests/components/smarla/test_config_flow.py | 86 +++++++++++++ 17 files changed, 726 insertions(+) create mode 100644 homeassistant/components/smarla/__init__.py create mode 100644 homeassistant/components/smarla/config_flow.py create mode 100644 homeassistant/components/smarla/const.py create mode 100644 homeassistant/components/smarla/icons.json create mode 100644 homeassistant/components/smarla/manifest.json create mode 100644 homeassistant/components/smarla/number.py create mode 100644 homeassistant/components/smarla/quality_scale.yaml create mode 100644 homeassistant/components/smarla/sensor.py create mode 100644 homeassistant/components/smarla/strings.json create mode 100644 homeassistant/components/smarla/switch.py create mode 100644 tests/components/smarla/__init__.py create mode 100644 tests/components/smarla/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index b80b9bc6591..ecac9a80a4c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1417,6 +1417,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 diff --git a/homeassistant/components/smarla/__init__.py b/homeassistant/components/smarla/__init__.py new file mode 100644 index 00000000000..137fcf62b82 --- /dev/null +++ b/homeassistant/components/smarla/__init__.py @@ -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, + ) diff --git a/homeassistant/components/smarla/config_flow.py b/homeassistant/components/smarla/config_flow.py new file mode 100644 index 00000000000..37eb3900945 --- /dev/null +++ b/homeassistant/components/smarla/config_flow.py @@ -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.""" diff --git a/homeassistant/components/smarla/const.py b/homeassistant/components/smarla/const.py new file mode 100644 index 00000000000..6890280291e --- /dev/null +++ b/homeassistant/components/smarla/const.py @@ -0,0 +1,7 @@ +"""Constants for the Swing2Sleep Smarla integration.""" + +DOMAIN = "smarla" + +HOST = "https://devices.swing2sleep.de" + +PLATFORMS = ["number", "sensor", "switch"] diff --git a/homeassistant/components/smarla/icons.json b/homeassistant/components/smarla/icons.json new file mode 100644 index 00000000000..cabe61dbc71 --- /dev/null +++ b/homeassistant/components/smarla/icons.json @@ -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" + } + } + } +} diff --git a/homeassistant/components/smarla/manifest.json b/homeassistant/components/smarla/manifest.json new file mode 100644 index 00000000000..30d0b3e350b --- /dev/null +++ b/homeassistant/components/smarla/manifest.json @@ -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"] +} diff --git a/homeassistant/components/smarla/number.py b/homeassistant/components/smarla/number.py new file mode 100644 index 00000000000..a177759f3bc --- /dev/null +++ b/homeassistant/components/smarla/number.py @@ -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)) diff --git a/homeassistant/components/smarla/quality_scale.yaml b/homeassistant/components/smarla/quality_scale.yaml new file mode 100644 index 00000000000..76b8d347408 --- /dev/null +++ b/homeassistant/components/smarla/quality_scale.yaml @@ -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 diff --git a/homeassistant/components/smarla/sensor.py b/homeassistant/components/smarla/sensor.py new file mode 100644 index 00000000000..35041748eec --- /dev/null +++ b/homeassistant/components/smarla/sensor.py @@ -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] + ) diff --git a/homeassistant/components/smarla/strings.json b/homeassistant/components/smarla/strings.json new file mode 100644 index 00000000000..3300ac952d5 --- /dev/null +++ b/homeassistant/components/smarla/strings.json @@ -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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smarla/switch.py b/homeassistant/components/smarla/switch.py new file mode 100644 index 00000000000..1397ee33581 --- /dev/null +++ b/homeassistant/components/smarla/switch.py @@ -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) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 43db3f5be10..3b823bd0795 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -577,6 +577,7 @@ FLOWS = { "slimproto", "sma", "smappee", + "smarla", "smart_meter_texas", "smartthings", "smarttub", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 9357424dc76..22ed1d65109 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6022,6 +6022,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", diff --git a/requirements_all.txt b/requirements_all.txt index dd938be0067..161362b9212 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2334,6 +2334,9 @@ pysma==0.7.5 # homeassistant.components.smappee pysmappee==0.2.29 +# homeassistant.components.smarla +pysmarlaapi==0.4.0 + # homeassistant.components.smartthings pysmartthings==3.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e99def6471e..804b035aed9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1907,6 +1907,9 @@ pysma==0.7.5 # homeassistant.components.smappee pysmappee==0.2.29 +# homeassistant.components.smarla +pysmarlaapi==0.4.0 + # homeassistant.components.smartthings pysmartthings==3.2.2 diff --git a/tests/components/smarla/__init__.py b/tests/components/smarla/__init__.py new file mode 100644 index 00000000000..afa9f035eb7 --- /dev/null +++ b/tests/components/smarla/__init__.py @@ -0,0 +1 @@ +"""Tests for the Smarla integration.""" diff --git a/tests/components/smarla/test_config_flow.py b/tests/components/smarla/test_config_flow.py new file mode 100644 index 00000000000..d92d7356a85 --- /dev/null +++ b/tests/components/smarla/test_config_flow.py @@ -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