Compare commits

...

3 Commits

Author SHA1 Message Date
Joost Lekkerkerker 7dfef5c82a Add icon translations to Electrolux (#170422) 2026-05-13 07:54:58 +02:00
Joostlek b75cd0f6a7 Merge branch 'dev' into electrolux 2026-05-12 16:50:46 +02:00
ferenc-fustos-electrolux 7859aba432 Add electrolux integration (#157176) 2026-05-12 12:40:40 +01:00
29 changed files with 4277 additions and 0 deletions
Generated
+2
View File
@@ -464,6 +464,8 @@ CLAUDE.md @home-assistant/core
/tests/components/electrasmart/ @jafar-atili
/homeassistant/components/electric_kiwi/ @mikey0000
/tests/components/electric_kiwi/ @mikey0000
/homeassistant/components/electrolux/ @electrolux-oss
/tests/components/electrolux/ @electrolux-oss
/homeassistant/components/elevenlabs/ @sorgfresser
/tests/components/elevenlabs/ @sorgfresser
/homeassistant/components/elgato/ @frenck
@@ -0,0 +1,220 @@
"""The Electrolux integration."""
from asyncio import CancelledError
from collections.abc import Awaitable, Callable
import logging
from electrolux_group_developer_sdk.auth.token_manager import TokenManager
from electrolux_group_developer_sdk.client.appliance_client import ApplianceClient
from electrolux_group_developer_sdk.client.appliances.appliance_data import (
ApplianceData,
)
from electrolux_group_developer_sdk.client.bad_credentials_exception import (
BadCredentialsException,
)
from electrolux_group_developer_sdk.client.client_exception import (
ApplianceClientException,
)
from electrolux_group_developer_sdk.client.failed_connection_exception import (
FailedConnectionException,
)
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import CONF_REFRESH_TOKEN, DOMAIN, NEW_APPLIANCE_SIGNAL, USER_AGENT
from .coordinator import (
ElectroluxConfigEntry,
ElectroluxData,
ElectroluxDataUpdateCoordinator,
)
_LOGGER: logging.Logger = logging.getLogger(__name__)
PLATFORMS = [
Platform.SENSOR,
]
async def async_setup_entry(hass: HomeAssistant, entry: ElectroluxConfigEntry) -> bool:
"""Set up Electrolux integration entry."""
token_manager = create_token_manager(hass, entry)
client = ApplianceClient(
token_manager=token_manager, external_user_agent=USER_AGENT
)
try:
await client.test_connection()
except BadCredentialsException as e:
raise ConfigEntryAuthFailed("Bad credentials detected.") from e
except FailedConnectionException as e:
raise ConfigEntryNotReady("Connection with client failed.") from e
try:
appliances = await fetch_appliance_data(client)
except ApplianceClientException as e:
raise ConfigEntryNotReady from e
coordinators: dict[str, ElectroluxDataUpdateCoordinator] = {}
on_livestream_opening_callback_list: list[Callable[[], Awaitable[None]]] = []
async def check_for_new_devices_callback() -> None:
"""Trigger _check_for_new_devices asynchronously."""
await _check_for_new_devices(
hass, entry, client, on_livestream_opening_callback_list
)
on_livestream_opening_callback_list.append(check_for_new_devices_callback)
for appliance in appliances:
appliance_id = appliance.appliance.applianceId
coordinator = ElectroluxDataUpdateCoordinator(
hass, entry, client=client, appliance_id=appliance_id
)
await coordinator.async_config_entry_first_refresh()
# Subscribe this coordinator to its appliance events
coordinator.add_client_listener()
coordinators[appliance_id] = coordinator
# Device state is refreshed whenever the SSE connection opens.
on_livestream_opening_callback_list.append(coordinator.async_refresh)
sse_task = entry.async_create_background_task(
hass,
client.start_event_stream(on_livestream_opening_callback_list),
"electrolux event listener",
)
entry.runtime_data = ElectroluxData(
client=client,
appliances=appliances,
coordinators=coordinators,
sse_task=sse_task,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ElectroluxConfigEntry) -> bool:
"""Unload a config entry."""
# Remove SSE listeners
coordinators = entry.runtime_data.coordinators
for coordinator in coordinators.values():
coordinator.remove_client_listeners()
# Cancel SSE task
sse_task = entry.runtime_data.sse_task
sse_task.cancel()
try:
await sse_task
except CancelledError:
_LOGGER.info("SSE stream cancelled for entry %s", entry.entry_id)
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
def create_token_manager(
hass: HomeAssistant,
entry: ElectroluxConfigEntry,
) -> TokenManager:
"""Create a token manager for the Electrolux integration."""
def save_tokens(new_access: str, new_refresh: str, new_api_key: str) -> None:
hass.config_entries.async_update_entry(
entry,
data={
**entry.data,
CONF_API_KEY: new_api_key,
CONF_ACCESS_TOKEN: new_access,
CONF_REFRESH_TOKEN: new_refresh,
},
)
api_key = entry.data.get(CONF_API_KEY)
refresh_token = entry.data.get(CONF_REFRESH_TOKEN)
access_token = entry.data.get(CONF_ACCESS_TOKEN)
if access_token and refresh_token and api_key:
return TokenManager(access_token, refresh_token, api_key, save_tokens)
raise ConfigEntryAuthFailed("Missing access token, refresh token or API key")
async def _check_for_new_devices(
hass: HomeAssistant,
entry: ElectroluxConfigEntry,
client: ApplianceClient,
on_livestream_opening_callback_list: list[Callable[[], Awaitable[None]]],
) -> None:
"""Fetch appliances from API and trigger discovery for any new ones."""
_LOGGER.info("Checking for new devices")
coordinators = entry.runtime_data.coordinators
appliances = await fetch_appliance_data(client)
entry.runtime_data.appliances = appliances
existing_ids = set(coordinators.keys())
for appliance in appliances:
appliance_id = appliance.appliance.applianceId
# Detect NEW appliances
if appliance_id not in existing_ids:
# Create coordinator for appliance
coordinator = ElectroluxDataUpdateCoordinator(
hass, entry, client=client, appliance_id=appliance_id
)
await coordinator.async_refresh()
coordinator.add_client_listener()
coordinators[appliance_id] = coordinator
on_livestream_opening_callback_list.append(coordinator.async_refresh)
# Notify all platforms
async_dispatcher_send(
hass, f"{NEW_APPLIANCE_SIGNAL}_{entry.entry_id}", appliance
)
# Detect MISSING appliances
discovered_ids = {appliance.appliance.applianceId for appliance in appliances}
missing_ids = existing_ids - discovered_ids
device_registry = dr.async_get(hass)
for missing_id in missing_ids:
_LOGGER.warning("Appliance %s no longer found, removing", missing_id)
# Remove coordinator
coordinator = coordinators.pop(missing_id)
coordinator.remove_client_listeners()
on_livestream_opening_callback_list.remove(coordinator.async_refresh)
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, missing_id)}
)
if device_entry:
device_registry.async_update_device(
device_entry.id, remove_config_entry_id=entry.entry_id
)
async def fetch_appliance_data(client: ApplianceClient) -> list[ApplianceData]:
"""Helper method to retrieve all the appliances data from the Electrolux APIs."""
try:
appliances = await client.get_appliance_data()
except ApplianceClientException as e:
_LOGGER.warning("Failed to get appliances: %s", e)
raise
# Filter out appliances where details or state is None
return [
appliance
for appliance in appliances
if appliance.details is not None and appliance.state is not None
]
@@ -0,0 +1,99 @@
"""Config flow for Electrolux integration."""
from collections.abc import Mapping
import logging
from typing import Any
from electrolux_group_developer_sdk.auth.invalid_credentials_exception import (
InvalidCredentialsException,
)
from electrolux_group_developer_sdk.auth.token_manager import TokenManager
from electrolux_group_developer_sdk.client.appliance_client import ApplianceClient
from electrolux_group_developer_sdk.client.bad_credentials_exception import (
BadCredentialsException,
)
from electrolux_group_developer_sdk.client.failed_connection_exception import (
FailedConnectionException,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_API_KEY
from .const import CONF_REFRESH_TOKEN, DOMAIN, USER_AGENT
_LOGGER: logging.Logger = logging.getLogger(__name__)
class ElectroluxConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for the Electrolux integration."""
VERSION = 1
MINOR_VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step of the config flow."""
errors: dict[str, str] = {}
if user_input is not None:
token_manager: TokenManager
email: str
try:
token_manager = await _authenticate_user(user_input)
client = ApplianceClient(
token_manager=token_manager, external_user_agent=USER_AGENT
)
email = (await client.get_user_email()).email
except InvalidCredentialsException, BadCredentialsException:
errors["base"] = "invalid_auth"
except FailedConnectionException:
errors["base"] = "cannot_connect"
else:
await self.async_set_unique_id(token_manager.get_user_id())
self._abort_if_unique_id_configured()
if not errors:
return self.async_create_entry(
title=f"Electrolux for {email}",
data=user_input,
)
return self._show_form(step_id="user", errors=errors)
def _show_form(self, step_id: str, errors: dict[str, str]) -> ConfigFlowResult:
return self.async_show_form(
step_id=step_id,
data_schema=vol.Schema(
{
vol.Required(CONF_API_KEY): str,
vol.Required(CONF_ACCESS_TOKEN): str,
vol.Required(CONF_REFRESH_TOKEN): str,
}
),
errors=errors,
description_placeholders={
"portal_link": "https://developer.electrolux.one/generateToken"
},
)
async def _authenticate_user(user_input: Mapping[str, Any]) -> TokenManager:
token_manager = TokenManager(
access_token=user_input[CONF_ACCESS_TOKEN],
refresh_token=user_input[CONF_REFRESH_TOKEN],
api_key=user_input[CONF_API_KEY],
)
token_manager.ensure_credentials()
appliance_client = ApplianceClient(
token_manager=token_manager, external_user_agent=USER_AGENT
)
# Test a connection in the config flow
await appliance_client.test_connection()
return token_manager
@@ -0,0 +1,11 @@
"""Constants for Electrolux integration."""
from homeassistant.const import __version__ as HA_VERSION
DOMAIN = "electrolux"
CONF_REFRESH_TOKEN = "refresh_token"
NEW_APPLIANCE_SIGNAL = "electrolux_new_appliance"
USER_AGENT = f"HomeAssistant/{HA_VERSION}"
@@ -0,0 +1,96 @@
"""Electrolux coordinator class."""
from __future__ import annotations
from asyncio import Task
from dataclasses import dataclass
import logging
from electrolux_group_developer_sdk.client.appliance_client import (
ApplianceClient,
apply_sse_update,
)
from electrolux_group_developer_sdk.client.appliances.appliance_data import (
ApplianceData,
)
from electrolux_group_developer_sdk.client.client_exception import (
ApplianceClientException,
)
from electrolux_group_developer_sdk.client.dto.appliance_state import ApplianceState
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER: logging.Logger = logging.getLogger(__name__)
@dataclass(kw_only=True, slots=True)
class ElectroluxData:
"""Electrolux data type."""
client: ApplianceClient
appliances: list[ApplianceData]
coordinators: dict[str, ElectroluxDataUpdateCoordinator]
sse_task: Task
type ElectroluxConfigEntry = ConfigEntry[ElectroluxData]
class ElectroluxDataUpdateCoordinator(DataUpdateCoordinator[ApplianceState]):
"""Class for fetching appliance data from the API."""
def __init__(
self,
hass: HomeAssistant,
config_entry: ElectroluxConfigEntry,
client: ApplianceClient,
appliance_id: str,
) -> None:
"""Initialize."""
self.client = client
self._appliance_id = appliance_id
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=f"{DOMAIN}_{config_entry.entry_id}_{appliance_id}",
update_interval=None,
always_update=False,
)
async def _async_update_data(self) -> ApplianceState:
"""Return the current appliance state (SSE keeps it updated)."""
try:
appliance_state = await self.client.get_appliance_state(self._appliance_id)
except ValueError as exception:
raise UpdateFailed(exception) from exception
except ApplianceClientException as exception:
raise UpdateFailed(exception) from exception
else:
return appliance_state
def add_client_listener(self) -> None:
"""Register an SSE listener to the appliance client for appliance state updates."""
self.client.add_listener(self._appliance_id, self.callback_handle_event)
def remove_client_listeners(self) -> None:
"""Remove all SSE listeners."""
self.client.remove_all_listeners_by_appliance_id(self._appliance_id)
def callback_handle_event(self, event: dict) -> None:
"""Handle an incoming SSE event. Event will look like: {"userId": "...", "applianceId": "...", "property": "timeToEnd", "value": 720}."""
current_state = self.data
if not current_state:
return
updated_state = apply_sse_update(
current_state,
event,
)
self.async_set_updated_data(updated_state)
@@ -0,0 +1,80 @@
"""Base entity for Electrolux integration."""
from abc import abstractmethod
import logging
from typing import TYPE_CHECKING
from electrolux_group_developer_sdk.client.appliances.appliance_data import (
ApplianceData,
)
from homeassistant.core import callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import ElectroluxDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
class ElectroluxBaseEntity[T: ApplianceData](
CoordinatorEntity[ElectroluxDataUpdateCoordinator]
):
"""Base class for Electrolux entities."""
_attr_has_entity_name = True
def __init__(
self,
appliance_data: T,
coordinator: ElectroluxDataUpdateCoordinator,
unique_id_suffix: str,
) -> None:
"""Initialize the base device."""
super().__init__(coordinator)
appliance_name = appliance_data.appliance.applianceName
appliance_id = appliance_data.appliance.applianceId
if TYPE_CHECKING:
assert appliance_data.details
assert appliance_data.state
appliance_info = appliance_data.details.applianceInfo
self._appliance_data = appliance_data
self._attr_unique_id = f"{appliance_id}_{unique_id_suffix}"
self._appliance_id = appliance_id
self._appliance_capabilities = appliance_data.details.capabilities
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, appliance_id)},
name=appliance_name,
manufacturer=appliance_info.brand,
model=appliance_info.model,
serial_number=appliance_info.serialNumber,
)
async def async_added_to_hass(self) -> None:
"""When entity is added to HA."""
await super().async_added_to_hass()
self._handle_coordinator_update()
@abstractmethod
def _update_attr_state(self) -> bool:
"""Update entity-specific attributes. Returns True if any attributes were changed, otherwise False."""
@callback
def _handle_coordinator_update(self) -> None:
"""When the coordinator updates."""
appliance_state = self.coordinator.data
if not appliance_state:
_LOGGER.warning("Appliance %s not found in update", self._appliance_id)
return
# Update state
self._appliance_data.update_state(appliance_state)
state_changed = self._update_attr_state()
if state_changed:
self.async_write_ha_state()
@@ -0,0 +1,49 @@
"""Contains entity helper methods."""
from collections.abc import Callable
from electrolux_group_developer_sdk.client.appliances.appliance_data import (
ApplianceData,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import NEW_APPLIANCE_SIGNAL
from .coordinator import ElectroluxConfigEntry, ElectroluxDataUpdateCoordinator
from .entity import ElectroluxBaseEntity
async def async_setup_entities_helper(
hass: HomeAssistant,
entry: ElectroluxConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
build_entities_fn: Callable[
[ApplianceData, dict[str, ElectroluxDataUpdateCoordinator]],
list[ElectroluxBaseEntity],
],
) -> None:
"""Provide async_setup_entry helper."""
appliances: list[ApplianceData] = entry.runtime_data.appliances
coordinators = entry.runtime_data.coordinators
entities: list[ElectroluxBaseEntity] = []
for appliance_data in appliances:
entities.extend(build_entities_fn(appliance_data, coordinators))
async_add_entities(entities)
# Listen for new/removed appliances
async def _new_appliance(appliance_data: ApplianceData):
new_entities = build_entities_fn(appliance_data, coordinators)
if new_entities:
async_add_entities(new_entities)
entry.async_on_unload(
async_dispatcher_connect(
hass, f"{NEW_APPLIANCE_SIGNAL}_{entry.entry_id}", _new_appliance
)
)
@@ -0,0 +1,18 @@
{
"entity": {
"sensor": {
"appliance_state": {
"default": "mdi:information-outline"
},
"food_probe_state": {
"default": "mdi:thermometer-probe"
},
"food_probe_temperature": {
"default": "mdi:thermometer-probe"
},
"remote_control": {
"default": "mdi:remote"
}
}
}
}
@@ -0,0 +1,11 @@
{
"domain": "electrolux",
"name": "Electrolux",
"codeowners": ["@electrolux-oss"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/electrolux",
"integration_type": "hub",
"iot_class": "cloud_push",
"quality_scale": "bronze",
"requirements": ["electrolux-group-developer-sdk==0.5.0"]
}
@@ -0,0 +1,70 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
No actions are implemented currently.
appropriate-polling:
status: exempt
comment: |
Polling is only performed on infrequent events (when the livestream of events is opened, in order to sync),
otherwise the integration works via push
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
No actions are implemented currently.
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: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: todo
integration-owner: done
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: done
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: todo
strict-typing: todo
@@ -0,0 +1,290 @@
"""Sensor entity for Electrolux Integration."""
from collections.abc import Callable
from dataclasses import dataclass
import logging
from typing import cast
from electrolux_group_developer_sdk.client.appliances.appliance_data import (
ApplianceData,
)
from electrolux_group_developer_sdk.client.appliances.cr_appliance import CRAppliance
from electrolux_group_developer_sdk.client.appliances.ov_appliance import OVAppliance
from electrolux_group_developer_sdk.feature_constants import (
APPLIANCE_STATE,
DISPLAY_FOOD_PROBE_TEMPERATURE_C,
DISPLAY_FOOD_PROBE_TEMPERATURE_F,
DISPLAY_TEMPERATURE_C,
DISPLAY_TEMPERATURE_F,
FOOD_PROBE_STATE,
REMOTE_CONTROL,
)
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
StateType,
)
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.unit_conversion import TemperatureConverter
from .coordinator import ElectroluxConfigEntry, ElectroluxDataUpdateCoordinator
from .entity import ElectroluxBaseEntity
from .entity_helper import async_setup_entities_helper
_LOGGER = logging.getLogger(__name__)
ELECTROLUX_TO_HA_TEMPERATURE_UNIT = {
"CELSIUS": UnitOfTemperature.CELSIUS,
"FAHRENHEIT": UnitOfTemperature.FAHRENHEIT,
}
@dataclass(frozen=True, kw_only=True)
class ElectroluxSensorDescription(SensorEntityDescription):
"""Custom sensor description for Electrolux sensors."""
value_fn: Callable[..., StateType]
exists_fn: Callable[[ApplianceData], bool] = lambda *args: True
feature_name: str | None = None
known_values: set[str] | None = None
OVEN_ELECTROLUX_SENSORS: tuple[ElectroluxSensorDescription, ...] = (
ElectroluxSensorDescription(
key="appliance_state",
translation_key="appliance_state",
value_fn=lambda appliance: appliance.get_current_appliance_state(),
device_class=SensorDeviceClass.ENUM,
feature_name=APPLIANCE_STATE,
exists_fn=lambda appliance: appliance.is_feature_supported(APPLIANCE_STATE),
known_values={
"alarm",
"delayed_start",
"end_of_cycle",
"idle",
"off",
"paused",
"ready_to_start",
"running",
},
),
ElectroluxSensorDescription(
key="food_probe_state",
translation_key="food_probe_state",
value_fn=lambda appliance: appliance.get_current_food_probe_insertion_state(),
device_class=SensorDeviceClass.ENUM,
feature_name=FOOD_PROBE_STATE,
exists_fn=lambda appliance: appliance.is_feature_supported(FOOD_PROBE_STATE),
known_values={
"inserted",
"not_inserted",
},
),
ElectroluxSensorDescription(
key="remote_control",
translation_key="remote_control",
value_fn=lambda appliance: appliance.get_current_remote_control(),
device_class=SensorDeviceClass.ENUM,
feature_name=REMOTE_CONTROL,
exists_fn=lambda appliance: appliance.is_feature_supported(REMOTE_CONTROL),
known_values={
"disabled",
"enabled",
"not_safety_relevant_enabled",
"temporary_locked",
},
),
)
OVEN_TEMPERATURE_ELECTROLUX_SENSORS: tuple[ElectroluxSensorDescription, ...] = (
ElectroluxSensorDescription(
key="food_probe_temperature",
translation_key="food_probe_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda appliance, temp_unit=None: (
appliance.get_current_display_food_probe_temperature_f()
if temp_unit == UnitOfTemperature.FAHRENHEIT
else appliance.get_current_display_food_probe_temperature_c()
),
exists_fn=lambda appliance: appliance.is_feature_supported(
[DISPLAY_FOOD_PROBE_TEMPERATURE_F, DISPLAY_FOOD_PROBE_TEMPERATURE_C]
),
),
ElectroluxSensorDescription(
key="display_temperature",
translation_key="display_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda appliance, temp_unit=None: (
appliance.get_current_display_temperature_f()
if temp_unit == UnitOfTemperature.FAHRENHEIT
else appliance.get_current_display_temperature_c()
),
exists_fn=lambda appliance: appliance.is_feature_supported(
[DISPLAY_TEMPERATURE_C, DISPLAY_TEMPERATURE_F]
),
),
)
def build_entities_for_appliance(
appliance_data: ApplianceData,
coordinators: dict[str, ElectroluxDataUpdateCoordinator],
) -> list[ElectroluxBaseEntity]:
"""Return all entities for a single appliance."""
appliance = appliance_data.appliance
coordinator = coordinators[appliance.applianceId]
entities: list[ElectroluxBaseEntity] = []
if isinstance(appliance_data, OVAppliance):
entities.extend(
ElectroluxSensor(appliance_data, coordinator, description)
for description in OVEN_ELECTROLUX_SENSORS
if description.exists_fn(appliance_data)
)
entities.extend(
ElectroluxTemperatureSensor(appliance_data, coordinator, description)
for description in OVEN_TEMPERATURE_ELECTROLUX_SENSORS
if description.exists_fn(appliance_data)
)
return entities
async def async_setup_entry(
hass: HomeAssistant,
entry: ElectroluxConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set sensor for Electrolux Integration."""
await async_setup_entities_helper(
hass, entry, async_add_entities, build_entities_for_appliance
)
class ElectroluxSensor(ElectroluxBaseEntity[ApplianceData], SensorEntity):
"""Representation of a generic sensor for Electrolux appliances."""
entity_description: ElectroluxSensorDescription
def __init__(
self,
appliance_data: ApplianceData,
coordinator: ElectroluxDataUpdateCoordinator,
description: ElectroluxSensorDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(appliance_data, coordinator, description.key)
if (
description.feature_name is not None
and description.known_values is not None
):
options = appliance_data.get_feature_state_string_options(
description.feature_name
)
snake_case_options = [
snake_case_option
for option in options
if (snake_case_option := _convert_to_snake_case(option))
in description.known_values
]
if len(snake_case_options) > 0:
self._attr_options = snake_case_options
self.entity_description = description
def _update_attr_state(self) -> bool:
new_value = self._get_value()
if isinstance(new_value, str):
new_value = _convert_to_snake_case(new_value)
if self.entity_description.known_values:
new_value = _map_to_known_value(
self.entity_description.known_values,
self.entity_description.key,
new_value,
)
if self._attr_native_value != new_value:
self._attr_native_value = new_value
return True
return False
def _get_value(self) -> StateType:
return self.entity_description.value_fn(self._appliance_data)
class ElectroluxTemperatureSensor(ElectroluxSensor):
"""Representation of a temperature sensor for Electrolux appliances."""
def __init__(
self,
appliance_data: ApplianceData,
coordinator: ElectroluxDataUpdateCoordinator,
description: ElectroluxSensorDescription,
) -> None:
"""Initialize the sensor."""
self._appliance = cast(OVAppliance | CRAppliance, appliance_data)
self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
super().__init__(appliance_data, coordinator, description)
def _get_value(self) -> StateType:
temp_unit = self._get_temperature_unit()
temp_value: float | None = cast(
float | None,
self.entity_description.value_fn(self._appliance_data, temp_unit=temp_unit),
)
if temp_value is None:
return None
return TemperatureConverter.convert(
temp_value, temp_unit, UnitOfTemperature.CELSIUS
)
def _get_temperature_unit(self) -> UnitOfTemperature:
temp_unit = self._appliance.get_current_temperature_unit()
if temp_unit is not None:
temp_unit = temp_unit.upper()
return ELECTROLUX_TO_HA_TEMPERATURE_UNIT.get(
temp_unit, UnitOfTemperature.CELSIUS
)
def _convert_to_snake_case(x: str) -> str:
"""Converts a string to snake case."""
lower_case = x.lower()
return "".join([_convert_char_to_snake_case(char) for char in lower_case])
def _convert_char_to_snake_case(char: str) -> str:
if char.isspace():
return "_"
return char
def _map_to_known_value(
known_values: set[str], entity_key: str, value: str
) -> str | None:
"""Return provided value if it is known, otherwise log warn message and return None."""
if value not in known_values:
_LOGGER.warning(
"An unknown value %s was reported for a sensor of the Electrolux integration. "
"Please report it for the integration, and include the following information: "
'entity key="%s", reported value="%s"',
value,
entity_key,
value,
)
return None
return value
@@ -0,0 +1,66 @@
{
"config": {
"abort": {
"already_configured": "This Electrolux account is already configured."
},
"error": {
"cannot_connect": "Unable to connect to the Electrolux API. Please check credentials and try again.",
"invalid_auth": "Authentication failed. Please check your credentials."
},
"step": {
"user": {
"data": {
"access_token": "[%key:common::config_flow::data::access_token%]",
"api_key": "[%key:common::config_flow::data::api_key%]",
"refresh_token": "Refresh token"
},
"data_description": {
"access_token": "The access token from Electrolux Group for Developer.",
"api_key": "Your Electrolux Group for Developer API key.",
"refresh_token": "The refresh token used to renew your access token."
},
"description": "Please go to the [developer portal]({portal_link}) to generate new access and refresh tokens, then paste them below.",
"title": "Configure your Electrolux Group account"
}
}
},
"entity": {
"sensor": {
"appliance_state": {
"name": "Appliance state",
"state": {
"alarm": "Alarm",
"delayed_start": "Delayed start",
"end_of_cycle": "Cycle ended",
"idle": "[%key:common::state::idle%]",
"off": "[%key:common::state::off%]",
"paused": "[%key:common::state::paused%]",
"ready_to_start": "Ready to start",
"running": "Running"
}
},
"display_temperature": {
"name": "Current temperature"
},
"food_probe_state": {
"name": "Food probe state",
"state": {
"inserted": "Inserted",
"not_inserted": "Not inserted"
}
},
"food_probe_temperature": {
"name": "Food probe temperature"
},
"remote_control": {
"name": "Remote control",
"state": {
"disabled": "[%key:common::state::disabled%]",
"enabled": "[%key:common::state::enabled%]",
"not_safety_relevant_enabled": "Not safety relevant enabled",
"temporary_locked": "Temporarily locked"
}
}
}
}
}
+1
View File
@@ -191,6 +191,7 @@ FLOWS = {
"ekeybionyx",
"electrasmart",
"electric_kiwi",
"electrolux",
"elevenlabs",
"elgato",
"elkm1",
@@ -1702,6 +1702,12 @@
"config_flow": true,
"iot_class": "cloud_polling"
},
"electrolux": {
"name": "Electrolux",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_push"
},
"elevenlabs": {
"name": "ElevenLabs",
"integration_type": "service",
+3
View File
@@ -891,6 +891,9 @@ ekey-bionyxpy==1.0.1
# homeassistant.components.electric_kiwi
electrickiwi-api==0.9.14
# homeassistant.components.electrolux
electrolux-group-developer-sdk==0.5.0
# homeassistant.components.elevenlabs
elevenlabs==2.3.0
+3
View File
@@ -797,6 +797,9 @@ ekey-bionyxpy==1.0.1
# homeassistant.components.electric_kiwi
electrickiwi-api==0.9.14
# homeassistant.components.electrolux
electrolux-group-developer-sdk==0.5.0
# homeassistant.components.elevenlabs
elevenlabs==2.3.0
+52
View File
@@ -0,0 +1,52 @@
"""Tests for the electrolux integration."""
from functools import cache
from electrolux_group_developer_sdk.client.dto.appliance import Appliance
from electrolux_group_developer_sdk.client.dto.appliance_details import ApplianceDetails
from electrolux_group_developer_sdk.client.dto.appliance_state import ApplianceState
from homeassistant.components.electrolux.const import DOMAIN
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry, load_fixture
APPLIANCE_FIXTURES = ["fenix_oven", "pux_oven"]
async def setup_integration(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Set up Electrolux integration for tests."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
@cache
def get_fixture_name(appliance_id: str) -> str:
"""Get the fixture name for the given appliance ID."""
for name in APPLIANCE_FIXTURES:
if load_appliance(name).applianceId == appliance_id:
return name
raise KeyError(f"Fixture name for appliance ID {appliance_id} does not exist")
def load_appliance(appliance_name: str) -> Appliance:
"""Load an Appliance object from a fixture for the given appliance name."""
json_string = load_fixture(f"appliances/{appliance_name}.json", DOMAIN)
return Appliance.model_validate_json(json_string)
def load_appliance_details(appliance_name: str) -> ApplianceDetails:
"""Load an ApplianceDetails object from a fixture for the given appliance name."""
json_string = load_fixture(f"appliance_details/{appliance_name}.json", DOMAIN)
return ApplianceDetails.model_validate_json(json_string)
def load_appliance_state(appliance_name: str) -> ApplianceState:
"""Load an ApplianceState object from a fixture for the given appliance name."""
json_string = load_fixture(f"appliance_states/{appliance_name}.json", DOMAIN)
return ApplianceState.model_validate_json(json_string)
+140
View File
@@ -0,0 +1,140 @@
"""Common fixtures for the electrolux tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
from electrolux_group_developer_sdk.client.appliance_data_factory import (
appliance_data_factory,
)
from electrolux_group_developer_sdk.client.dto.appliance_state import ApplianceState
from electrolux_group_developer_sdk.client.dto.email import Email
import pytest
from homeassistant.components.electrolux.const import CONF_REFRESH_TOKEN, DOMAIN
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_API_KEY
from homeassistant.core import HomeAssistant
from . import (
APPLIANCE_FIXTURES,
get_fixture_name,
load_appliance,
load_appliance_details,
load_appliance_state,
setup_integration,
)
from tests.common import MockConfigEntry
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.electrolux.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
async def init_integration(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Set up Electrolux integration for tests."""
await setup_integration(hass, mock_config_entry)
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Create mocked config entry."""
return MockConfigEntry(
domain=DOMAIN,
title="Electrolux",
unique_id="mock_user_id",
data={
CONF_API_KEY: "mock_api_key",
CONF_ACCESS_TOKEN: "mock_access_token",
CONF_REFRESH_TOKEN: "mock_refresh_token",
},
)
@pytest.fixture
def mock_appliance_client() -> Generator[AsyncMock]:
"""Mock the Electrolux Group Developer SDK client."""
with (
patch(
"homeassistant.components.electrolux.ApplianceClient",
autospec=True,
) as mock_client,
patch(
"homeassistant.components.electrolux.config_flow.ApplianceClient",
new=mock_client,
),
):
client = mock_client.return_value
def get_appliance_state(appliance_id: str) -> ApplianceState | None:
return load_appliance_state(get_fixture_name(appliance_id))
client.get_appliance_state.side_effect = get_appliance_state
client.get_user_email.return_value = Email(email="mock@email.com")
yield client
@pytest.fixture
def mock_token_manager() -> Generator[AsyncMock]:
"""Mock the Electrolux Group Developer SDK token manager."""
with (
patch(
"homeassistant.components.electrolux.TokenManager",
autospec=True,
) as mock_token_manager,
patch(
"homeassistant.components.electrolux.config_flow.TokenManager",
new=mock_token_manager,
),
):
token_manager = mock_token_manager.return_value
token_manager.ensure_credentials.return_value = None
token_manager.get_user_id.return_value = "mock_user_id"
yield token_manager
@pytest.fixture
def appliance_fixture() -> str | None:
"""Return the appliance fixture that should be loaded, or None if all appliances should be loaded."""
return None
@pytest.fixture
def appliances(
mock_appliance_client: AsyncMock, appliance_fixture: str | None
) -> AsyncMock:
"""Mock the list of appliances."""
appliance_names = []
if appliance_fixture is not None:
appliance_names.append(appliance_fixture)
else:
appliance_names.extend(APPLIANCE_FIXTURES)
appliance_data_list = []
for appliance_name in appliance_names:
appliance = load_appliance(appliance_name)
details = load_appliance_details(appliance_name)
state = load_appliance_state(appliance_name)
appliance_data = appliance_data_factory(
appliance=appliance,
details=details,
state=state,
)
appliance_data_list.append(appliance_data)
mock_appliance_client.get_appliance_data.return_value = appliance_data_list
return mock_appliance_client
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,874 @@
{
"applianceInfo": {
"serialNumber": "11112225",
"pnc": "949288049",
"brand": "AEG",
"deviceType": "BUILT-IN OVEN",
"model": "NBX7P631SB",
"variant": "PIZZA-HOTAIRFAN+RING",
"colour": "BLACK"
},
"capabilities": {
"alerts": {
"access": "read",
"type": "alert",
"values": {
"AD_CONVERTER_REFERENCE_ALARM": {},
"BACKLIGHT_ALARM": {},
"BOARD_TEMPERATURE_ALARM": {},
"COMMUNICATION_ALARM_BETWEEN_TWO_CONTROLLERS": {},
"COMMUNICATION_ALARM_OUI_OC": {},
"CONFIGURATION_CHECKSUM_ALARM": {},
"CONFIGURATION_COHERENT_ALARM": {},
"CONFIGURATION_COMPATIBILITY_ALARM": {},
"COOKING_FAN_CONFIG_ALARM": {},
"DATA_FLASH_ALARM_UI": {},
"DOOR_LOCK_ACTUATOR": {},
"DOOR_LOCK_CONFIGURATION_ALARM": {},
"DOOR_LOCK_SENSOR_ALARM": {},
"ELECTRONIC_CLIXON_ALARM": {},
"FIX_SENSOR_DETECTION_ALARM": {},
"FOOD_PROBE_COMMUNICATION_ALARM": {},
"FOOD_PROBE_CONFIGURATION_ALARM": {},
"FUNCTION_SELECTOR_NOT_CONNECTED": {},
"HMI-TOUCH_BOARD_FMEA_ALARM": {},
"HMI-TOUCH_BOARD_SERIAL_COMMUNICATION_ALARM": {},
"HOB_OVEN_COMMUNICATION_ALARM": {},
"HOB_OVEN_POWER_MANAGEMENT_ALARM": {},
"HUMIDITY_SENSOR_OUT_OF_RANGE_ALARM": {},
"INTERNAL_ERROR": {},
"LIB_FMEA_ALARM_ROTARY_GRAB": {},
"MACS_COMMNUNICATION_ERROR": {},
"MEAT_PROBE_OUT_OF_RANGE_ALARM": {},
"NETVM_COMMUNICATION_ALARM": {},
"NIUX_COMMUNICATION_ALARM": {},
"NIUX_ONBOARDING_FAILED_ALARM": {},
"NTC_OUT_OF_RANGE_ALARM": {},
"OTA_FAILURE": {},
"PERIPHERAL_INIT_ALARM_ROTARY_SERIAL_COMMUNICATION_ALARM": {},
"PERIPHERAL_RUNTIME_ALARM_ROTARY_BIT_ENCODER_ALARM": {},
"POWER_ALARM": {},
"PT500_OUT_OF_RANGE_ALARM": {},
"PT500_STEAM_OUT_OF_RANGE_ALARM": {},
"PTO_COMMUNICATION_ALARM_OUI_PTO": {},
"PTO_CONFIGURATION_CHECKSUM_ALARM": {},
"PTO_KEY_ALARM": {},
"PTO_LIB_FMEA_ALARM": {},
"PYR_HOB_ALARM": {},
"ROTARY_TOUCH_ALARM": {},
"RTC_ALARM": {},
"RTC_OUT_OF_RANGE_ALARM": {},
"SMART_AD_CALIBRATION_RUNNING_ERROR": {},
"SMART_ERROR_UNKNOWN": {},
"SMART_INVALID_CONFIGURATION_ERROR": {},
"SMART_NO_START_TEMPERATURE": {},
"SMART_READ_FLASH_ERROR": {},
"SOFTWARE_COMPATIBILITY_CODE_ALARM": {},
"STEAM_MAGNETRON_NTC": {},
"TOO_HIGH_TEMPERATURE_ALARM": {},
"TOUCHSCREEN_DRIVER_ALARM": {},
"TOUCH_KEY_1_ALARM": {},
"TOUCH_KEY_ALARM": {},
"TRIAC_ALARM": {},
"UNKNOWN_STATE_ERROR": {},
"USB_CAMERA_DISCONNECTED_ALARM": {},
"WATER_LEVEL_SENSOR_IN_STEAMER_OUT_OF_RANGE_ALARM": {},
"WATER_LEVEL_SENSOR_IN_STEAM_TANK_DRAWER_OUT_OF_RANGE_ALARM": {},
"WIFI_SIGNAL_MISS_ALARM": {}
}
},
"applianceState": {
"access": "read",
"triggers": [
{
"action": {
"executeCommand": {
"access": "write",
"values": {
"STOPRESET": {}
}
}
},
"condition": {
"operand_1": "value",
"operand_2": "RUNNING",
"operator": "eq"
}
},
{
"action": {
"executeCommand": {
"access": "write",
"values": {
"STOPRESET": {}
}
}
},
"condition": {
"operand_1": "value",
"operand_2": "PAUSED",
"operator": "eq"
}
},
{
"action": {
"executeCommand": {
"access": "write",
"values": {
"START": {}
}
}
},
"condition": {
"operand_1": "value",
"operand_2": "READY_TO_START",
"operator": "eq"
}
},
{
"action": {
"$self": {
"access": "read"
}
},
"condition": {
"operand_1": "value",
"operand_2": "ALARM",
"operator": "eq"
}
},
{
"action": {
"executeCommand": {
"access": "write",
"values": {
"STOPRESET": {}
}
}
},
"condition": {
"operand_1": "value",
"operand_2": "DELAYED_START",
"operator": "eq"
}
},
{
"action": {
"executeCommand": {
"access": "write",
"values": {
"START": {}
}
}
},
"condition": {
"operand_1": "value",
"operand_2": "END_OF_CYCLE",
"operator": "eq"
}
}
],
"type": "string",
"values": {
"ALARM": {},
"DELAYED_START": {},
"END_OF_CYCLE": {},
"IDLE": {},
"OFF": {},
"PAUSED": {},
"READY_TO_START": {},
"RUNNING": {}
}
},
"cavityLight": {
"access": "readwrite",
"type": "boolean",
"values": {
"OFF": {},
"ON": {}
}
},
"displayTemperatureC": {
"access": "read",
"type": "temperature"
},
"doorState": {
"access": "read",
"type": "string",
"values": {
"CLOSED": {},
"OPEN": {}
}
},
"executeCommand": {
"access": "write",
"type": "string",
"values": {
"START": {},
"STOPRESET": {}
}
},
"hideExecuteCommand": {
"access": "constant",
"default": 0,
"triggers": [
{
"action": {
"executeCommand": {
"disabled": false
}
},
"condition": {
"operand_1": "value",
"operand_2": 0,
"operator": "eq"
}
},
{
"action": {
"executeCommand": {
"disabled": true
}
},
"condition": {
"operand_1": "value",
"operand_2": 1,
"operator": "eq"
}
}
],
"type": "int"
},
"keyModel": {
"access": "constant",
"default": "PUX_SP_PYR_PE_A++",
"type": "string"
},
"networkInterface": {
"command": {
"access": "write",
"type": "string",
"values": {
"ABORT": {},
"APPLIANCE_AUTHORIZE": {},
"DOWNLOAD": {},
"START": {},
"USER_AUTHORIZE": {},
"USER_NOT_AUTHORIZE": {}
}
},
"linkQualityIndicator": {
"access": "read",
"type": "string",
"values": {
"EXCELLENT": {},
"GOOD": {},
"POOR": {},
"UNDEFINED": {},
"VERY_GOOD": {},
"VERY_POOR": {}
}
},
"niuSwUpdateCurrentDescription": {
"access": "read",
"type": "string"
},
"otaState": {
"access": "read",
"type": "string",
"values": {
"DESCRIPTION_AVAILABLE": {},
"DESCRIPTION_DOWNLOADING": {},
"DESCRIPTION_READY": {},
"FW_DOWNLOADING": {},
"FW_DOWNLOAD_START": {},
"FW_SIGNATURE_CHECK": {},
"FW_UPDATE_IN_PROGRESS": {},
"IDLE": {},
"READY_TO_UPDATE": {},
"UPDATE_ABORT": {},
"UPDATE_CHECK": {},
"UPDATE_ERROR": {},
"UPDATE_OK": {},
"WAITINGFORAUTHORIZATION": {}
}
},
"startUpCommand": {
"access": "write",
"type": "string",
"values": {
"UNINSTALL": {}
}
},
"swAncAndRevision": {
"access": "read",
"type": "string"
},
"swVersion": {
"access": "read",
"type": "string"
}
},
"pizzaShieldState": {
"access": "read",
"type": "string",
"values": {
"INSERTED_COLD": {},
"INSERTED_HOT": {},
"NOT_INSERTED": {},
"UNKNOWN": {}
}
},
"preheatComplete": {
"access": "read",
"type": "string",
"values": {
"OFF": {},
"PRE_HEAT_COMPLETED": {},
"PRE_HEAT_RUNNING": {},
"RE_HEAT_COMPLETED": {},
"RE_HEAT_RUNNING": {}
}
},
"program": {
"access": "readwrite",
"type": "string",
"values": {
"ASSIST_PIZZA_EXPERT_REHEAT": {
"hideExecuteCommand": {
"access": "readwrite",
"default": 0,
"disabled": true
},
"targetDuration": {
"access": "readwrite",
"disabled": false,
"max": 180,
"min": 150,
"step": 1
},
"targetTemperatureC": {
"access": "readwrite",
"default": 340,
"disabled": false,
"max": 340,
"min": 320,
"step": 5,
"type": "temperature"
}
},
"AUGRATIN": {
"hideExecuteCommand": {
"access": "readwrite",
"default": 0,
"disabled": true
},
"targetDuration": {
"access": "readwrite",
"disabled": false,
"max": 86340,
"min": 0,
"step": 60
},
"targetTemperatureC": {
"access": "readwrite",
"default": 300,
"disabled": false,
"max": 300,
"min": 80,
"step": 5,
"type": "temperature"
}
},
"BAKE_TRUE_FAN": {
"hideExecuteCommand": {
"access": "readwrite",
"default": 0,
"disabled": true
},
"targetDuration": {
"access": "readwrite",
"disabled": false,
"max": 86340,
"min": 0,
"step": 60
},
"targetTemperatureC": {
"access": "readwrite",
"default": 200,
"disabled": false,
"max": 300,
"min": 80,
"step": 5,
"type": "temperature"
}
},
"BOTTOM": {
"hideExecuteCommand": {
"access": "readwrite",
"default": 0,
"disabled": true
},
"targetDuration": {
"access": "readwrite",
"disabled": false,
"max": 86340,
"min": 0,
"step": 60
},
"targetTemperatureC": {
"access": "readwrite",
"default": 150,
"disabled": false,
"max": 300,
"min": 80,
"step": 5,
"type": "temperature"
}
},
"BREAD_BAKING": {
"hideExecuteCommand": {
"access": "readwrite",
"default": 0,
"disabled": true
},
"targetDuration": {
"access": "readwrite",
"disabled": false,
"max": 86340,
"min": 0,
"step": 60
},
"targetTemperatureC": {
"access": "readwrite",
"default": 220,
"disabled": false,
"max": 300,
"min": 80,
"step": 5,
"type": "temperature"
}
},
"CONVENTIONAL_COOKING": {
"hideExecuteCommand": {
"access": "readwrite",
"default": 0,
"disabled": true
},
"targetDuration": {
"access": "readwrite",
"disabled": false,
"max": 86340,
"min": 0,
"step": 60
},
"targetTemperatureC": {
"access": "readwrite",
"default": 200,
"disabled": false,
"max": 300,
"min": 30,
"step": 5,
"type": "temperature"
}
},
"DEFROST": {
"hideExecuteCommand": {
"access": "readwrite",
"default": 0,
"disabled": true
},
"targetDuration": {
"access": "readwrite",
"disabled": false,
"max": 86340,
"min": 0,
"step": 60
},
"targetTemperatureC": {
"access": "readwrite",
"default": 30,
"disabled": false,
"max": 30,
"min": 30,
"step": 0,
"type": "temperature"
}
},
"DOUGH_PROVING": {
"hideExecuteCommand": {
"access": "readwrite",
"default": 0,
"disabled": true
},
"targetDuration": {
"access": "readwrite",
"disabled": false,
"max": 86340,
"min": 300,
"step": 60
},
"targetTemperatureC": {
"access": "readwrite",
"default": 40,
"disabled": false,
"max": 40,
"min": 40,
"step": 0,
"type": "temperature"
}
},
"DRYING": {
"hideExecuteCommand": {
"access": "readwrite",
"default": 0,
"disabled": true
},
"targetDuration": {
"access": "readwrite",
"disabled": false,
"max": 86340,
"min": 0,
"step": 60
},
"targetTemperatureC": {
"access": "readwrite",
"default": 60,
"disabled": false,
"max": 100,
"min": 50,
"step": 5,
"type": "temperature"
}
},
"FROZEN_FOOD": {
"hideExecuteCommand": {
"access": "readwrite",
"default": 0,
"disabled": true
},
"targetDuration": {
"access": "readwrite",
"disabled": false,
"max": 86340,
"min": 0,
"step": 60
},
"targetTemperatureC": {
"access": "readwrite",
"default": 220,
"disabled": false,
"max": 300,
"min": 80,
"step": 5,
"type": "temperature"
}
},
"GRILL": {
"hideExecuteCommand": {
"access": "readwrite",
"default": 0,
"disabled": true
},
"targetDuration": {
"access": "readwrite",
"disabled": false,
"max": 86340,
"min": 0,
"step": 60
},
"targetTemperatureC": {
"access": "readwrite",
"default": 300,
"disabled": false,
"max": 300,
"min": 80,
"step": 5,
"type": "temperature"
}
},
"GRILL_FAN": {
"hideExecuteCommand": {
"access": "readwrite",
"default": 0,
"disabled": true
},
"targetDuration": {
"access": "readwrite",
"disabled": false,
"max": 86340,
"min": 0,
"step": 60
},
"targetTemperatureC": {
"access": "readwrite",
"default": 180,
"disabled": false,
"max": 300,
"min": 80,
"step": 5,
"type": "temperature"
}
},
"KEEP_WARM": {
"hideExecuteCommand": {
"access": "readwrite",
"default": 0,
"disabled": true
},
"targetDuration": {
"access": "readwrite",
"disabled": false,
"max": 86340,
"min": 0,
"step": 60
},
"targetTemperatureC": {
"access": "readwrite",
"default": 80,
"disabled": false,
"max": 80,
"min": 80,
"step": 0,
"type": "temperature"
}
},
"MOIST_FAN_BAKING": {
"hideExecuteCommand": {
"access": "readwrite",
"default": 0,
"disabled": true
},
"targetDuration": {
"access": "readwrite",
"disabled": false,
"max": 86340,
"min": 0,
"step": 60
},
"targetTemperatureC": {
"access": "readwrite",
"default": 160,
"disabled": false,
"max": 230,
"min": 90,
"step": 5,
"type": "temperature"
}
},
"PIZZA_EXPERT_EXTENSION": {
"hideExecuteCommand": {
"access": "readwrite",
"default": 0,
"disabled": true
},
"targetDuration": {
"access": "readwrite",
"disabled": false,
"max": 15,
"min": 15,
"step": 0
},
"targetTemperatureC": {
"access": "readwrite",
"default": 340,
"disabled": false,
"max": 340,
"min": 320,
"step": 5,
"type": "temperature"
}
},
"PIZZA_EXPERT_MAIN": {
"hideExecuteCommand": {
"access": "readwrite",
"default": 0,
"disabled": true
},
"targetDuration": {
"access": "readwrite",
"disabled": false,
"max": 180,
"min": 150,
"step": 1
},
"targetTemperatureC": {
"access": "readwrite",
"default": 340,
"disabled": false,
"max": 340,
"min": 320,
"step": 5,
"type": "temperature"
}
},
"PIZZA_EXPERT_PREHEAT": {
"hideExecuteCommand": {
"access": "readwrite",
"default": 0,
"disabled": true
},
"targetDuration": {
"access": "readwrite",
"disabled": false,
"max": 86340,
"min": 60,
"step": 60
},
"targetTemperatureC": {
"access": "readwrite",
"default": 340,
"disabled": false,
"max": 340,
"min": 320,
"step": 5,
"type": "temperature"
}
},
"PLATE_WARMING": {
"hideExecuteCommand": {
"access": "readwrite",
"default": 0,
"disabled": true
},
"targetDuration": {
"access": "readwrite",
"disabled": false,
"max": 86340,
"min": 0,
"step": 60
},
"targetTemperatureC": {
"access": "readwrite",
"default": 70,
"disabled": false,
"max": 70,
"min": 70,
"step": 0,
"type": "temperature"
}
},
"PRESERVING": {
"hideExecuteCommand": {
"access": "readwrite",
"default": 0,
"disabled": true
},
"targetDuration": {
"access": "readwrite",
"disabled": false,
"max": 86340,
"min": 0,
"step": 60
},
"targetTemperatureC": {
"access": "readwrite",
"default": 160,
"disabled": false,
"max": 170,
"min": 100,
"step": 5,
"type": "temperature"
}
},
"PYRO_CLEAN_INTENSE": {
"hideExecuteCommand": {
"access": "readwrite",
"default": 1,
"disabled": true
}
},
"PYRO_CLEAN_LIGHT": {
"hideExecuteCommand": {
"access": "readwrite",
"default": 1,
"disabled": true
}
},
"PYRO_CLEAN_NORMAL": {
"hideExecuteCommand": {
"access": "readwrite",
"default": 1,
"disabled": true
}
},
"SLOW_COOK": {
"hideExecuteCommand": {
"access": "readwrite",
"default": 0,
"disabled": true
},
"targetDuration": {
"access": "readwrite",
"disabled": false,
"max": 86340,
"min": 0,
"step": 60
},
"targetTemperatureC": {
"access": "readwrite",
"default": 90,
"disabled": false,
"max": 150,
"min": 80,
"step": 5,
"type": "temperature"
}
},
"TRUE_FAN": {
"hideExecuteCommand": {
"access": "readwrite",
"default": 0,
"disabled": true
},
"targetDuration": {
"access": "readwrite",
"disabled": false,
"max": 86340,
"min": 0,
"step": 60
},
"targetTemperatureC": {
"access": "readwrite",
"default": 150,
"disabled": false,
"max": 300,
"min": 30,
"step": 5,
"type": "temperature"
}
}
}
},
"remoteControl": {
"access": "read",
"type": "string",
"values": {
"DISABLED": {},
"ENABLED": {},
"NOT_SAFETY_RELEVANT_ENABLED": {},
"TEMPORARY_LOCKED": {}
}
},
"runningTime": {
"access": "read",
"default": 0,
"type": "number"
},
"targetDuration": {
"access": "readwrite",
"max": 86340,
"min": 0,
"step": 60,
"type": "number"
},
"targetTemperatureC": {
"access": "readwrite",
"type": "temperature"
},
"timeToEnd": {
"access": "read",
"type": "number"
}
}
}
@@ -0,0 +1,48 @@
{
"applianceId": "900412569_00:43319382-443E0748CCD4",
"connectionState": "connected",
"status": "enabled",
"properties": {
"reported": {
"cleaningReminder": false,
"doorState": "CLOSED",
"remoteControl": "ENABLED",
"targetTemperatureF": 356,
"targetTemperatureC": 180,
"program": "KEY_ERROR",
"targetMicrowavePower": 65535,
"waterTrayInsertionState": "INSERTED",
"waterTankEmpty": "STEAM_TANK_FULL",
"targetDuration": 0,
"startTime": -1,
"applianceInfo": {
"capabilityHash": "ab7b74c6ac8a74614980f77811389dfd47206456617a4d698ffcb97da1cc955b",
"applianceType": "OV"
},
"preheatComplete": "OFF",
"cpv": "00",
"targetFoodProbeTemperatureC": 60,
"targetFoodProbeTemperatureF": 140,
"runningTime": 0,
"applianceState": "READY_TO_START",
"alerts": [],
"networkInterface": {
"swVersion": "v3.0.0S_argo",
"otaState": "IDLE",
"linkQualityIndicator": "VERY_GOOD",
"niuSwUpdateCurrentDescription": "A23642201A-S00007645A",
"swAncAndRevision": "S00007645A"
},
"foodProbeInsertionState": "INSERTED",
"cavityLight": false,
"processPhase": "NONE",
"descalingReminderState": false,
"connectivityState": "connected",
"timeToEnd": -1,
"displayTemperatureC": 30,
"displayFoodProbeTemperatureC": 23,
"displayTemperatureF": 86,
"displayFoodProbeTemperatureF": 73.4
}
}
}
@@ -0,0 +1,45 @@
{
"applianceId": "949288049_00:11112225-443E076A37D6",
"connectionState": "connected",
"status": "enabled",
"properties": {
"reported": {
"applianceInfo": {
"capabilityHash": "212e55b62f19cfec40ff724301b07f80adef9a0ccf072693d45e38049708ef03",
"applianceType": "OV"
},
"doorState": "CLOSED",
"preheatComplete": "OFF",
"remoteControl": "NOT_SAFETY_RELEVANT_ENABLED",
"cpv": "00",
"targetTemperatureF": 302,
"targetTemperatureC": 150,
"runningTime": 0,
"program": "TRUE_FAN",
"applianceState": "READY_TO_START",
"targetMicrowavePower": 65535,
"alerts": [],
"pizzaShieldState": "UNKNOWN",
"networkInterface": {
"autoLocalTimeOffset": -2147483648,
"niuSwUpdateCurrentDescription": "A23642205A-S00008458A",
"swVersion": "v4.0.0S_argo",
"otaState": "IDLE",
"timeZoneDatabaseName": "Etc/UTC",
"swAncAndRevision": "S00008458A",
"linkQualityIndicator": "VERY_GOOD"
},
"foodProbeInsertionState": "NOT_INSERTED",
"cavityLight": false,
"waterTrayInsertionState": "INSERTED",
"waterTankEmpty": "STEAM_TANK_FULL",
"targetDuration": 0,
"startTime": -1,
"processPhase": "NONE",
"connectivityState": "connected",
"timeToEnd": -1,
"displayTemperatureC": 30,
"displayTemperatureF": 86
}
}
}
@@ -0,0 +1,6 @@
{
"applianceId": "900412569_00:43319382-443E0748CCD4",
"applianceName": "Fenix",
"applianceType": "OV",
"created": "2024-10-30T14:00:00.000+00:00"
}
@@ -0,0 +1,6 @@
{
"applianceId": "949288049_00:11112225-443E076A37D6",
"applianceName": "PUX pizza oven",
"applianceType": "OV",
"created": "2026-01-15T14:59:00.000+00:00"
}
@@ -0,0 +1,63 @@
# serializer version: 1
# name: test_all_appliances[fenix_oven]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'electrolux',
'900412569_00:43319382-443E0748CCD4',
),
}),
'labels': set({
}),
'manufacturer': 'ELECTROLUX',
'model': 'OE9XS',
'model_id': None,
'name': 'Fenix',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': '43319382',
'sw_version': None,
'via_device_id': None,
})
# ---
# name: test_all_appliances[pux_oven]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'electrolux',
'949288049_00:11112225-443E076A37D6',
),
}),
'labels': set({
}),
'manufacturer': 'AEG',
'model': 'NBX7P631SB',
'model_id': None,
'name': 'PUX pizza oven',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': '11112225',
'sw_version': None,
'via_device_id': None,
})
# ---
@@ -0,0 +1,515 @@
# serializer version: 1
# name: test_sensor[sensor.fenix_appliance_state-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'options': list([
'alarm',
'delayed_start',
'end_of_cycle',
'idle',
'off',
'paused',
'ready_to_start',
'running',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.fenix_appliance_state',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Appliance state',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': 'mdi:information-outline',
'original_name': 'Appliance state',
'platform': 'electrolux',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'appliance_state',
'unique_id': '900412569_00:43319382-443E0748CCD4_appliance_state',
'unit_of_measurement': None,
})
# ---
# name: test_sensor[sensor.fenix_appliance_state-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
'friendly_name': 'Fenix Appliance state',
'icon': 'mdi:information-outline',
'options': list([
'alarm',
'delayed_start',
'end_of_cycle',
'idle',
'off',
'paused',
'ready_to_start',
'running',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.fenix_appliance_state',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'ready_to_start',
})
# ---
# name: test_sensor[sensor.fenix_current_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.fenix_current_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Current temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': 'mdi:thermometer',
'original_name': 'Current temperature',
'platform': 'electrolux',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'display_temperature',
'unique_id': '900412569_00:43319382-443E0748CCD4_display_temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensor[sensor.fenix_current_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Fenix Current temperature',
'icon': 'mdi:thermometer',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.fenix_current_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '30',
})
# ---
# name: test_sensor[sensor.fenix_food_probe_state-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'options': list([
'inserted',
'not_inserted',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.fenix_food_probe_state',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Food probe state',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': 'mdi:thermometer-probe',
'original_name': 'Food probe state',
'platform': 'electrolux',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'food_probe_state',
'unique_id': '900412569_00:43319382-443E0748CCD4_food_probe_state',
'unit_of_measurement': None,
})
# ---
# name: test_sensor[sensor.fenix_food_probe_state-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
'friendly_name': 'Fenix Food probe state',
'icon': 'mdi:thermometer-probe',
'options': list([
'inserted',
'not_inserted',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.fenix_food_probe_state',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'inserted',
})
# ---
# name: test_sensor[sensor.fenix_food_probe_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.fenix_food_probe_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Food probe temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': 'mdi:thermometer-probe',
'original_name': 'Food probe temperature',
'platform': 'electrolux',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'food_probe_temperature',
'unique_id': '900412569_00:43319382-443E0748CCD4_food_probe_temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensor[sensor.fenix_food_probe_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Fenix Food probe temperature',
'icon': 'mdi:thermometer-probe',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.fenix_food_probe_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '23',
})
# ---
# name: test_sensor[sensor.fenix_remote_control-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'options': list([
'disabled',
'enabled',
'not_safety_relevant_enabled',
'temporary_locked',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.fenix_remote_control',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Remote control',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': 'mdi:remote',
'original_name': 'Remote control',
'platform': 'electrolux',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'remote_control',
'unique_id': '900412569_00:43319382-443E0748CCD4_remote_control',
'unit_of_measurement': None,
})
# ---
# name: test_sensor[sensor.fenix_remote_control-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
'friendly_name': 'Fenix Remote control',
'icon': 'mdi:remote',
'options': list([
'disabled',
'enabled',
'not_safety_relevant_enabled',
'temporary_locked',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.fenix_remote_control',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'enabled',
})
# ---
# name: test_sensor[sensor.pux_pizza_oven_appliance_state-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'options': list([
'alarm',
'delayed_start',
'end_of_cycle',
'idle',
'off',
'paused',
'ready_to_start',
'running',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.pux_pizza_oven_appliance_state',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Appliance state',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': 'mdi:information-outline',
'original_name': 'Appliance state',
'platform': 'electrolux',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'appliance_state',
'unique_id': '949288049_00:11112225-443E076A37D6_appliance_state',
'unit_of_measurement': None,
})
# ---
# name: test_sensor[sensor.pux_pizza_oven_appliance_state-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
'friendly_name': 'PUX pizza oven Appliance state',
'icon': 'mdi:information-outline',
'options': list([
'alarm',
'delayed_start',
'end_of_cycle',
'idle',
'off',
'paused',
'ready_to_start',
'running',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.pux_pizza_oven_appliance_state',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'ready_to_start',
})
# ---
# name: test_sensor[sensor.pux_pizza_oven_current_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.pux_pizza_oven_current_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Current temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': 'mdi:thermometer',
'original_name': 'Current temperature',
'platform': 'electrolux',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'display_temperature',
'unique_id': '949288049_00:11112225-443E076A37D6_display_temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensor[sensor.pux_pizza_oven_current_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'PUX pizza oven Current temperature',
'icon': 'mdi:thermometer',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.pux_pizza_oven_current_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '30',
})
# ---
# name: test_sensor[sensor.pux_pizza_oven_remote_control-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'options': list([
'disabled',
'enabled',
'not_safety_relevant_enabled',
'temporary_locked',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.pux_pizza_oven_remote_control',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Remote control',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': 'mdi:remote',
'original_name': 'Remote control',
'platform': 'electrolux',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'remote_control',
'unique_id': '949288049_00:11112225-443E076A37D6_remote_control',
'unit_of_measurement': None,
})
# ---
# name: test_sensor[sensor.pux_pizza_oven_remote_control-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
'friendly_name': 'PUX pizza oven Remote control',
'icon': 'mdi:remote',
'options': list([
'disabled',
'enabled',
'not_safety_relevant_enabled',
'temporary_locked',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.pux_pizza_oven_remote_control',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'not_safety_relevant_enabled',
})
# ---
@@ -0,0 +1,176 @@
"""Unit test for Electrolux config flow."""
from unittest.mock import AsyncMock
from electrolux_group_developer_sdk.auth.invalid_credentials_exception import (
InvalidCredentialsException,
)
from electrolux_group_developer_sdk.client.bad_credentials_exception import (
BadCredentialsException,
)
from electrolux_group_developer_sdk.client.failed_connection_exception import (
FailedConnectionException,
)
from homeassistant import config_entries, data_entry_flow
from homeassistant.components.electrolux.const import CONF_REFRESH_TOKEN, DOMAIN
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_API_KEY
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
valid_user_input = {
CONF_API_KEY: "test_api_key",
CONF_ACCESS_TOKEN: "test_access_token",
CONF_REFRESH_TOKEN: "test_refresh_token",
}
invalid_user_input = {
CONF_API_KEY: "api_key",
CONF_ACCESS_TOKEN: "invalid_token",
CONF_REFRESH_TOKEN: "invalid_token",
}
async def test_user_flow_success(
hass: HomeAssistant, appliances: AsyncMock, mock_token_manager: AsyncMock
) -> None:
"""Test a successful user config flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is data_entry_flow.FlowResultType.FORM
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=valid_user_input
)
assert result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY
assert result["title"] == "Electrolux for mock@email.com"
assert result["data"] == valid_user_input
assert result["result"].unique_id == "mock_user_id"
async def test_user_flow_invalid_auth(
hass: HomeAssistant, appliances: AsyncMock, mock_token_manager: AsyncMock
) -> None:
"""Test an invalid auth config flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is data_entry_flow.FlowResultType.FORM
mock_token_manager.ensure_credentials.side_effect = InvalidCredentialsException
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=invalid_user_input,
)
assert result["type"] is data_entry_flow.FlowResultType.FORM
assert result["errors"] == {"base": "invalid_auth"}
mock_token_manager.ensure_credentials.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=valid_user_input
)
assert result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY
assert result["title"] == "Electrolux for mock@email.com"
assert result["data"] == valid_user_input
assert result["result"].unique_id == "mock_user_id"
async def test_user_flow_bad_credentials(
hass: HomeAssistant, appliances: AsyncMock, mock_token_manager: AsyncMock
) -> None:
"""Test an invalid auth config flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is data_entry_flow.FlowResultType.FORM
appliances.test_connection.side_effect = BadCredentialsException()
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=invalid_user_input,
)
assert result["type"] is data_entry_flow.FlowResultType.FORM
assert result["errors"] == {"base": "invalid_auth"}
appliances.test_connection.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=valid_user_input
)
assert result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY
assert result["title"] == "Electrolux for mock@email.com"
assert result["data"] == valid_user_input
assert result["result"].unique_id == "mock_user_id"
async def test_user_flow_failed_connection(
hass: HomeAssistant, appliances: AsyncMock, mock_token_manager: AsyncMock
) -> None:
"""Test an invalid auth config flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is data_entry_flow.FlowResultType.FORM
appliances.test_connection.side_effect = FailedConnectionException()
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=invalid_user_input,
)
assert result["type"] is data_entry_flow.FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"}
appliances.test_connection.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=valid_user_input
)
assert result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY
assert result["title"] == "Electrolux for mock@email.com"
assert result["data"] == valid_user_input
assert result["result"].unique_id == "mock_user_id"
async def test_user_flow_duplicate_entry(
hass: HomeAssistant,
appliances: AsyncMock,
mock_token_manager: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test an invalid auth config flow."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is data_entry_flow.FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=valid_user_input,
)
assert result["type"] is data_entry_flow.FlowResultType.ABORT
assert result["reason"] == "already_configured"
+189
View File
@@ -0,0 +1,189 @@
"""Unit test for Electrolux init flow."""
from unittest.mock import AsyncMock
from electrolux_group_developer_sdk.client.appliance_data_factory import (
appliance_data_factory,
)
from electrolux_group_developer_sdk.client.appliances.appliance_data import (
ApplianceData,
)
from electrolux_group_developer_sdk.client.bad_credentials_exception import (
BadCredentialsException,
)
from electrolux_group_developer_sdk.client.client_exception import (
ApplianceClientException,
)
from electrolux_group_developer_sdk.client.failed_connection_exception import (
FailedConnectionException,
)
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.electrolux import ElectroluxData
from homeassistant.components.electrolux.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from . import (
APPLIANCE_FIXTURES,
get_fixture_name,
load_appliance,
load_appliance_details,
load_appliance_state,
setup_integration,
)
from tests.common import MockConfigEntry
async def test_async_setup_entry_success(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
appliances: AsyncMock,
mock_token_manager: AsyncMock,
) -> None:
"""Test successful setup of the Electrolux integration."""
await setup_integration(hass, mock_config_entry)
# Check integration is loaded
assert mock_config_entry.state is ConfigEntryState.LOADED
assert mock_config_entry.runtime_data is not None
assert isinstance(mock_config_entry.runtime_data, ElectroluxData)
# Unload the config entry
assert await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
def test_appliance_fixture_data() -> None:
"""Test that all appliance fixtures are configured correctly."""
appliance_id_set = set()
for appliance_fixture in APPLIANCE_FIXTURES:
appliance = load_appliance(appliance_fixture)
appliance_id = appliance.applianceId
assert appliance_id not in appliance_id_set, (
f"Duplicate appliance ID {appliance_id} detected in fixture {appliance_fixture}"
)
appliance_state = load_appliance_state(appliance_fixture)
assert appliance_id == appliance_state.applianceId, (
f"Appliance ID in state {appliance_state.applianceId} does not match appliance ID in appliance object {appliance_id}"
)
appliance_id_set.add(appliance_id)
async def test_all_appliances(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
appliances: AsyncMock,
mock_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test all entities for all appliance fixtures."""
await setup_integration(hass, mock_config_entry)
appliance_list: list[ApplianceData] = await appliances.get_appliance_data()
for appliance in appliance_list:
appliance_id = appliance.appliance.applianceId
device = device_registry.async_get_device({(DOMAIN, appliance_id)})
assert device is not None
assert device == snapshot(name=get_fixture_name(appliance_id))
async def test_check_for_dynamic_devices(
hass: HomeAssistant,
mock_appliance_client: AsyncMock,
mock_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test that the integration adds and removes devices correctly."""
old_appliance_fixture = "fenix_oven"
old_appliance_id = "900412569_00:43319382-443E0748CCD4"
new_appliance_fixture = "pux_oven"
new_appliance_id = "949288049_00:11112225-443E076A37D6"
def set_appliance_fixture_mock(appliance_fixture: str):
appliance = load_appliance(appliance_fixture)
details = load_appliance_details(appliance_fixture)
state = load_appliance_state(appliance_fixture)
appliance_data = appliance_data_factory(
appliance=appliance,
details=details,
state=state,
)
mock_appliance_client.get_appliance_data.return_value = [appliance_data]
set_appliance_fixture_mock(old_appliance_fixture)
await setup_integration(hass, mock_config_entry)
assert device_registry.async_get_device({(DOMAIN, old_appliance_id)}) is not None
assert device_registry.async_get_device({(DOMAIN, new_appliance_id)}) is None
set_appliance_fixture_mock(new_appliance_fixture)
event_stream_call_args = mock_appliance_client.start_event_stream.call_args.args
assert event_stream_call_args is not None and len(event_stream_call_args) > 0, (
"start_event_stream method called without any callbacks specified"
)
callback_list = event_stream_call_args[0]
for callback in callback_list:
await callback()
assert device_registry.async_get_device({(DOMAIN, old_appliance_id)}) is None
assert device_registry.async_get_device({(DOMAIN, new_appliance_id)}) is not None
@pytest.mark.parametrize(
("exception", "entry_state"),
[
(ApplianceClientException(), ConfigEntryState.SETUP_RETRY),
],
)
async def test_appliance_client_exception(
hass: HomeAssistant,
mock_appliance_client: AsyncMock,
mock_config_entry: MockConfigEntry,
exception: Exception,
entry_state: ConfigEntryState,
) -> None:
"""Test for handling errors that occur while getting the data of all appliances."""
mock_appliance_client.get_appliance_data.side_effect = exception
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is entry_state
@pytest.mark.parametrize(
("exception", "entry_state"),
[
(BadCredentialsException(), ConfigEntryState.SETUP_ERROR),
(FailedConnectionException(), ConfigEntryState.SETUP_RETRY),
],
)
async def test_appliance_client_test_connection_bad_credentials(
hass: HomeAssistant,
mock_appliance_client: AsyncMock,
mock_config_entry: MockConfigEntry,
exception: Exception,
entry_state: ConfigEntryState,
) -> None:
"""Test for handling errors that occur while testing the connection."""
mock_appliance_client.test_connection.side_effect = exception
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is entry_state
@@ -0,0 +1,34 @@
"""Sensor tests of Electrolux integration."""
from collections.abc import Generator
from unittest.mock import patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, snapshot_platform
@pytest.fixture(autouse=True)
def override_platforms() -> Generator[None]:
"""Override PLATFORMS."""
with patch("homeassistant.components.electrolux.PLATFORMS", [Platform.SENSOR]):
yield
@pytest.mark.usefixtures("appliances")
async def test_sensor(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test states of the sensor."""
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)