Compare commits

..

32 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
Thomas Bouron 09a08011d6 Add support for Inverter Pool Heat Pump (InverGo) (#169606)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-05-12 16:26:08 +02:00
Åke Strandberg 891e0aebb0 Bump pymiele version to 0.6.2 (#170419) 2026-05-12 16:25:21 +02:00
David ca9a7f6051 Add fault codes to Tuya Pro Breeze OmniDr Dehumidifier (#170411)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-05-12 15:41:29 +02:00
Erik Montnemery 24dc206462 Initiate translation attributes on class level of HomeAssistantError (#170413) 2026-05-12 15:10:07 +02:00
Mattie 60e4f924a0 Bump python-qube-heatpump to 1.10.0 (#170405) 2026-05-12 14:55:46 +02:00
Manu 339703ca04 Add initial quality scale to HTML5 integration (#167046)
Co-authored-by: Markus Tuominen <3738613+Markus98@users.noreply.github.com>
2026-05-12 15:13:28 +03:00
Erik Montnemery 362cba91fb Make ConditionError a non dataclass (#170391) 2026-05-12 13:55:48 +02:00
ferenc-fustos-electrolux 7859aba432 Add electrolux integration (#157176) 2026-05-12 12:40:40 +01:00
Raman Gupta a215b82bd9 Cancel previous Debouncer timer handle in _schedule_timer (#170339)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-12 12:32:58 +02:00
knsj 3393598d91 Bump ihcsdk to v. 2.8.12 (#170366) 2026-05-12 12:30:51 +02:00
Erik Montnemery 676df1d2b2 Fix cv.CONDITION_SCHEMA (#170395) 2026-05-12 11:39:43 +02:00
Artur Pragacz 36cc629faf Validate device info string fields in the registry (#170021) 2026-05-12 11:01:04 +02:00
Marc Mueller 99b1e7c229 Enable parallel type checking for mypy (#170381) 2026-05-12 10:34:47 +02:00
renovate[bot] cfdb00bf36 Update pyOpenSSL to 26.2.0 (#170371)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-12 10:17:56 +02:00
puddly 9b8c81cba1 Bump serialx to 1.7.3 (#170368) 2026-05-12 07:52:05 +02:00
Petar Petrov 095cf07f43 Add battery state of charge to energy preferences (#169550)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-05-12 08:37:25 +03:00
Marc Mueller b275791a71 Update mypy to 2.1.0 (#170352) 2026-05-12 05:08:39 +02:00
Lukas e7dccd3ad3 Bump infrared-protocols to 5.1.0 (#170365) 2026-05-11 22:36:56 -04:00
Franck Nijhof adab0d6486 Clean up template engine after extension modularization (#170346) 2026-05-11 18:41:54 -04:00
Robert Svensson aad964889f Bump axis to v71 (#170347) 2026-05-11 23:10:20 +02:00
Jan-Philipp Benecke 9200658526 Enhance WebDAV metadata download with concurrency (#170223) 2026-05-11 21:33:22 +02:00
Christian Lackas 68f10249a5 Add target temperature sensor for ViCare RadiatorActuator devices (#170102) 2026-05-11 21:32:47 +02:00
Andreas Schneider b5ee78aeac Bump pyzbar to 0.1.9 (#170076) 2026-05-11 21:32:19 +02:00
Christian Lackas 86a967ee7b homematicip_cloud: fix HmIP-FLC lock state polarity (#170159) 2026-05-11 21:29:00 +02:00
theobld-ww eeca75b937 Watts: add timer mode service (#169846)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-11 21:18:13 +02:00
Franck Nijhof ce6b6601fa Set parallel updates for Ecowitt platforms (#170349) 2026-05-11 21:00:15 +02:00
Sören 4641c829ca Add config flow to Avea (#168070)
Co-authored-by: Erwin Douna <e.douna@gmail.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-05-11 18:42:24 +02:00
Joost Lekkerkerker 56fbd096e2 Cleanup Eurotronic number platform (#170337) 2026-05-11 18:30:58 +02:00
Richard Kroegel c071c08f86 Add number platform to eurotronic_cometblue (#168119)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-11 17:42:56 +02:00
TheJulianJES e47c152222 Add ZWaveNodeBaseEntity for Z-Wave node-level entities (#170124) 2026-05-11 17:34:30 +02:00
145 changed files with 8343 additions and 1433 deletions
+2 -2
View File
@@ -853,7 +853,7 @@ jobs:
run: |
. venv/bin/activate
python --version
mypy homeassistant pylint
mypy --num-workers=4 homeassistant pylint
- name: Run mypy (partially)
if: needs.info.outputs.test_full_suite == 'false'
shell: bash
@@ -862,7 +862,7 @@ jobs:
run: |
. venv/bin/activate
python --version
mypy $(printf "homeassistant/components/%s " ${INTEGRATIONS_GLOB})
mypy --num-workers=4 $(printf "homeassistant/components/%s " ${INTEGRATIONS_GLOB})
prepare-pytest-full:
name: Split tests for full run
Generated
+3
View File
@@ -196,6 +196,7 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/autoskope/ @mcisk
/tests/components/autoskope/ @mcisk
/homeassistant/components/avea/ @pattyland
/tests/components/avea/ @pattyland
/homeassistant/components/awair/ @ahayworth @ricohageman
/tests/components/awair/ @ahayworth @ricohageman
/homeassistant/components/aws_s3/ @tomasbedrich
@@ -463,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
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/acer_projector",
"iot_class": "local_polling",
"quality_scale": "legacy",
"requirements": ["serialx==1.7.2"]
"requirements": ["serialx==1.7.3"]
}
+33 -1
View File
@@ -1 +1,33 @@
"""The avea component."""
"""The Avea integration."""
import avea
from homeassistant.components.bluetooth import async_ble_device_from_address
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ADDRESS, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
type AveaConfigEntry = ConfigEntry[avea.Bulb]
PLATFORMS: list[Platform] = [Platform.LIGHT]
async def async_setup_entry(hass: HomeAssistant, entry: AveaConfigEntry) -> bool:
"""Set up Avea from a config entry."""
ble_device = async_ble_device_from_address(
hass, entry.data[CONF_ADDRESS], connectable=True
)
if not ble_device:
raise ConfigEntryNotReady(
f"Could not find Avea device with address {entry.data[CONF_ADDRESS]}"
)
entry.runtime_data = avea.Bulb(ble_device)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: AveaConfigEntry) -> bool:
"""Unload an Avea config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -0,0 +1,216 @@
"""Config flow for Avea."""
from contextlib import suppress
import logging
from typing import Any
import avea
from bleak.exc import BleakError
import voluptuous as vol
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ADDRESS, CONF_NAME
from .const import AVEA_SERVICE_UUID, DOMAIN, UNKNOWN_NAME
_LOGGER = logging.getLogger(__name__)
def _normalize_name(name: str | None) -> str | None:
"""Return a valid Avea name."""
if not name or name == UNKNOWN_NAME:
return None
return name
def _validate_device(discovery_info: BluetoothServiceInfoBleak) -> str:
"""Validate the device is reachable and return a title for it."""
bulb = avea.Bulb(discovery_info.device)
try:
if not bulb.connect():
raise CannotConnect
try:
name = bulb.get_name()
except BleakError, OSError, RuntimeError:
_LOGGER.debug(
"Failed to get name for Avea device %s",
discovery_info.address,
exc_info=True,
)
name = None
brightness = bulb.get_brightness()
except (BleakError, OSError, RuntimeError) as err:
raise CannotConnect from err
finally:
with suppress(BleakError, OSError, RuntimeError):
bulb.close()
if brightness is None:
raise CannotConnect
return (
_normalize_name(name)
or _normalize_name(discovery_info.name)
or discovery_info.address
)
def _is_avea_discovery(discovery_info: BluetoothServiceInfoBleak) -> bool:
"""Return if the bluetooth discovery matches an Avea bulb."""
return AVEA_SERVICE_UUID in discovery_info.service_uuids
class AveaConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Avea."""
def __init__(self) -> None:
"""Initialize the config flow."""
self._discovery_info: BluetoothServiceInfoBleak | None = None
self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {}
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfoBleak
) -> ConfigFlowResult:
"""Handle the bluetooth discovery step."""
await self.async_set_unique_id(discovery_info.address)
self._abort_if_unique_id_configured()
self._discovery_info = discovery_info
self.context["title_placeholders"] = {
"name": discovery_info.name or discovery_info.address
}
return await self.async_step_bluetooth_confirm()
async def async_step_bluetooth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm the discovered device before creating the entry."""
assert self._discovery_info is not None
errors: dict[str, str] = {}
if user_input is not None:
try:
title = await self.hass.async_add_executor_job(
_validate_device, self._discovery_info
)
except CannotConnect:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected error while validating Avea device")
errors["base"] = "unknown"
else:
return self.async_create_entry(
title=title,
data={CONF_ADDRESS: self._discovery_info.address},
)
self.context["title_placeholders"] = {
"name": self._discovery_info.name or self._discovery_info.address
}
self._set_confirm_only()
return self.async_show_form(
step_id="bluetooth_confirm",
description_placeholders=self.context["title_placeholders"],
errors=errors,
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the user step to pick a discovered device."""
errors: dict[str, str] = {}
if user_input is not None:
address = user_input[CONF_ADDRESS]
discovery_info = self._discovered_devices[address]
await self.async_set_unique_id(address, raise_on_progress=False)
self._abort_if_unique_id_configured()
try:
title = await self.hass.async_add_executor_job(
_validate_device, discovery_info
)
except CannotConnect:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected error while validating Avea device")
errors["base"] = "unknown"
else:
return self.async_create_entry(
title=title,
data={CONF_ADDRESS: address},
)
if discovery := self._discovery_info:
self._discovered_devices[discovery.address] = discovery
else:
current_addresses = self._async_current_ids(include_ignore=False)
for discovery in async_discovered_service_info(self.hass):
if (
discovery.address in current_addresses
or discovery.address in self._discovered_devices
or not _is_avea_discovery(discovery)
):
continue
self._discovered_devices[discovery.address] = discovery
if not self._discovered_devices:
return self.async_abort(reason="no_devices_found")
if self._discovery_info:
data_schema = vol.Schema(
{
vol.Required(
CONF_ADDRESS, default=self._discovery_info.address
): vol.In(
{
self._discovery_info.address: (
f"{self._discovery_info.name or self._discovery_info.address}"
f" ({self._discovery_info.address})"
)
}
)
}
)
else:
data_schema = vol.Schema(
{
vol.Required(CONF_ADDRESS): vol.In(
{
service_info.address: (
f"{service_info.name or service_info.address}"
f" ({service_info.address})"
)
for service_info in self._discovered_devices.values()
}
),
}
)
return self.async_show_form(
step_id="user",
data_schema=data_schema,
errors=errors,
)
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
"""Handle import from YAML."""
address = import_data[CONF_ADDRESS]
await self.async_set_unique_id(address, raise_on_progress=False)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=import_data.get(CONF_NAME, address),
data={CONF_ADDRESS: address},
)
class CannotConnect(Exception):
"""Error to indicate an Avea device cannot be connected to."""
+8
View File
@@ -0,0 +1,8 @@
"""Constants for the Avea integration."""
DOMAIN = "avea"
INTEGRATION_TITLE = "Elgato Avea"
MANUFACTURER = "Elgato"
MODEL = "Avea"
AVEA_SERVICE_UUID = "f815e810-456c-6761-746f-4d756e696368"
UNKNOWN_NAME = "Unknown"
+142 -18
View File
@@ -1,8 +1,11 @@
"""Support for the Elgato Avea lights."""
"""Light platform for Avea."""
from contextlib import suppress
import logging
from typing import Any
import avea
from bleak.exc import BleakError
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
@@ -10,29 +13,153 @@ from homeassistant.components.light import (
ColorMode,
LightEntity,
)
from homeassistant.core import HomeAssistant
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_ADDRESS, CONF_NAME
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import color as color_util
from . import AveaConfigEntry
from .const import DOMAIN, INTEGRATION_TITLE, UNKNOWN_NAME
def setup_platform(
_LOGGER = logging.getLogger(__name__)
UPDATE_EXCEPTIONS = (BleakError, OSError, RuntimeError)
BREAKS_IN_HA_VERSION = "2026.12.0"
def _normalize_name(name: str | None) -> str | None:
"""Return a valid Avea name."""
if not name or name == UNKNOWN_NAME:
return None
return name
def _create_deprecated_yaml_issue(hass: HomeAssistant) -> None:
"""Create the deprecated YAML issue for Avea."""
ir.async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
breaks_in_ha_version=BREAKS_IN_HA_VERSION,
is_fixable=False,
is_persistent=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": INTEGRATION_TITLE,
},
)
def _create_yaml_import_failed_issue(hass: HomeAssistant) -> None:
"""Create a repair issue when the Avea YAML import cannot find bulbs."""
ir.async_create_issue(
hass,
DOMAIN,
"deprecated_yaml_import_issue_no_bulbs",
breaks_in_ha_version=BREAKS_IN_HA_VERSION,
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_yaml_import_issue_no_bulbs",
translation_placeholders={
"domain": DOMAIN,
"integration_title": INTEGRATION_TITLE,
},
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AveaConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Avea light platform."""
async_add_entities([AveaLight(entry.runtime_data)], update_before_add=True)
def _discover_bulbs_for_import() -> list[dict[str, str]]:
"""Discover and validate Avea bulbs for YAML import."""
discovered_bulbs: list[dict[str, str]] = []
for bulb in avea.discover_avea_bulbs():
address = bulb.addr
try:
name = bulb.get_name()
brightness = bulb.get_brightness()
except UPDATE_EXCEPTIONS as err:
_LOGGER.warning(
"Skipping Avea bulb %s during YAML import due to read failure: %s",
address,
err,
)
continue
finally:
with suppress(*UPDATE_EXCEPTIONS):
bulb.close()
if brightness is None:
_LOGGER.warning(
"Skipping Avea bulb %s during YAML import due to read failure: brightness is None",
address,
)
continue
discovered_bulbs.append(
{
CONF_ADDRESS: address,
CONF_NAME: _normalize_name(name)
or _normalize_name(bulb.name)
or address,
}
)
return discovered_bulbs
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
add_entities: AddEntitiesCallback,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Avea platform."""
"""Import the Avea YAML platform into config entries."""
try:
nearby_bulbs = avea.discover_avea_bulbs()
for bulb in nearby_bulbs:
bulb.get_name()
bulb.get_brightness()
except OSError as err:
raise PlatformNotReady from err
bulbs = await hass.async_add_executor_job(_discover_bulbs_for_import)
except UPDATE_EXCEPTIONS as err:
raise PlatformNotReady("Could not discover Avea bulbs for YAML import") from err
add_entities(AveaLight(bulb) for bulb in nearby_bulbs)
if not bulbs:
_create_yaml_import_failed_issue(hass)
for bulb in bulbs:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=bulb,
)
if (
result.get("type") is FlowResultType.ABORT
and result.get("reason") != "already_configured"
):
_LOGGER.warning(
"Skipping Avea YAML import for bulb %s: %s",
bulb[CONF_ADDRESS],
result.get("reason"),
)
continue
_create_deprecated_yaml_issue(hass)
class AveaLight(LightEntity):
@@ -41,7 +168,7 @@ class AveaLight(LightEntity):
_attr_color_mode = ColorMode.HS
_attr_supported_color_modes = {ColorMode.HS}
def __init__(self, light):
def __init__(self, light: avea.Bulb) -> None:
"""Initialize an AveaLight."""
self._light = light
self._attr_name = light.name
@@ -64,10 +191,7 @@ class AveaLight(LightEntity):
self._light.set_brightness(0)
def update(self) -> None:
"""Fetch new state data for this light.
This is the only method that should fetch new data for Home Assistant.
"""
"""Fetch new state data for this light."""
if (brightness := self._light.get_brightness()) is not None:
self._attr_is_on = brightness != 0
self._attr_brightness = round(255 * (brightness / 4095))
+9 -1
View File
@@ -1,10 +1,18 @@
{
"domain": "avea",
"name": "Elgato Avea",
"bluetooth": [
{
"local_name": "Avea*",
"service_uuid": "f815e810-456c-6761-746f-4d756e696368"
}
],
"codeowners": ["@pattyland"],
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/avea",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["avea"],
"quality_scale": "legacy",
"requirements": ["avea==1.6.1"]
}
@@ -0,0 +1,35 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"flow_title": "{name}",
"step": {
"bluetooth_confirm": {
"description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]"
},
"user": {
"data": {
"address": "[%key:common::config_flow::data::device%]"
},
"description": "[%key:component::bluetooth::config::step::user::description%]"
}
}
},
"issues": {
"deprecated_yaml": {
"description": "[%key:component::homeassistant::issues::deprecated_yaml::description%]",
"title": "[%key:component::homeassistant::issues::deprecated_yaml::title%]"
},
"deprecated_yaml_import_issue_no_bulbs": {
"description": "Configuring {integration_title} using YAML is deprecated and will be removed in a future release. While importing your YAML configuration, Home Assistant could not discover any Avea bulbs. Make sure the bulbs are powered on, nearby, and reachable over Bluetooth, then restart Home Assistant. If you no longer use the YAML configuration, remove the `{domain}` entry from your `configuration.yaml` file.",
"title": "Avea YAML configuration import failed"
}
}
}
+1 -1
View File
@@ -29,7 +29,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["axis"],
"requirements": ["axis==70"],
"requirements": ["axis==71"],
"ssdp": [
{
"manufacturer": "AXIS"
+3 -1
View File
@@ -64,5 +64,7 @@ class BroadlinkEntity(Entity):
manufacturer=device.api.manufacturer,
model=device.api.model,
name=device.name,
sw_version=device.fw_version,
sw_version=str(device.fw_version)
if device.fw_version is not None
else None,
)
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING
from broadlink.exceptions import BroadlinkException
from broadlink.remote import pulses_to_data as _bl_pulses_to_data
from homeassistant.components.infrared import InfraredCommand, InfraredEmitterEntity
from homeassistant.components.infrared import InfraredCommand, InfraredEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@@ -43,8 +43,8 @@ async def async_setup_entry(
async_add_entities([BroadlinkInfraredEntity(device)])
class BroadlinkInfraredEntity(BroadlinkEntity, InfraredEmitterEntity):
"""Broadlink infrared emitter entity."""
class BroadlinkInfraredEntity(BroadlinkEntity, InfraredEntity):
"""Broadlink infrared transmitter entity."""
_attr_has_entity_name = True
_attr_translation_key = "infrared_emitter"
@@ -17,6 +17,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import EcowittConfigEntry
from .entity import EcowittEntity
PARALLEL_UPDATES = 0
ECOWITT_BINARYSENSORS_MAPPING: Final = {
EcoWittSensorTypes.LEAK: BinarySensorEntityDescription(
key="LEAK", device_class=BinarySensorDeviceClass.MOISTURE
@@ -38,6 +38,8 @@ from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM
from . import EcowittConfigEntry
from .entity import EcowittEntity
PARALLEL_UPDATES = 0
_LOGGER = logging.getLogger(__name__)
@@ -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"
}
}
}
}
}
+4
View File
@@ -163,6 +163,9 @@ class BatterySourceType(TypedDict):
# User's original power sensor configuration
power_config: NotRequired[PowerConfig]
# statistic_id of a sensor (unit %) reporting the battery state of charge
stat_soc: NotRequired[str]
class GasSourceType(TypedDict):
"""Dictionary holding the source of gas consumption."""
@@ -483,6 +486,7 @@ BATTERY_SOURCE_SCHEMA = vol.Schema(
# If power_config is provided, it takes precedence and stat_rate is overwritten
vol.Optional("stat_rate"): str,
vol.Optional("power_config"): POWER_CONFIG_SCHEMA,
vol.Optional("stat_soc"): str,
}
)
+3 -5
View File
@@ -5,7 +5,7 @@ import logging
from aioesphomeapi import EntityState, InfraredCapability, InfraredInfo
from homeassistant.components.infrared import InfraredCommand, InfraredEmitterEntity
from homeassistant.components.infrared import InfraredCommand, InfraredEntity
from homeassistant.core import callback
from .entity import (
@@ -19,10 +19,8 @@ _LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
class EsphomeInfraredEntity(
EsphomeEntity[InfraredInfo, EntityState], InfraredEmitterEntity
):
"""ESPHome infrared emitter entity using native API."""
class EsphomeInfraredEntity(EsphomeEntity[InfraredInfo, EntityState], InfraredEntity):
"""ESPHome infrared entity using native API."""
@callback
def _on_device_update(self) -> None:
@@ -16,6 +16,7 @@ from .coordinator import CometBlueConfigEntry, CometBlueDataUpdateCoordinator
PLATFORMS: list[Platform] = [
Platform.BUTTON,
Platform.CLIMATE,
Platform.NUMBER,
Platform.SENSOR,
]
@@ -4,6 +4,17 @@
"sync_time": {
"default": "mdi:calendar-clock"
}
},
"number": {
"comfort_setpoint": {
"default": "mdi:thermometer-chevron-up"
},
"eco_setpoint": {
"default": "mdi:thermometer-chevron-down"
},
"offset": {
"default": "mdi:thermometer-check"
}
}
}
}
@@ -0,0 +1,127 @@
"""Comet Blue number integration."""
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from eurotronic_cometblue_ha import AsyncCometBlue
from homeassistant.components.number import (
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
)
from homeassistant.const import PRECISION_HALVES, EntityCategory, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .climate import MAX_TEMP, MIN_TEMP
from .coordinator import CometBlueConfigEntry, CometBlueDataUpdateCoordinator
from .entity import CometBlueBluetoothEntity
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class CometBlueNumberEntityDescription(NumberEntityDescription):
"""Describes a Comet Blue number entity."""
cometblue_key: str
set_fn: Callable[[AsyncCometBlue], Any]
DESCRIPTIONS = [
CometBlueNumberEntityDescription(
key="offset",
cometblue_key="tempOffset",
translation_key="offset",
device_class=NumberDeviceClass.TEMPERATURE,
entity_category=EntityCategory.CONFIG,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
set_fn=lambda x: x.set_temperature_async,
native_min_value=-5.0,
native_max_value=5.0,
native_step=PRECISION_HALVES,
entity_registry_enabled_default=False,
),
CometBlueNumberEntityDescription(
key="eco_setpoint",
cometblue_key="targetTempLow",
translation_key="eco_setpoint",
device_class=NumberDeviceClass.TEMPERATURE,
entity_category=EntityCategory.CONFIG,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
set_fn=lambda x: x.set_temperature_async,
native_min_value=MIN_TEMP,
native_max_value=MAX_TEMP,
native_step=PRECISION_HALVES,
entity_registry_enabled_default=True,
),
CometBlueNumberEntityDescription(
key="comfort_setpoint",
cometblue_key="targetTempHigh",
translation_key="comfort_setpoint",
device_class=NumberDeviceClass.TEMPERATURE,
entity_category=EntityCategory.CONFIG,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
set_fn=lambda x: x.set_temperature_async,
native_min_value=MIN_TEMP,
native_max_value=MAX_TEMP,
native_step=PRECISION_HALVES,
entity_registry_enabled_default=True,
),
]
async def async_setup_entry(
hass: HomeAssistant,
entry: CometBlueConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the client entities."""
coordinator = entry.runtime_data
entities: list[CometBlueNumberEntity] = [
CometBlueNumberEntity(coordinator, description) for description in DESCRIPTIONS
]
async_add_entities(entities)
class CometBlueNumberEntity(CometBlueBluetoothEntity, NumberEntity):
"""Representation of a number."""
entity_description: CometBlueNumberEntityDescription
def __init__(
self,
coordinator: CometBlueDataUpdateCoordinator,
description: CometBlueNumberEntityDescription,
) -> None:
"""Initialize CometBlueNumberEntity."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.address}-{description.key}"
@property
def native_value(self) -> float | None:
"""Return the entity value to represent the entity state."""
return self.coordinator.data.temperatures.get(
self.entity_description.cometblue_key
)
async def async_set_native_value(self, value: float) -> None:
"""Update to the device."""
await self.coordinator.send_command(
self.entity_description.set_fn(self.coordinator.device),
{
"values": {
# manual temperature always needs to be set, otherwise TRV will turn OFF
"manualTemp": self.coordinator.data.temperatures["manualTemp"],
self.entity_description.cometblue_key: value,
}
},
)
await self.coordinator.async_request_refresh()
@@ -35,6 +35,17 @@
"sync_time": {
"name": "Sync time"
}
},
"number": {
"comfort_setpoint": {
"name": "Comfort setpoint"
},
"eco_setpoint": {
"name": "Eco setpoint"
},
"offset": {
"name": "Setpoint offset"
}
}
}
}
@@ -172,7 +172,7 @@ def _format_tool(
def _escape_decode(value: Any) -> Any:
"""Recursively call codecs.escape_decode on all values."""
if isinstance(value, str):
return codecs.escape_decode(bytes(value, "utf-8"))[0].decode("utf-8") # type: ignore[attr-defined]
return codecs.escape_decode(bytes(value, "utf-8"))[0].decode("utf-8")
if isinstance(value, list):
return [_escape_decode(item) for item in value]
if isinstance(value, dict):
@@ -2,7 +2,12 @@
from typing import Any
from homematicip.base.enums import LockState, SmokeDetectorAlarmType, WindowState
from homematicip.base.enums import (
BinaryBehaviorType,
LockState,
SmokeDetectorAlarmType,
WindowState,
)
from homematicip.base.functionalChannels import MultiModeInputChannel
from homematicip.device import (
AccelerationSensor,
@@ -352,7 +357,22 @@ class HomematicipFullFlushLockControllerLocked(
@property
def is_on(self) -> bool:
"""Return true if the controlled lock is locked."""
"""Return true if the controlled lock is unlocked.
Per HA's BinarySensorDeviceClass.LOCK contract, ON means
unlocked / open and OFF means locked / closed.
The mapping from the firmware-reported ``lockState`` depends on
the channel's ``binaryBehaviorType``. With the default
``NORMALLY_OPEN`` wiring, the input goes ACTIVE (and lockState
flips to ``LOCKED``) when the contact closes — i.e. when a
magnetic door contact registers the door as closed. With
``NORMALLY_CLOSE`` the same physical event puts the input into
the IDLE state (lockState ``UNLOCKED``). To present the same
HA semantics regardless of which way the user wired the
contact, ``lockState`` is interpreted relative to the
configured behavior.
"""
channel = _get_channel_by_role(
self._device,
"MULTI_MODE_LOCK_INPUT_CHANNEL",
@@ -361,7 +381,15 @@ class HomematicipFullFlushLockControllerLocked(
if channel is None:
return False
lock_state = getattr(channel, "lockState", None)
return getattr(lock_state, "name", lock_state) == LockState.LOCKED.name
is_locked_state = (
getattr(lock_state, "name", lock_state) == LockState.LOCKED.name
)
binary_behavior = getattr(channel, "binaryBehaviorType", None)
normally_close = (
getattr(binary_behavior, "name", binary_behavior)
== BinaryBehaviorType.NORMALLY_CLOSE.name
)
return is_locked_state if normally_close else not is_locked_state
class HomematicipFullFlushLockControllerGlassBreak(
@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["python_qube_heatpump"],
"quality_scale": "bronze",
"requirements": ["python-qube-heatpump==1.8.0"]
"requirements": ["python-qube-heatpump==1.10.0"]
}
@@ -0,0 +1,84 @@
rules:
# Bronze
action-setup: done
appropriate-polling:
status: exempt
comment: The integration does not poll
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: todo
docs-actions: done
docs-high-level-description: todo
docs-installation-instructions: done
docs-removal-instructions: todo
entity-event-setup: done
entity-unique-id: todo
has-entity-name: done
runtime-data:
status: exempt
comment: The integration has no runtime data
test-before-configure:
status: exempt
comment: Only validates the VAPID private key, no live service interaction is available to test.
test-before-setup:
status: exempt
comment: The integration has no runtime behavior that can be tested prior to setup.
unique-config-entry: done
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: todo
integration-owner: done
log-when-unavailable:
status: exempt
comment: No service availability state to monitor or log.
parallel-updates: todo
reauthentication-flow:
status: exempt
comment: No user authentication or credential refresh mechanism is used.
test-coverage: done
# Gold
devices: todo
diagnostics:
status: exempt
comment: No runtime data available and configuration contains only sensitive information.
discovery-update-info:
status: exempt
comment: The integration does not support discovery.
discovery:
status: exempt
comment: The integration does not support discovery.
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: done
entity-device-class:
status: exempt
comment: Notify platform does not support device classes, and the event platform does not provide a suitable device class for the entities.
entity-disabled-by-default: done
entity-translations:
status: exempt
comment: Device name is used for main entities
exception-translations: done
icon-translations: done
reconfiguration-flow:
status: exempt
comment: Nothing to reconfigure
repair-issues: done
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo
+1 -1
View File
@@ -6,5 +6,5 @@
"iot_class": "local_push",
"loggers": ["ihcsdk"],
"quality_scale": "legacy",
"requirements": ["defusedxml==0.7.1", "ihcsdk==2.8.5"]
"requirements": ["defusedxml==0.7.1", "ihcsdk==2.8.12"]
}
+13 -191
View File
@@ -1,23 +1,17 @@
"""Provides functionality to interact with infrared devices."""
from abc import abstractmethod
from collections.abc import Callable
from dataclasses import dataclass
from datetime import timedelta
from enum import StrEnum
import logging
from typing import final
from infrared_protocols.commands import Command as InfraredCommand
from propcache.api import cached_property
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, callback
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.deprecation import deprecated_class
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.restore_state import RestoreEntity
@@ -29,32 +23,15 @@ from .const import DOMAIN
__all__ = [
"DOMAIN",
"InfraredEmitterEntity",
"InfraredEmitterEntityDescription",
"InfraredEntity",
"InfraredEntityDescription",
"InfraredReceivedSignal",
"InfraredReceiverEntity",
"InfraredReceiverEntityDescription",
"async_get_emitters",
"async_get_receivers",
"async_send_command",
"async_subscribe_receiver",
]
class InfraredDeviceClass(StrEnum):
"""Device class for infrared entities."""
EMITTER = "emitter"
RECEIVER = "receiver"
_LOGGER = logging.getLogger(__name__)
DATA_COMPONENT: HassKey[
EntityComponent[InfraredEmitterEntity | InfraredReceiverEntity]
] = HassKey(DOMAIN)
DATA_COMPONENT: HassKey[EntityComponent[InfraredEntity]] = HassKey(DOMAIN)
ENTITY_ID_FORMAT = DOMAIN + ".{}"
PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
@@ -63,9 +40,9 @@ SCAN_INTERVAL = timedelta(seconds=30)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the infrared domain."""
component = hass.data[DATA_COMPONENT] = EntityComponent[
InfraredEmitterEntity | InfraredReceiverEntity
](_LOGGER, DOMAIN, hass, SCAN_INTERVAL)
component = hass.data[DATA_COMPONENT] = EntityComponent[InfraredEntity](
_LOGGER, DOMAIN, hass, SCAN_INTERVAL
)
await component.async_setup(config)
return True
@@ -88,25 +65,7 @@ def async_get_emitters(hass: HomeAssistant) -> list[str]:
if component is None:
return []
return [
entity.entity_id
for entity in component.entities
if isinstance(entity, InfraredEmitterEntity)
]
@callback
def async_get_receivers(hass: HomeAssistant) -> list[str]:
"""Get all infrared receiver entity IDs."""
component = hass.data.get(DATA_COMPONENT)
if component is None:
return []
return [
entity.entity_id
for entity in component.entities
if isinstance(entity, InfraredReceiverEntity)
]
return [entity.entity_id for entity in component.entities]
async def async_send_command(
@@ -130,7 +89,7 @@ async def async_send_command(
ent_reg = er.async_get(hass)
entity_id = er.async_validate_entity_id(ent_reg, entity_id_or_uuid)
entity = component.get_entity(entity_id)
if entity is None or not isinstance(entity, InfraredEmitterEntity):
if entity is None:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="entity_not_found",
@@ -143,62 +102,14 @@ async def async_send_command(
await entity.async_send_command_internal(command)
@callback
def async_subscribe_receiver(
hass: HomeAssistant,
entity_id_or_uuid: str,
signal_callback: Callable[[InfraredReceivedSignal], None],
) -> CALLBACK_TYPE:
"""Subscribe to IR signals from a specific receiver entity.
Raises:
HomeAssistantError: If the receiver entity is not found.
"""
component = hass.data.get(DATA_COMPONENT)
if component is None:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="component_not_loaded",
)
ent_reg = er.async_get(hass)
try:
entity_id = er.async_validate_entity_id(ent_reg, entity_id_or_uuid)
except vol.Invalid as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="receiver_not_found",
translation_placeholders={"entity_id": entity_id_or_uuid},
) from err
entity = component.get_entity(entity_id)
if entity is None or not isinstance(entity, InfraredReceiverEntity):
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="receiver_not_found",
translation_placeholders={"entity_id": entity_id},
)
return entity.async_subscribe_received_signal(signal_callback)
class InfraredEntityDescription(EntityDescription, frozen_or_thawed=True):
"""Describes infrared entities."""
@dataclass(frozen=True, slots=True)
class InfraredReceivedSignal:
"""Represents a received IR signal."""
class InfraredEntity(RestoreEntity):
"""Base class for infrared transmitter entities."""
timings: list[int]
modulation: int | None = None
class InfraredEmitterEntityDescription(EntityDescription, frozen_or_thawed=True):
"""Describes infrared emitter entities."""
class InfraredEmitterEntity(RestoreEntity):
"""Base class for infrared emitter entities."""
entity_description: InfraredEmitterEntityDescription
_attr_device_class: InfraredDeviceClass = InfraredDeviceClass.EMITTER
entity_description: InfraredEntityDescription
_attr_should_poll = False
_attr_state: None = None
@@ -238,92 +149,3 @@ class InfraredEmitterEntity(RestoreEntity):
Raises:
HomeAssistantError: If transmission fails.
"""
class InfraredReceiverEntityDescription(EntityDescription, frozen_or_thawed=True):
"""Describes infrared receiver entities."""
class InfraredReceiverEntity(RestoreEntity):
"""Base class for infrared receiver entities."""
entity_description: InfraredReceiverEntityDescription
_attr_device_class: InfraredDeviceClass = InfraredDeviceClass.RECEIVER
_attr_should_poll = False
_attr_state: None = None
__last_signal_received: str | None = None
@cached_property
def __signal_callbacks(self) -> set[Callable[[InfraredReceivedSignal], None]]:
"""Subscriber callback set, lazily initialized on first access."""
return set()
@property
@final
def state(self) -> str | None:
"""Return the entity state."""
return self.__last_signal_received
@final
async def async_internal_added_to_hass(self) -> None:
"""Call when the infrared entity is added to hass."""
await super().async_internal_added_to_hass()
state = await self.async_get_last_state()
if state is not None and state.state not in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
None,
):
self.__last_signal_received = state.state
@final
def _handle_received_signal(self, signal: InfraredReceivedSignal) -> None:
"""Handle a received IR signal.
Should not be overridden. To be called by platform implementations when a
signal is received.
"""
self.__last_signal_received = dt_util.utcnow().isoformat(
timespec="milliseconds"
)
self.async_write_ha_state()
for signal_callback in tuple(self.__signal_callbacks):
try:
signal_callback(signal)
except Exception:
_LOGGER.exception("Error in signal callback for %s", self.entity_id)
@callback
def async_subscribe_received_signal(
self,
signal_callback: Callable[[InfraredReceivedSignal], None],
) -> CALLBACK_TYPE:
"""Subscribe to received IR signals.
Returns a callable to unsubscribe.
"""
callbacks = self.__signal_callbacks
callbacks.add(signal_callback)
@callback
def remove_callback() -> None:
callbacks.discard(signal_callback)
return remove_callback
@deprecated_class(
"homeassistant.components.infrared.InfraredEmitterEntityDescription",
breaks_in_ha_version="2027.6",
)
class InfraredEntityDescription(InfraredEmitterEntityDescription):
"""Deprecated alias for InfraredEmitterEntityDescription."""
@deprecated_class(
"homeassistant.components.infrared.InfraredEmitterEntity",
breaks_in_ha_version="2027.6",
)
class InfraredEntity(InfraredEmitterEntity):
"""Deprecated alias for InfraredEmitterEntity."""
@@ -2,9 +2,6 @@
"entity_component": {
"_": {
"default": "mdi:led-on"
},
"receiver": {
"default": "mdi:led-off"
}
}
}
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/infrared",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["infrared-protocols==4.0.0"]
"requirements": ["infrared-protocols==5.1.0"]
}
@@ -1,21 +1,10 @@
{
"entity_component": {
"_": {
"name": "Infrared emitter"
},
"receiver": {
"name": "Infrared receiver"
}
},
"exceptions": {
"component_not_loaded": {
"message": "Infrared component not loaded"
},
"entity_not_found": {
"message": "Infrared entity `{entity_id}` not found"
},
"receiver_not_found": {
"message": "Infrared receiver entity `{entity_id}` not found"
}
}
}
@@ -55,7 +55,6 @@ from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
COMPONENTS_WITH_DEMO_PLATFORM = [
Platform.BUTTON,
Platform.FAN,
Platform.EVENT,
Platform.IMAGE,
Platform.INFRARED,
Platform.LAWN_MOWER,
@@ -9,7 +9,6 @@ from homeassistant import data_entry_flow
from homeassistant.components.infrared import (
DOMAIN as INFRARED_DOMAIN,
async_get_emitters,
async_get_receivers,
)
from homeassistant.config_entries import (
ConfigEntry,
@@ -23,7 +22,7 @@ from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.selector import EntitySelector, EntitySelectorConfig
from .const import CONF_INFRARED_ENTITY_ID, CONF_INFRARED_RECEIVER_ENTITY_ID, DOMAIN
from .const import CONF_INFRARED_ENTITY_ID, DOMAIN
CONF_BOOLEAN = "bool"
CONF_INT = "int"
@@ -179,36 +178,25 @@ class InfraredFanSubentryFlowHandler(ConfigSubentryFlow):
) -> SubentryFlowResult:
"""User flow to add an infrared fan."""
entities = async_get_emitters(self.hass)
if not entities:
return self.async_abort(reason="no_emitters")
if user_input is not None:
title = user_input.pop("name")
return self.async_create_entry(data=user_input, title=title)
emitter_entities = async_get_emitters(self.hass)
receiver_entities = async_get_receivers(self.hass)
if not emitter_entities and not receiver_entities:
return self.async_abort(reason="no_infrared_entities")
schema_dict: dict[vol.Marker, Any] = {
vol.Required("name"): str,
}
if emitter_entities:
schema_dict[vol.Optional(CONF_INFRARED_ENTITY_ID)] = EntitySelector(
EntitySelectorConfig(
domain=INFRARED_DOMAIN,
include_entities=emitter_entities,
)
)
if receiver_entities:
schema_dict[vol.Optional(CONF_INFRARED_RECEIVER_ENTITY_ID)] = (
EntitySelector(
EntitySelectorConfig(
domain=INFRARED_DOMAIN,
include_entities=receiver_entities,
)
)
)
return self.async_show_form(step_id="user", data_schema=vol.Schema(schema_dict))
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required("name"): str,
vol.Required(CONF_INFRARED_ENTITY_ID): EntitySelector(
EntitySelectorConfig(
domain=INFRARED_DOMAIN,
include_entities=entities,
)
),
}
),
)
@@ -6,14 +6,6 @@ from homeassistant.util.hass_dict import HassKey
DOMAIN = "kitchen_sink"
CONF_INFRARED_ENTITY_ID = "infrared_entity_id"
CONF_INFRARED_RECEIVER_ENTITY_ID = "infrared_receiver_entity_id"
DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey(
f"{DOMAIN}.backup_agent_listeners"
)
INFRARED_FAN_ADDRESS = 0x1234
INFRARED_CMD_POWER_ON = 0x01
INFRARED_CMD_POWER_OFF = 0x02
INFRARED_CMD_SPEED_LOW = 0x03
INFRARED_CMD_SPEED_MEDIUM = 0x04
INFRARED_CMD_SPEED_HIGH = 0x05
@@ -1,151 +0,0 @@
"""Demo platform that offers a fake infrared receiver event entity."""
from infrared_protocols.commands.nec import NECCommand
from homeassistant.components.event import EventEntity
from homeassistant.components.infrared import (
InfraredReceivedSignal,
async_subscribe_receiver,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import (
CALLBACK_TYPE,
Event,
EventStateChangedData,
HomeAssistant,
callback,
)
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event
from .const import (
CONF_INFRARED_RECEIVER_ENTITY_ID,
DOMAIN,
INFRARED_CMD_POWER_OFF,
INFRARED_CMD_POWER_ON,
INFRARED_CMD_SPEED_HIGH,
INFRARED_CMD_SPEED_LOW,
INFRARED_CMD_SPEED_MEDIUM,
INFRARED_FAN_ADDRESS,
)
PARALLEL_UPDATES = 0
COMMAND_EVENTS = {
INFRARED_CMD_POWER_ON: "power_on",
INFRARED_CMD_POWER_OFF: "power_off",
INFRARED_CMD_SPEED_LOW: "speed_low",
INFRARED_CMD_SPEED_MEDIUM: "speed_medium",
INFRARED_CMD_SPEED_HIGH: "speed_high",
}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the demo infrared event platform."""
for subentry_id, subentry in config_entry.subentries.items():
if subentry.subentry_type != "infrared_fan":
continue
if subentry.data.get(CONF_INFRARED_RECEIVER_ENTITY_ID) is None:
continue
async_add_entities(
[
DemoInfraredEvent(
subentry_id=subentry_id,
device_name=subentry.title,
infrared_receiver_entity_id=subentry.data[
CONF_INFRARED_RECEIVER_ENTITY_ID
],
)
],
config_subentry_id=subentry_id,
)
class DemoInfraredEvent(EventEntity):
"""Representation of a demo infrared event entity."""
_attr_has_entity_name = True
_attr_name = "Received IR Event"
_attr_should_poll = False
_attr_event_types = list(COMMAND_EVENTS.values())
def __init__(
self, subentry_id: str, device_name: str, infrared_receiver_entity_id: str
) -> None:
"""Initialize the demo infrared event entity."""
self._receiver_entity_id = infrared_receiver_entity_id
self._attr_unique_id = subentry_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, subentry_id)}, name=device_name
)
async def async_added_to_hass(self) -> None:
"""Subscribe to the IR receiver when added to hass."""
await super().async_added_to_hass()
@callback
def _handle_signal(signal: InfraredReceivedSignal) -> None:
"""Handle a received IR signal."""
command = NECCommand.from_raw_timings(signal.timings)
if command is None or command.address != INFRARED_FAN_ADDRESS:
return
event_type = COMMAND_EVENTS.get(command.command)
if event_type is None:
return
self._trigger_event(event_type, {"raw_code": signal.timings})
self.async_write_ha_state()
remove_signal_subscription: CALLBACK_TYPE | None = None
@callback
def _async_unsubscribe_receiver() -> None:
"""Unsubscribe from the current IR receiver."""
nonlocal remove_signal_subscription
if remove_signal_subscription is None:
return
remove_signal_subscription()
remove_signal_subscription = None
@callback
def _async_update_receiver_subscription(write_state: bool = True) -> None:
"""Update the IR receiver subscription when availability changes."""
nonlocal remove_signal_subscription
ir_state = self.hass.states.get(self._receiver_entity_id)
receiver_available = (
ir_state is not None and ir_state.state != STATE_UNAVAILABLE
)
if not receiver_available:
_async_unsubscribe_receiver()
elif remove_signal_subscription is None:
remove_signal_subscription = async_subscribe_receiver(
self.hass, self._receiver_entity_id, _handle_signal
)
if self._attr_available == receiver_available:
return
self._attr_available = receiver_available
if write_state:
self.async_write_ha_state()
@callback
def _async_ir_state_changed(event: Event[EventStateChangedData]) -> None:
"""Handle infrared entity state changes."""
_async_update_receiver_subscription()
_async_update_receiver_subscription(write_state=False)
self.async_on_remove(_async_unsubscribe_receiver)
self.async_on_remove(
async_track_state_change_event(
self.hass, [self._receiver_entity_id], _async_ir_state_changed
)
)
+15 -18
View File
@@ -13,19 +13,17 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event
from .const import (
CONF_INFRARED_ENTITY_ID,
DOMAIN,
INFRARED_CMD_POWER_OFF,
INFRARED_CMD_POWER_ON,
INFRARED_CMD_SPEED_HIGH,
INFRARED_CMD_SPEED_LOW,
INFRARED_CMD_SPEED_MEDIUM,
INFRARED_FAN_ADDRESS,
)
from .const import CONF_INFRARED_ENTITY_ID, DOMAIN
PARALLEL_UPDATES = 0
DUMMY_FAN_ADDRESS = 0x1234
DUMMY_CMD_POWER_ON = 0x01
DUMMY_CMD_POWER_OFF = 0x02
DUMMY_CMD_SPEED_LOW = 0x03
DUMMY_CMD_SPEED_MEDIUM = 0x04
DUMMY_CMD_SPEED_HIGH = 0x05
async def async_setup_entry(
hass: HomeAssistant,
@@ -36,8 +34,6 @@ async def async_setup_entry(
for subentry_id, subentry in config_entry.subentries.items():
if subentry.subentry_type != "infrared_fan":
continue
if subentry.data.get(CONF_INFRARED_ENTITY_ID) is None:
continue
async_add_entities(
[
DemoInfraredFan(
@@ -107,7 +103,7 @@ class DemoInfraredFan(FanEntity):
async def _send_command(self, command_code: int) -> None:
"""Send an IR command using the NEC protocol."""
command = NECCommand(
address=INFRARED_FAN_ADDRESS,
address=DUMMY_FAN_ADDRESS,
command=command_code,
modulation=38000,
)
@@ -125,13 +121,13 @@ class DemoInfraredFan(FanEntity):
if percentage is not None:
await self.async_set_percentage(percentage)
return
await self._send_command(INFRARED_CMD_POWER_ON)
await self._send_command(DUMMY_CMD_POWER_ON)
self._attr_percentage = 33
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the fan."""
await self._send_command(INFRARED_CMD_POWER_OFF)
await self._send_command(DUMMY_CMD_POWER_OFF)
self._attr_percentage = 0
self.async_write_ha_state()
@@ -142,10 +138,11 @@ class DemoInfraredFan(FanEntity):
return
if percentage <= 33:
await self._send_command(INFRARED_CMD_SPEED_LOW)
await self._send_command(DUMMY_CMD_SPEED_LOW)
elif percentage <= 66:
await self._send_command(INFRARED_CMD_SPEED_MEDIUM)
await self._send_command(DUMMY_CMD_SPEED_MEDIUM)
else:
await self._send_command(INFRARED_CMD_SPEED_HIGH)
await self._send_command(DUMMY_CMD_SPEED_HIGH)
self._attr_percentage = percentage
self.async_write_ha_state()
@@ -3,27 +3,16 @@
from infrared_protocols.commands import Command as InfraredCommand
from homeassistant.components import persistent_notification
from homeassistant.components.infrared import (
InfraredEmitterEntity,
InfraredReceivedSignal,
InfraredReceiverEntity,
)
from homeassistant.components.infrared import InfraredEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN
PARALLEL_UPDATES = 0
INFRARED_COMMAND_SIGNAL = f"{DOMAIN}_infrared_command_signal"
async def async_setup_entry(
hass: HomeAssistant,
@@ -33,60 +22,37 @@ async def async_setup_entry(
"""Set up the demo infrared platform."""
async_add_entities(
[
DemoInfraredEmitter(
unique_id="ir_emitter",
entity_name="Infrared Emitter",
),
DemoInfraredReceiver(
unique_id="ir_receiver",
entity_name="Infrared Receiver",
DemoInfrared(
unique_id="ir_transmitter",
device_name="IR Blaster",
entity_name="Infrared Transmitter",
),
]
)
# pylint: disable=hass-enforce-class-module
class DemoInfraredEntityBase(Entity):
class DemoInfrared(InfraredEntity):
"""Representation of a demo infrared entity."""
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(self, unique_id: str, entity_name: str) -> None:
def __init__(
self,
unique_id: str,
device_name: str,
entity_name: str,
) -> None:
"""Initialize the demo infrared entity."""
super().__init__()
self._attr_unique_id = unique_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, "infrared")}, name="IR Blaster"
identifiers={(DOMAIN, unique_id)},
name=device_name,
)
self._attr_name = entity_name
class DemoInfraredEmitter(DemoInfraredEntityBase, InfraredEmitterEntity):
"""Representation of a demo infrared emitter entity."""
async def async_send_command(self, command: InfraredCommand) -> None:
"""Send an IR command."""
raw_timings = command.get_raw_timings()
persistent_notification.async_create(
self.hass, str(raw_timings), title="Infrared Command Sent"
)
async_dispatcher_send(self.hass, INFRARED_COMMAND_SIGNAL, raw_timings)
class DemoInfraredReceiver(DemoInfraredEntityBase, InfraredReceiverEntity):
"""Representation of a demo infrared receiver entity."""
@callback
def _on_dispatcher_signal(self, raw_timings: list[int]) -> None:
"""Handle received infrared command signal."""
self._handle_received_signal(InfraredReceivedSignal(timings=raw_timings))
async def async_added_to_hass(self) -> None:
"""Called when entity is added to hass."""
await super().async_added_to_hass()
self.async_on_remove(
async_dispatcher_connect(
self.hass, INFRARED_COMMAND_SIGNAL, self._on_dispatcher_signal
)
self.hass, str(command.get_raw_timings()), title="Infrared Command"
)
@@ -35,7 +35,7 @@
},
"infrared_fan": {
"abort": {
"no_infrared_entities": "No infrared entities found. Please set up an infrared device first."
"no_emitters": "No infrared transmitter entities found. Please set up an infrared device first."
},
"entry_type": "Infrared fan",
"initiate_flow": {
@@ -44,11 +44,10 @@
"step": {
"user": {
"data": {
"infrared_entity_id": "Infrared emitter",
"infrared_receiver_entity_id": "Infrared receiver",
"infrared_entity_id": "Infrared transmitter",
"name": "[%key:common::config_flow::data::name%]"
},
"description": "Select infrared devices for the fan."
"description": "Select an infrared transmitter to control the fan."
}
}
}
+1 -1
View File
@@ -9,7 +9,7 @@
"iot_class": "cloud_push",
"loggers": ["pymiele"],
"quality_scale": "platinum",
"requirements": ["pymiele==0.6.1"],
"requirements": ["pymiele==0.6.2"],
"single_config_entry": true,
"zeroconf": ["_mieleathome._tcp.local."]
}
@@ -6,5 +6,5 @@
"iot_class": "calculated",
"loggers": ["pyzbar"],
"quality_scale": "legacy",
"requirements": ["Pillow==12.2.0", "pyzbar==0.1.7"]
"requirements": ["Pillow==12.2.0", "pyzbar==0.1.9"]
}
@@ -4,5 +4,5 @@
"codeowners": ["@fabaff"],
"documentation": "https://www.home-assistant.io/integrations/serial",
"iot_class": "local_polling",
"requirements": ["serialx==1.7.2"]
"requirements": ["serialx==1.7.3"]
}
@@ -20,8 +20,8 @@ class SlideEntity(CoordinatorEntity[SlideCoordinator]):
manufacturer="Innovation in Motion",
connections={(dr.CONNECTION_NETWORK_MAC, coordinator.data["mac"])},
name=coordinator.data["device_name"],
sw_version=coordinator.api_version,
hw_version=coordinator.data["board_rev"],
sw_version=str(coordinator.api_version),
hw_version=str(coordinator.data["board_rev"]),
serial_number=coordinator.data["mac"],
configuration_url=f"http://{coordinator.host}",
)
@@ -36,8 +36,8 @@ class SmartyCoordinator(DataUpdateCoordinator[None]):
async def _async_setup(self) -> None:
if not await self.hass.async_add_executor_job(self.client.update):
raise UpdateFailed("Failed to update Smarty data")
self.software_version = self.client.get_software_version()
self.configuration_version = self.client.get_configuration_version()
self.software_version = str(self.client.get_software_version())
self.configuration_version = str(self.client.get_configuration_version())
async def _async_update_data(self) -> None:
"""Fetch data from Smarty."""
+3 -3
View File
@@ -3,7 +3,7 @@
from pysmlight.exceptions import SmlightError
from pysmlight.models import IRPayload
from homeassistant.components.infrared import InfraredCommand, InfraredEmitterEntity
from homeassistant.components.infrared import InfraredCommand, InfraredEntity
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -27,8 +27,8 @@ async def async_setup_entry(
async_add_entities([SmInfraredEntity(coordinator)])
class SmInfraredEntity(SmEntity, InfraredEmitterEntity):
"""Representation of a SLZB-Ultima infrared emitter."""
class SmInfraredEntity(SmEntity, InfraredEntity):
"""Representation of a SLZB-Ultima infrared."""
_attr_translation_key = "infrared_emitter"
@@ -88,6 +88,14 @@ BINARY_SENSORS: dict[DeviceCategory, tuple[TuyaBinarySensorEntityDescription, ..
bitmap_key="tankfull",
translation_key="tankfull",
),
TuyaBinarySensorEntityDescription(
key=f"{DPCode.FAULT}_FULL",
dpcode=DPCode.FAULT,
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
bitmap_key="FULL",
translation_key="tankfull",
),
TuyaBinarySensorEntityDescription(
key="defrost",
dpcode=DPCode.FAULT,
@@ -96,6 +104,14 @@ BINARY_SENSORS: dict[DeviceCategory, tuple[TuyaBinarySensorEntityDescription, ..
bitmap_key="defrost",
translation_key="defrost",
),
TuyaBinarySensorEntityDescription(
key=f"{DPCode.FAULT}_COIL",
dpcode=DPCode.FAULT,
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
bitmap_key="COIL",
translation_key="coil_freeze",
),
TuyaBinarySensorEntityDescription(
key="wet",
dpcode=DPCode.FAULT,
@@ -104,6 +120,54 @@ BINARY_SENSORS: dict[DeviceCategory, tuple[TuyaBinarySensorEntityDescription, ..
bitmap_key="wet",
translation_key="wet",
),
TuyaBinarySensorEntityDescription(
key=f"{DPCode.FAULT}_Cleaning",
dpcode=DPCode.FAULT,
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
bitmap_key="Cleaning",
translation_key="filter_cleaning",
),
TuyaBinarySensorEntityDescription(
key=f"{DPCode.FAULT}_E1",
dpcode=DPCode.FAULT,
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
bitmap_key="E1",
translation_key="temp_error",
),
TuyaBinarySensorEntityDescription(
key=f"{DPCode.FAULT}_CL",
dpcode=DPCode.FAULT,
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
bitmap_key="CL",
translation_key="low_temp",
),
TuyaBinarySensorEntityDescription(
key=f"{DPCode.FAULT}_CH",
dpcode=DPCode.FAULT,
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
bitmap_key="CH",
translation_key="high_temp",
),
TuyaBinarySensorEntityDescription(
key=f"{DPCode.FAULT}_LO",
dpcode=DPCode.FAULT,
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
bitmap_key="LO",
translation_key="low_humidity",
),
TuyaBinarySensorEntityDescription(
key=f"{DPCode.FAULT}_MOTOR",
dpcode=DPCode.FAULT,
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
bitmap_key="MOTOR",
translation_key="motor_fault",
),
),
DeviceCategory.CWWSQ: (
TuyaBinarySensorEntityDescription(
+5
View File
@@ -658,6 +658,7 @@ class DPCode(StrEnum):
COLOUR_DATA = "colour_data" # Colored light mode
COLOUR_DATA_HSV = "colour_data_hsv" # Colored light mode
COLOUR_DATA_V2 = "colour_data_v2" # Colored light mode
COMPRESSOR_STRENGTH = "compressor_strength"
CONCENTRATION_SET = "concentration_set" # Concentration setting
CONTROL = "control"
CONTROL_2 = "control_2"
@@ -911,8 +912,10 @@ class DPCode(StrEnum):
TARGET_DIS_CLOSEST = "target_dis_closest" # Closest target distance
TDS_IN = "tds_in" # Total dissolved solids
TEMP = "temp" # Temperature setting
TEMP_AROUND = "temp_around" # Current around (outside) temperature
TEMP_BOILING_C = "temp_boiling_c"
TEMP_BOILING_F = "temp_boiling_f"
TEMP_COILER = "temp_coiler" # Current coil temperature
TEMP_CONTROLLER = "temp_controller"
TEMP_CORRECTION = "temp_correction"
TEMP_CURRENT = "temp_current" # Current temperature in °C
@@ -933,12 +936,14 @@ class DPCode(StrEnum):
"temp_current_external_f" # Current external temperature in Fahrenheit
)
TEMP_CURRENT_F = "temp_current_f" # Current temperature in °F
TEMP_EFFLUENT = "temp_effluent" # Current flow temperature
TEMP_INDOOR = "temp_indoor" # Indoor temperature in °C
TEMP_SET = "temp_set" # Set the temperature in °C
TEMP_SET_F = "temp_set_f" # Set the temperature in °F
TEMP_UNIT_CONVERT = "temp_unit_convert" # Temperature unit switching
TEMP_VALUE = "temp_value" # Color temperature
TEMP_VALUE_V2 = "temp_value_v2"
TEMP_VENTING = "temp_venting" # Current heat plate temperature
TEMPER_ALARM = "temper_alarm" # Tamper alarm
TIME_TOTAL = "time_total"
TIME_USE = "time_use" # Total seconds of irrigation
+29
View File
@@ -1613,12 +1613,41 @@ SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = {
),
),
DeviceCategory.ZNRB: (
TuyaSensorEntityDescription(
key=DPCode.COMPRESSOR_STRENGTH,
translation_key="compressor_strength",
state_class=SensorStateClass.MEASUREMENT,
),
TuyaSensorEntityDescription(
key=DPCode.TEMP_AROUND,
translation_key="outside_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
TuyaSensorEntityDescription(
key=DPCode.TEMP_COILER,
translation_key="coil_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
TuyaSensorEntityDescription(
key=DPCode.TEMP_CURRENT,
translation_key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
TuyaSensorEntityDescription(
key=DPCode.TEMP_EFFLUENT,
translation_key="flow_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
TuyaSensorEntityDescription(
key=DPCode.TEMP_VENTING,
translation_key="heat_exchanger_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
),
DeviceCategory.ZWJCY: (
TuyaSensorEntityDescription(
@@ -36,6 +36,9 @@
"carbon_monoxide": {
"name": "Carbon monoxide"
},
"coil_freeze": {
"name": "Coil freeze/defrost"
},
"cover_off": {
"name": "Cover off"
},
@@ -48,12 +51,27 @@
"feeding": {
"name": "Feeding"
},
"filter_cleaning": {
"name": "Filter cleaning"
},
"formaldehyde": {
"name": "Formaldehyde"
},
"high_temp": {
"name": "High temperature"
},
"low_humidity": {
"name": "Low humidity"
},
"low_temp": {
"name": "Low temperature"
},
"methane": {
"name": "Methane"
},
"motor_fault": {
"name": "Motor fault"
},
"pm25": {
"name": "PM2.5"
},
@@ -63,6 +81,9 @@
"tankfull": {
"name": "Tank full"
},
"temp_error": {
"name": "Temperature error"
},
"tilt": {
"name": "Tilt"
},
@@ -655,6 +676,12 @@
"cleaning_time": {
"name": "Cleaning time"
},
"coil_temperature": {
"name": "Coil temperature"
},
"compressor_strength": {
"name": "Compressor strength"
},
"concentration_carbon_dioxide": {
"name": "Concentration of carbon dioxide"
},
@@ -691,12 +718,18 @@
"filter_utilization": {
"name": "Filter utilization"
},
"flow_temperature": {
"name": "Flow temperature"
},
"formaldehyde": {
"name": "[%key:component::tuya::entity::binary_sensor::formaldehyde::name%]"
},
"gas": {
"name": "Gas"
},
"heat_exchanger_temperature": {
"name": "Heat exchanger temperature"
},
"heat_index_temperature": {
"name": "Heat index"
},
@@ -779,6 +812,9 @@
"work": "Working"
}
},
"outside_temperature": {
"name": "Outside temperature"
},
"oxydo_reduction_potential": {
"name": "Oxydo reduction potential"
},
+6
View File
@@ -901,6 +901,12 @@ SWITCHES: dict[DeviceCategory, tuple[SwitchEntityDescription, ...]] = {
),
),
DeviceCategory.ZNRB: (
SwitchEntityDescription(
key=DPCode.CHILD_LOCK,
translation_key="child_lock",
icon="mdi:account-lock",
entity_category=EntityCategory.CONFIG,
),
SwitchEntityDescription(
key=DPCode.SWITCH,
translation_key="switch",
+1 -1
View File
@@ -7,5 +7,5 @@
"integration_type": "system",
"iot_class": "local_push",
"quality_scale": "internal",
"requirements": ["aiousbwatcher==1.1.2", "serialx==1.7.2"]
"requirements": ["aiousbwatcher==1.1.2", "serialx==1.7.3"]
}
@@ -984,6 +984,14 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
value_getter=lambda api: api.getTemperature(),
),
ViCareSensorEntityDescription(
key="target_temperature",
translation_key="target_temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
value_getter=lambda api: api.getTargetTemperature(),
),
ViCareSensorEntityDescription(
key="room_humidity",
device_class=SensorDeviceClass.HUMIDITY,
@@ -604,6 +604,9 @@
"supply_temperature": {
"name": "Supply temperature"
},
"target_temperature": {
"name": "Target temperature"
},
"valve_position": {
"name": "Valve position"
},
+15 -1
View File
@@ -12,8 +12,13 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
from homeassistant.helpers import (
aiohttp_client,
config_entry_oauth2_flow,
config_validation as cv,
)
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, SUPPORTED_DEVICE_TYPES
from .coordinator import (
@@ -21,11 +26,20 @@ from .coordinator import (
WattsVisionDeviceData,
WattsVisionHubCoordinator,
)
from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SWITCH]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Watts Vision component."""
async_setup_services(hass)
return True
@dataclass
class WattsVisionRuntimeData:
+44 -1
View File
@@ -1,8 +1,10 @@
"""Climate platform for Watts Vision integration."""
from datetime import timedelta
import logging
from typing import Any
from visionpluspython.exceptions import WattsVisionError
from visionpluspython.models import ThermostatDevice, ThermostatMode
from homeassistant.components.climate import (
@@ -13,7 +15,7 @@ from homeassistant.components.climate import (
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -194,6 +196,47 @@ class WattsVisionClimate(WattsVisionEntity[ThermostatDevice], ClimateEntity):
await self.coordinator.async_refresh()
async def async_activate_timer_mode(
self, temperature: float, duration: timedelta
) -> None:
"""Activate timer mode with a target temperature and duration."""
if not self._attr_min_temp <= temperature <= self._attr_max_temp:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="timer_temperature_out_of_range",
translation_placeholders={
"temperature": str(temperature),
"min_temp": str(self._attr_min_temp),
"max_temp": str(self._attr_max_temp),
},
)
duration_minutes, remainder = divmod(duration, timedelta(minutes=1))
if remainder:
duration_minutes += 1
try:
await self.coordinator.client.activate_thermostat_timer(
self.device_id, temperature, duration_minutes
)
except (WattsVisionError, ValueError, RuntimeError) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="activate_timer_mode_error",
) from err
_LOGGER.debug(
"Successfully activated timer mode: %s%s for %d min on %s",
temperature,
self.temperature_unit,
duration_minutes,
self.device_id,
)
self.coordinator.trigger_fast_polling()
await self.coordinator.async_refresh()
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
mode = HVAC_MODE_TO_THERMOSTAT[hvac_mode]
+4
View File
@@ -67,3 +67,7 @@ HVAC_ACTION_TO_HA: dict[str, HVACAction] = {
}
SUPPORTED_DEVICE_TYPES = (ThermostatDevice, SwitchDevice)
# Timer service
SERVICE_ACTIVATE_TIMER_MODE = "activate_timer_mode"
ATTR_DURATION = "duration"
@@ -14,5 +14,10 @@
}
}
}
},
"services": {
"activate_timer_mode": {
"service": "mdi:timer"
}
}
}
@@ -0,0 +1,31 @@
"""Services for Watts Vision integration."""
from datetime import timedelta
import voluptuous as vol
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
from homeassistant.const import ATTR_TEMPERATURE
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, service
from .const import ATTR_DURATION, DOMAIN, SERVICE_ACTIVATE_TIMER_MODE
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up services for the Watts Vision integration."""
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_ACTIVATE_TIMER_MODE,
entity_domain=CLIMATE_DOMAIN,
schema={
vol.Required(ATTR_TEMPERATURE): vol.Coerce(float),
vol.Required(ATTR_DURATION): vol.All(
cv.time_period,
vol.Range(min=timedelta(minutes=1), max=timedelta(days=1)),
),
},
func="async_activate_timer_mode",
)
@@ -0,0 +1,18 @@
activate_timer_mode:
target:
entity:
domain: climate
integration: watts
fields:
temperature:
required: true
selector:
number:
step: 0.5
unit_of_measurement: "°"
mode: box
duration:
required: true
selector:
duration:
enable_second: false
@@ -45,6 +45,9 @@
}
},
"exceptions": {
"activate_timer_mode_error": {
"message": "An error occurred while activating timer mode"
},
"authentication_failed": {
"message": "Authentication failed"
},
@@ -83,6 +86,25 @@
},
"temporary_connection_error": {
"message": "Temporary connection error"
},
"timer_temperature_out_of_range": {
"message": "Timer temperature {temperature} is out of range ({min_temp}-{max_temp})"
}
},
"services": {
"activate_timer_mode": {
"description": "Activates timer mode on the thermostat for a specified temperature and duration.",
"fields": {
"duration": {
"description": "Duration of the timer.",
"name": "Duration"
},
"temperature": {
"description": "Target temperature while the timer is active.",
"name": "Temperature"
}
},
"name": "Activate timer mode"
}
}
}
+12 -3
View File
@@ -20,6 +20,7 @@ from homeassistant.components.backup import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.json import json_dumps
from homeassistant.util.async_ import gather_with_limited_concurrency
from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads_object
from . import WebDavConfigEntry
@@ -29,6 +30,7 @@ _LOGGER = logging.getLogger(__name__)
BACKUP_TIMEOUT = ClientTimeout(connect=10, total=43200)
CACHE_TTL = 300
METADATA_DOWNLOAD_CONCURRENCY = 4
async def async_get_backup_agents(
@@ -237,11 +239,18 @@ class WebDavBackupAgent(BackupAgent):
async def _list_metadata_files() -> dict[str, AgentBackup]:
"""List metadata files."""
files = await self._client.list_files(self._backup_path)
metadata_contents = await gather_with_limited_concurrency(
METADATA_DOWNLOAD_CONCURRENCY,
*(
_download_metadata(file_name)
for file_name in files
if file_name.endswith(".metadata.json")
),
)
return {
metadata_content.backup_id: metadata_content
for file_name in files
if file_name.endswith(".metadata.json")
if (metadata_content := await _download_metadata(file_name))
for metadata_content in metadata_contents
if metadata_content
}
self._cache_metadata_files = await _list_metadata_files()
+4 -43
View File
@@ -9,10 +9,9 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, LOGGER
from .const import DOMAIN
from .discovery import ZwaveDiscoveryInfo
from .entity import ZWaveBaseEntity
from .helpers import get_device_info, get_valueless_base_unique_id
from .entity import ZWaveBaseEntity, ZWaveNodeBaseEntity
from .models import ZwaveJSConfigEntry
PARALLEL_UPDATES = 0
@@ -78,54 +77,16 @@ class ZwaveBooleanNodeButton(ZWaveBaseEntity, ButtonEntity):
await self._async_set_value(self.info.primary_value, True)
class ZWaveNodePingButton(ButtonEntity):
class ZWaveNodePingButton(ZWaveNodeBaseEntity, ButtonEntity):
"""Representation of a ping button entity."""
_attr_should_poll = False
_attr_entity_category = EntityCategory.CONFIG
_attr_has_entity_name = True
_attr_translation_key = "ping"
def __init__(self, driver: Driver, node: ZwaveNode) -> None:
"""Initialize a ping Z-Wave device button entity."""
self.node = node
# Entity class attributes
self._base_unique_id = get_valueless_base_unique_id(driver, node)
super().__init__(driver, node)
self._attr_unique_id = f"{self._base_unique_id}.ping"
# device may not be precreated in main handler yet
self._attr_device_info = get_device_info(driver, node)
async def async_poll_value(self, _: bool) -> None:
"""Poll a value."""
# We log an error instead of raising an exception because this service call occurs
# in a separate task since it is called via the dispatcher and we don't want to
# raise the exception in that separate task because it is confusing to the user.
LOGGER.error(
"There is no value to refresh for this entity so the zwave_js.refresh_value"
" service won't work for it"
)
async def async_added_to_hass(self) -> None:
"""Call when entity is added."""
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{self.unique_id}_poll_value",
self.async_poll_value,
)
)
# we don't listen for `remove_entity_on_ready_node` signal because this entity
# is created when the node is added which occurs before ready. It only needs to
# be removed if the node is removed from the network.
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{self._base_unique_id}_remove_entity",
self.async_remove,
)
)
async def async_press(self) -> None:
"""Press the button."""
+69 -1
View File
@@ -6,6 +6,7 @@ from typing import Any
from zwave_js_server.exceptions import BaseZwaveJSServerError
from zwave_js_server.model.driver import Driver
from zwave_js_server.model.node import Node as ZwaveNode
from zwave_js_server.model.value import (
SetValueResult,
Value as ZwaveValue,
@@ -28,7 +29,12 @@ from .const import (
LOGGER,
)
from .discovery_data_template import BaseDiscoverySchemaDataTemplate
from .helpers import get_device_id, get_unique_id, get_valueless_base_unique_id
from .helpers import (
get_device_id,
get_device_info,
get_unique_id,
get_valueless_base_unique_id,
)
from .models import PlatformZwaveDiscoveryInfo, ZwaveDiscoveryInfo, ZwaveJSConfigEntry
@@ -426,3 +432,65 @@ class ZWaveBaseEntity(Entity):
raise HomeAssistantError(
f"Unable to set value {value.value_id}: {err}"
) from err
class ZWaveNodeBaseEntity(Entity):
"""Base entity class for Z-Wave node-level (non-value) entities.
Used for entities that exist for the whole node rather than a specific
Z-Wave Value (e.g. firmware update, ping button, node status sensor).
"""
_attr_has_entity_name = True
_attr_should_poll = False
# Subclasses can opt in to also being removed when a node starts a
# reinterview. Useful for entities whose existence depends on CCs that
# may disappear during reinterview.
_remove_on_reinterview = False
def __init__(self, driver: Driver, node: ZwaveNode) -> None:
"""Initialize a Z-Wave node-level entity."""
self.driver = driver
self.node = node
self._base_unique_id = get_valueless_base_unique_id(driver, node)
# device may not be precreated in main handler yet
self._attr_device_info = get_device_info(driver, node)
async def async_poll_value(self, _: bool) -> None:
"""Poll a value (no-op for entities not backed by a Z-Wave Value)."""
# We log an error instead of raising an exception because this service
# call occurs in a separate task since it is called via the dispatcher
# and we don't want to raise the exception in that separate task because
# it is confusing to the user.
LOGGER.error(
"There is no value to refresh for %s so the zwave_js.refresh_value"
" service won't work for it",
self.entity_id,
)
async def async_added_to_hass(self) -> None:
"""Call when entity is added."""
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{self.unique_id}_poll_value",
self.async_poll_value,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{self._base_unique_id}_remove_entity",
self.async_remove,
)
)
if self._remove_on_reinterview:
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{self._base_unique_id}_remove_entity_on_interview_started",
self.async_remove,
)
)
+13 -109
View File
@@ -116,8 +116,7 @@ from .discovery_data_template import (
NumericSensorDataTemplate,
NumericSensorDataTemplateData,
)
from .entity import NewZwaveDiscoveryInfo, ZWaveBaseEntity
from .helpers import get_device_info, get_valueless_base_unique_id
from .entity import NewZwaveDiscoveryInfo, ZWaveBaseEntity, ZWaveNodeBaseEntity
from .migrate import async_migrate_statistics_sensors
from .models import (
NewZWaveDiscoverySchema,
@@ -1033,36 +1032,19 @@ class ZWaveConfigParameterSensor(ZWaveListSensor):
return {ATTR_VALUE: value}
class ZWaveNodeStatusSensor(SensorEntity):
class ZWaveNodeStatusSensor(ZWaveNodeBaseEntity, SensorEntity):
"""Representation of a node status sensor."""
_attr_should_poll = False
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_has_entity_name = True
_attr_translation_key = "node_status"
def __init__(
self, config_entry: ZwaveJSConfigEntry, driver: Driver, node: ZwaveNode
) -> None:
"""Initialize a generic Z-Wave device entity."""
super().__init__(driver, node)
self.config_entry = config_entry
self.node = node
# Entity class attributes
self._base_unique_id = get_valueless_base_unique_id(driver, node)
self._attr_unique_id = f"{self._base_unique_id}.node_status"
# device may not be precreated in main handler yet
self._attr_device_info = get_device_info(driver, node)
async def async_poll_value(self, _: bool) -> None:
"""Poll a value."""
# We log an error instead of raising an exception because this service call occurs
# in a separate task since it is called via the dispatcher and we don't want to
# raise the exception in that separate task because it is confusing to the user.
LOGGER.error(
"There is no value to refresh for this entity so the zwave_js.refresh_value"
" service won't work for it"
)
@callback
def _status_changed(self, _: dict) -> None:
@@ -1072,60 +1054,27 @@ class ZWaveNodeStatusSensor(SensorEntity):
async def async_added_to_hass(self) -> None:
"""Call when entity is added."""
# Add value_changed callbacks.
await super().async_added_to_hass()
for evt in ("wake up", "sleep", "dead", "alive"):
self.async_on_remove(self.node.on(evt, self._status_changed))
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{self.unique_id}_poll_value",
self.async_poll_value,
)
)
# we don't listen for `remove_entity_on_ready_node` signal because this entity
# is created when the node is added which occurs before ready. It only needs to
# be removed if the node is removed from the network.
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{self._base_unique_id}_remove_entity",
self.async_remove,
)
)
self._attr_native_value: str = self.node.status.name.lower()
self.async_write_ha_state()
class ZWaveControllerStatusSensor(SensorEntity):
class ZWaveControllerStatusSensor(ZWaveNodeBaseEntity, SensorEntity):
"""Representation of a controller status sensor."""
_attr_should_poll = False
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_has_entity_name = True
_attr_translation_key = "controller_status"
def __init__(self, config_entry: ZwaveJSConfigEntry, driver: Driver) -> None:
"""Initialize a generic Z-Wave device entity."""
self.config_entry = config_entry
self.controller = driver.controller
node = self.controller.own_node
assert node
# Entity class attributes
self._base_unique_id = get_valueless_base_unique_id(driver, node)
super().__init__(driver, node)
self.config_entry = config_entry
self._attr_unique_id = f"{self._base_unique_id}.controller_status"
# device may not be precreated in main handler yet
self._attr_device_info = get_device_info(driver, node)
async def async_poll_value(self, _: bool) -> None:
"""Poll a value."""
# We log an error instead of raising an exception because this service call occurs
# in a separate task since it is called via the dispatcher and we don't want to
# raise the exception in that separate task because it is confusing to the user.
LOGGER.error(
"There is no value to refresh for this entity so the zwave_js.refresh_value"
" service won't work for it"
)
@callback
def _status_changed(self, _: dict) -> None:
@@ -1135,34 +1084,16 @@ class ZWaveControllerStatusSensor(SensorEntity):
async def async_added_to_hass(self) -> None:
"""Call when entity is added."""
# Add value_changed callbacks.
await super().async_added_to_hass()
self.async_on_remove(self.controller.on("status changed", self._status_changed))
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{self.unique_id}_poll_value",
self.async_poll_value,
)
)
# we don't listen for `remove_entity_on_ready_node` signal because this is not
# a regular node
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{self._base_unique_id}_remove_entity",
self.async_remove,
)
)
self._attr_native_value: str = self.controller.status.name.lower()
class ZWaveStatisticsSensor(SensorEntity):
class ZWaveStatisticsSensor(ZWaveNodeBaseEntity, SensorEntity):
"""Representation of a node/controller statistics sensor."""
entity_description: ZWaveJSStatisticsSensorEntityDescription
_attr_should_poll = False
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_has_entity_name = True
def __init__(
self,
@@ -1172,8 +1103,6 @@ class ZWaveStatisticsSensor(SensorEntity):
description: ZWaveJSStatisticsSensorEntityDescription,
) -> None:
"""Initialize a Z-Wave statistics entity."""
self.entity_description = description
self.config_entry = config_entry
self.statistics_src = statistics_src
node = (
statistics_src.own_node
@@ -1181,22 +1110,10 @@ class ZWaveStatisticsSensor(SensorEntity):
else statistics_src
)
assert node
# Entity class attributes
self._base_unique_id = get_valueless_base_unique_id(driver, node)
super().__init__(driver, node)
self.entity_description = description
self.config_entry = config_entry
self._attr_unique_id = f"{self._base_unique_id}.statistics_{description.key}"
# device may not be precreated in main handler yet
self._attr_device_info = get_device_info(driver, node)
async def async_poll_value(self, _: bool) -> None:
"""Poll a value."""
# We log an error instead of raising an exception because this service call occurs
# in a separate task since it is called via the dispatcher and we don't want to
# raise the exception in that separate task because it is confusing to the user.
LOGGER.error(
"There is no value to refresh for this entity so the zwave_js.refresh_value"
" service won't work for it"
)
@callback
def _statistics_updated(self, event_data: dict) -> None:
@@ -1226,20 +1143,7 @@ class ZWaveStatisticsSensor(SensorEntity):
async def async_added_to_hass(self) -> None:
"""Call when entity is added."""
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{self.unique_id}_poll_value",
self.async_poll_value,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{self._base_unique_id}_remove_entity",
self.async_remove,
)
)
await super().async_added_to_hass()
self.async_on_remove(
self.statistics_src.on("statistics updated", self._statistics_updated)
)
+5 -44
View File
@@ -34,7 +34,7 @@ from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.restore_state import ExtraStoredData
from .const import API_KEY_FIRMWARE_UPDATE_SERVICE, DOMAIN, LOGGER
from .helpers import get_device_info, get_valueless_base_unique_id
from .entity import ZWaveNodeBaseEntity
from .models import ZwaveJSConfigEntry
PARALLEL_UPDATES = 1
@@ -162,12 +162,10 @@ async def async_setup_entry(
)
class ZWaveFirmwareUpdateEntity(UpdateEntity):
class ZWaveFirmwareUpdateEntity(ZWaveNodeBaseEntity, UpdateEntity):
"""Representation of a firmware update entity."""
driver: Driver
entity_description: ZWaveUpdateEntityDescription
node: ZwaveNode
_attr_entity_category = EntityCategory.CONFIG
_attr_device_class = UpdateDeviceClass.FIRMWARE
_attr_supported_features = (
@@ -175,8 +173,7 @@ class ZWaveFirmwareUpdateEntity(UpdateEntity):
| UpdateEntityFeature.RELEASE_NOTES
| UpdateEntityFeature.PROGRESS
)
_attr_has_entity_name = True
_attr_should_poll = False
_remove_on_reinterview = True
def __init__(
self,
@@ -186,9 +183,8 @@ class ZWaveFirmwareUpdateEntity(UpdateEntity):
entity_description: ZWaveUpdateEntityDescription,
) -> None:
"""Initialize a Z-Wave device firmware update entity."""
self.driver = driver
super().__init__(driver, node)
self.entity_description = entity_description
self.node = node
self._latest_version_firmware: FirmwareUpdateInfo | None = None
self._poll_unsub: Callable[[], None] | None = None
self._progress_unsub: Callable[[], None] | None = None
@@ -199,11 +195,8 @@ class ZWaveFirmwareUpdateEntity(UpdateEntity):
# Entity class attributes
self._attr_name = "Firmware"
self._base_unique_id = get_valueless_base_unique_id(driver, node)
self._attr_unique_id = f"{self._base_unique_id}.firmware_update"
self._attr_installed_version = node.firmware_version
# device may not be precreated in main handler yet
self._attr_device_info = get_device_info(driver, node)
@property
def extra_restore_state_data(self) -> ZWaveFirmwareUpdateExtraStoredData:
@@ -345,41 +338,9 @@ class ZWaveFirmwareUpdateEntity(UpdateEntity):
self._latest_version_firmware = None
self._unsub_firmware_events_and_reset_progress()
async def async_poll_value(self, _: bool) -> None:
"""Poll a value."""
# We log an error instead of raising an exception because this service call occurs
# in a separate task since it is called via the dispatcher and we don't want to
# raise the exception in that separate task because it is confusing to the user.
LOGGER.error(
"There is no value to refresh for this entity so the zwave_js.refresh_value"
" service won't work for it"
)
async def async_added_to_hass(self) -> None:
"""Call when entity is added."""
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{self.unique_id}_poll_value",
self.async_poll_value,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{self._base_unique_id}_remove_entity",
self.async_remove,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{self._base_unique_id}_remove_entity_on_interview_started",
self.async_remove,
)
)
await super().async_added_to_hass()
# Make sure these variables are set for the elif evaluation
state = None
+42 -16
View File
@@ -1,7 +1,6 @@
"""The exceptions used by Home Assistant."""
from collections.abc import Callable, Generator, Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from aiohttp import ClientResponse, ClientResponseError, RequestInfo
@@ -36,6 +35,9 @@ class HomeAssistantError(Exception):
_message: str | None = None
generate_message: bool = False
translation_domain: str | None = None
translation_key: str | None = None
translation_placeholders: dict[str, str] | None = None
def __init__(
self,
@@ -127,11 +129,13 @@ class TemplateError(HomeAssistantError):
super().__init__(f"{exception.__class__.__name__}: {exception}")
@dataclass(slots=True)
class ConditionError(HomeAssistantError):
"""Error during condition evaluation."""
type: str
def __init__(self, type: str) -> None:
"""Initialize condition error."""
super().__init__()
self.type = type
@staticmethod
def _indent(indent: int, message: str) -> str:
@@ -147,28 +151,45 @@ class ConditionError(HomeAssistantError):
return "\n".join(list(self.output(indent=0)))
@dataclass(slots=True)
class ConditionErrorMessage(ConditionError):
"""Condition error message."""
# A message describing this error
message: str
def __init__(self, type: str, message: str) -> None:
"""Initialize condition error with a message.
Args:
message: A message describing the error.
"""
super().__init__(type)
self.message = message
def output(self, indent: int) -> Generator[str]:
"""Yield an indented representation."""
yield self._indent(indent, f"In '{self.type}' condition: {self.message}")
@dataclass(slots=True)
class ConditionErrorIndex(ConditionError):
"""Condition error with index."""
# The zero-based index of the failed condition, for conditions with multiple parts
index: int
# The total number of parts in this condition, including non-failed parts
total: int
# The error that this error wraps
error: ConditionError
def __init__(
self,
type: str,
*,
index: int,
total: int,
error: ConditionError,
) -> None:
"""Initialize condition error with index.
Args:
index: The zero-based index of the failed condition, for conditions with multiple parts.
total: The total number of parts in this condition, including non-failed parts.
error: The error that this error wraps.
"""
super().__init__(type)
self.index = index
self.total = total
self.error = error
def output(self, indent: int) -> Generator[str]:
"""Yield an indented representation."""
@@ -182,12 +203,17 @@ class ConditionErrorIndex(ConditionError):
yield from self.error.output(indent + 1)
@dataclass(slots=True)
class ConditionErrorContainer(ConditionError):
"""Condition error with subconditions."""
# List of ConditionErrors that this error wraps
errors: Sequence[ConditionError]
def __init__(self, type: str, *, errors: Sequence[ConditionError]) -> None:
"""Initialize condition error container.
Args:
errors: List of ConditionErrors that this error wraps.
"""
super().__init__(type)
self.errors = errors
def output(self, indent: int) -> Generator[str]:
"""Yield an indented representation."""
+5
View File
@@ -63,6 +63,11 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [
"manufacturer_id": 1794,
"service_uuid": "0000fce0-0000-1000-8000-00805f9b34fb",
},
{
"domain": "avea",
"local_name": "Avea*",
"service_uuid": "f815e810-456c-6761-746f-4d756e696368",
},
{
"connectable": False,
"domain": "bluemaestro",
+2
View File
@@ -84,6 +84,7 @@ FLOWS = {
"aussie_broadband",
"autarco",
"autoskope",
"avea",
"awair",
"aws_s3",
"axis",
@@ -190,6 +191,7 @@ FLOWS = {
"ekeybionyx",
"electrasmart",
"electric_kiwi",
"electrolux",
"elevenlabs",
"elgato",
"elkm1",
+8 -2
View File
@@ -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",
@@ -1712,8 +1718,8 @@
"name": "Elgato",
"integrations": {
"avea": {
"integration_type": "hub",
"config_flow": false,
"integration_type": "device",
"config_flow": true,
"iot_class": "local_polling",
"name": "Elgato Avea"
},
+1 -1
View File
@@ -1770,7 +1770,7 @@ def _base_condition_validator(value: Any) -> Any:
vol.Schema(
{
**CONDITION_BASE_SCHEMA,
CONF_CONDITION: vol.All(str, vol.NotIn(BUILT_IN_CONDITIONS)),
vol.Required(CONF_CONDITION): vol.All(str, vol.NotIn(BUILT_IN_CONDITIONS)),
},
extra=vol.ALLOW_EXTRA,
)(value)
+6 -5
View File
@@ -193,8 +193,9 @@ class Debouncer[_R_co]:
@callback
def _schedule_timer(self) -> None:
"""Schedule a timer."""
if not self._shutdown_requested:
self._timer_task = self.hass.loop.call_later(
self.cooldown, self._on_debounce
)
"""Schedule a timer, cancelling any previously-scheduled handle."""
if self._shutdown_requested:
return
if self._timer_task is not None:
self._timer_task.cancel()
self._timer_task = self.hass.loop.call_later(self.cooldown, self._on_debounce)
+86 -42
View File
@@ -8,7 +8,7 @@ from enum import StrEnum
from functools import lru_cache
import logging
import time
from typing import TYPE_CHECKING, Any, Literal, TypedDict
from typing import TYPE_CHECKING, Any, Literal, TypedDict, Unpack
import attr
from yarl import URL
@@ -222,11 +222,11 @@ class DeviceConnectionCollisionError(DeviceCollisionError):
)
def _validate_device_info(
def _determine_device_info_type(
config_entry: ConfigEntry,
device_info: DeviceInfo,
) -> str:
"""Process a device info."""
"""Determine the type of a device info."""
keys = set(device_info)
# If no keys or not enough info to match up, abort
@@ -258,22 +258,66 @@ def _validate_device_info(
return device_info_type
class _ValidatedDeviceInfoFields(TypedDict):
"""Device info fields validated on create and update."""
configuration_url: str | URL | None | UndefinedType
hw_version: str | None | UndefinedType
manufacturer: str | None | UndefinedType
model: str | None | UndefinedType
model_id: str | None | UndefinedType
serial_number: str | None | UndefinedType
sw_version: str | None | UndefinedType
_cached_parse_url = lru_cache(maxsize=512)(URL)
"""Parse a URL and cache the result."""
def _validate_configuration_url(value: Any) -> str | None:
"""Validate and convert configuration_url."""
if value is None:
return None
def _validate_str(name: str, value: Any) -> str | None | UndefinedType:
"""Validate that a device registry string field has correct type."""
if (
value is UNDEFINED
or value is None
or type(value) is str # fast path for exact str
or isinstance(value, str)
):
return value
report_usage(
f"passes a non-string value of type {type(value).__name__} "
f"as {name} to the device registry",
core_behavior=ReportBehavior.LOG,
breaks_in_ha_version="2026.12.0",
)
return str(value)
url_as_str = str(value)
url = value if type(value) is URL else _cached_parse_url(url_as_str)
if url.scheme not in CONFIGURATION_URL_SCHEMES or not url.host:
raise ValueError(f"invalid configuration_url '{value}'")
return url_as_str
def _validate_device_info_fields(
**fields: Unpack[_ValidatedDeviceInfoFields],
) -> _ValidatedDeviceInfoFields:
"""Validate device-info field values."""
configuration_url = fields["configuration_url"]
url: URL | None = None
if type(configuration_url) is URL:
url = configuration_url
configuration_url = str(configuration_url)
else:
configuration_url = _validate_str("configuration_url", configuration_url)
if isinstance(configuration_url, str):
url = _cached_parse_url(configuration_url)
if url is not None and (
url.scheme not in CONFIGURATION_URL_SCHEMES or not url.host
):
raise ValueError(f"invalid configuration_url '{configuration_url}'")
return {
"configuration_url": configuration_url,
"hw_version": _validate_str("hw_version", fields["hw_version"]),
"manufacturer": _validate_str("manufacturer", fields["manufacturer"]),
"model": _validate_str("model", fields["model"]),
"model_id": _validate_str("model_id", fields["model_id"]),
"serial_number": _validate_str("serial_number", fields["serial_number"]),
"sw_version": _validate_str("sw_version", fields["sw_version"]),
}
@lru_cache(maxsize=512)
@@ -864,8 +908,19 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
via_device: tuple[str, str] | None | UndefinedType = UNDEFINED,
) -> DeviceEntry:
"""Get device. Create if it doesn't exist."""
if configuration_url is not UNDEFINED:
configuration_url = _validate_configuration_url(configuration_url)
default_manufacturer = _validate_str(
"default_manufacturer", default_manufacturer
)
default_model = _validate_str("default_model", default_model)
validated_fields = _validate_device_info_fields(
configuration_url=configuration_url,
hw_version=hw_version,
manufacturer=manufacturer,
model=model,
model_id=model_id,
serial_number=serial_number,
sw_version=sw_version,
)
config_entry = self.hass.config_entries.async_get_entry(config_entry_id)
if config_entry is None:
@@ -891,27 +946,21 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
device_info: DeviceInfo = { # type: ignore[assignment]
key: val
for key, val in (
("configuration_url", configuration_url),
("connections", connections),
("default_manufacturer", default_manufacturer),
("default_model", default_model),
("default_name", default_name),
("entry_type", entry_type),
("hw_version", hw_version),
("identifiers", identifiers),
("manufacturer", manufacturer),
("model", model),
("model_id", model_id),
("name", name),
("serial_number", serial_number),
("suggested_area", suggested_area),
("sw_version", sw_version),
("via_device", via_device),
*validated_fields.items(),
)
if val is not UNDEFINED
}
device_info_type = _validate_device_info(config_entry, device_info)
device_info_type = _determine_device_info_type(config_entry, device_info)
if identifiers is None or identifiers is UNDEFINED:
identifiers = set()
@@ -963,10 +1012,10 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
name = config_entry.title
if default_manufacturer is not UNDEFINED and device.manufacturer is None:
manufacturer = default_manufacturer
validated_fields["manufacturer"] = default_manufacturer
if default_model is not UNDEFINED and device.model is None:
model = default_model
validated_fields["model"] = default_model
if default_name is not UNDEFINED and device.name is None:
name = default_name
@@ -990,22 +1039,16 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
allow_collisions=True,
add_config_entry_id=config_entry_id,
add_config_subentry_id=config_subentry_id,
configuration_url=configuration_url,
device_info_type=device_info_type,
disabled_by=disabled_by,
entry_type=entry_type,
hw_version=hw_version,
is_new=is_new,
manufacturer=manufacturer,
merge_connections=connections or UNDEFINED,
merge_identifiers=identifiers or UNDEFINED,
model=model,
model_id=model_id,
name=name,
serial_number=serial_number,
suggested_area=suggested_area,
sw_version=sw_version,
via_device_id=via_device_id,
**validated_fields,
)
# This is safe because _async_update_device will always return a device
@@ -1249,9 +1292,6 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
)
old_values["identifiers"] = old.identifiers
if configuration_url is not UNDEFINED:
configuration_url = _validate_configuration_url(configuration_url)
for attr_name, value in (
("area_id", area_id),
("configuration_url", configuration_url),
@@ -1361,32 +1401,36 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
breaks_in_ha_version="2026.9.0",
)
validated_fields = _validate_device_info_fields(
configuration_url=configuration_url,
hw_version=hw_version,
manufacturer=manufacturer,
model=model,
model_id=model_id,
serial_number=serial_number,
sw_version=sw_version,
)
return self._async_update_device(
device_id,
add_config_entry_id=add_config_entry_id,
add_config_subentry_id=add_config_subentry_id,
area_id=area_id,
configuration_url=configuration_url,
device_info_type=device_info_type,
disabled_by=disabled_by,
entry_type=entry_type,
hw_version=hw_version,
labels=labels,
manufacturer=manufacturer,
merge_connections=merge_connections,
merge_identifiers=merge_identifiers,
model=model,
model_id=model_id,
name_by_user=name_by_user,
name=name,
new_connections=new_connections,
new_identifiers=new_identifiers,
remove_config_entry_id=remove_config_entry_id,
remove_config_subentry_id=remove_config_subentry_id,
serial_number=serial_number,
suggested_area=suggested_area,
sw_version=sw_version,
via_device_id=via_device_id,
**validated_fields,
)
@callback
@@ -1,6 +1,5 @@
"""State functions for Home Assistant templates."""
import collections.abc
from collections.abc import Iterable
import logging
from typing import TYPE_CHECKING, Any
@@ -162,7 +161,7 @@ class StateExtension(BaseTemplateExtension):
continue
elif isinstance(entity, State):
entity_id = entity.entity_id
elif isinstance(entity, collections.abc.Iterable):
elif isinstance(entity, Iterable):
search += entity
continue
else:
+2 -2
View File
@@ -56,14 +56,14 @@ psutil-home-assistant==0.0.1
PyJWT==2.12.1
pymicro-vad==1.0.1
PyNaCl==1.6.2
pyOpenSSL==26.1.0
pyOpenSSL==26.2.0
pyspeex-noise==1.0.2
python-slugify==8.0.4
PyTurboJPEG==1.8.3
PyYAML==6.0.3
requests==2.33.1
securetar==2026.4.1
serialx==1.7.2
serialx==1.7.3
SQLAlchemy==2.0.49
standard-aifc==3.13.0
standard-telnetlib==3.13.0
Generated
+2
View File
@@ -8,6 +8,8 @@ platform = linux
plugins = pydantic.mypy
show_error_codes = true
follow_imports = normal
native_parser = true
num_workers = 2
local_partial_types = true
strict_equality = true
strict_bytes = true
+1 -1
View File
@@ -60,7 +60,7 @@ dependencies = [
"cryptography==47.0.0",
"Pillow==12.2.0",
"propcache==0.4.1",
"pyOpenSSL==26.1.0",
"pyOpenSSL==26.2.0",
"orjson==3.11.8",
"packaging>=23.1",
"psutil-home-assistant==0.0.1",
+2 -2
View File
@@ -30,7 +30,7 @@ home-assistant-bluetooth==2.0.0
home-assistant-intents==2026.5.5
httpx==0.28.1
ifaddr==0.2.0
infrared-protocols==4.0.0
infrared-protocols==5.1.0
Jinja2==3.1.6
lru-dict==1.4.1
mutagen==1.47.0
@@ -41,7 +41,7 @@ propcache==0.4.1
psutil-home-assistant==0.0.1
PyJWT==2.12.1
pymicro-vad==1.0.1
pyOpenSSL==26.1.0
pyOpenSSL==26.2.0
pyspeex-noise==1.0.2
python-slugify==8.0.4
PyTurboJPEG==1.8.3
+10 -7
View File
@@ -606,7 +606,7 @@ avea==1.6.1
# avion==0.10
# homeassistant.components.axis
axis==70
axis==71
# homeassistant.components.fujitsu_fglair
ayla-iot-unofficial==1.4.7
@@ -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
@@ -1329,7 +1332,7 @@ iglo==1.2.7
igloohome-api==0.1.1
# homeassistant.components.ihc
ihcsdk==2.8.5
ihcsdk==2.8.12
# homeassistant.components.imeon_inverter
imeon_inverter_api==0.4.0
@@ -1350,7 +1353,7 @@ influxdb-client==1.50.0
influxdb==5.3.1
# homeassistant.components.infrared
infrared-protocols==4.0.0
infrared-protocols==5.1.0
# homeassistant.components.inkbird
inkbird-ble==1.1.1
@@ -2315,7 +2318,7 @@ pymeteoclimatic==0.1.1
pymicro-vad==1.0.1
# homeassistant.components.miele
pymiele==0.6.1
pymiele==0.6.2
# homeassistant.components.xiaomi_tv
pymitv==1.4.3
@@ -2684,7 +2687,7 @@ python-picnic-api2==1.3.4
python-pooldose==0.9.0
# homeassistant.components.hr_energy_qube
python-qube-heatpump==1.8.0
python-qube-heatpump==1.10.0
# homeassistant.components.rabbitair
python-rabbitair==0.0.8
@@ -2808,7 +2811,7 @@ pyxeoma==1.4.2
pyyardian==1.1.1
# homeassistant.components.qrcode
pyzbar==0.1.7
pyzbar==0.1.9
# homeassistant.components.zerproc
pyzerproc==0.4.8
@@ -2963,7 +2966,7 @@ sentry-sdk==2.48.0
# homeassistant.components.acer_projector
# homeassistant.components.serial
# homeassistant.components.usb
serialx==1.7.2
serialx==1.7.3
# homeassistant.components.sfr_box
sfrbox-api==0.1.1
+2 -2
View File
@@ -13,10 +13,10 @@ astroid==4.0.4
coverage==7.13.5
freezegun==1.5.5
# librt is an internal mypy dependency
librt==0.10.0
librt==0.11.0
license-expression==30.4.3
mock-open==1.4.0
mypy==2.0.0
mypy==2.1.0
prek==0.2.28
pydantic==2.13.2
pylint==4.0.5
+11 -5
View File
@@ -557,8 +557,11 @@ autoskope_client==1.4.1
# homeassistant.components.stream
av==16.0.1
# homeassistant.components.avea
avea==1.6.1
# homeassistant.components.axis
axis==70
axis==71
# homeassistant.components.fujitsu_fglair
ayla-iot-unofficial==1.4.7
@@ -794,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
@@ -1202,7 +1208,7 @@ influxdb-client==1.50.0
influxdb==5.3.1
# homeassistant.components.infrared
infrared-protocols==4.0.0
infrared-protocols==5.1.0
# homeassistant.components.inkbird
inkbird-ble==1.1.1
@@ -1989,7 +1995,7 @@ pymeteoclimatic==0.1.1
pymicro-vad==1.0.1
# homeassistant.components.miele
pymiele==0.6.1
pymiele==0.6.2
# homeassistant.components.mochad
pymochad==0.2.0
@@ -2292,7 +2298,7 @@ python-picnic-api2==1.3.4
python-pooldose==0.9.0
# homeassistant.components.hr_energy_qube
python-qube-heatpump==1.8.0
python-qube-heatpump==1.10.0
# homeassistant.components.rabbitair
python-rabbitair==0.0.8
@@ -2529,7 +2535,7 @@ sentry-sdk==2.48.0
# homeassistant.components.acer_projector
# homeassistant.components.serial
# homeassistant.components.usb
serialx==1.7.2
serialx==1.7.3
# homeassistant.components.sfr_box
sfrbox-api==0.1.1
+2
View File
@@ -38,6 +38,8 @@ GENERAL_SETTINGS: Final[dict[str, str]] = {
),
"show_error_codes": "true",
"follow_imports": "normal",
"native_parser": "true",
"num_workers": "2", # Use a conservative value here
# "enable_incomplete_feature": ", ".join(
# []
# ),
-1
View File
@@ -461,7 +461,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"honeywell",
"horizon",
"hp_ilo",
"html5",
"http",
"hue",
"huisbaasje",
+46
View File
@@ -0,0 +1,46 @@
"""Tests for the Avea integration."""
from homeassistant.components.avea.const import AVEA_SERVICE_UUID
from homeassistant.components.bluetooth import BluetoothServiceInfoBleak
from tests.components.bluetooth import generate_advertisement_data, generate_ble_device
AVEA_DISCOVERY_INFO = BluetoothServiceInfoBleak(
name="Avea Bulb",
address="AA:BB:CC:DD:EE:FF",
rssi=-60,
manufacturer_data={},
service_uuids=[AVEA_SERVICE_UUID],
service_data={},
source="local",
device=generate_ble_device(address="AA:BB:CC:DD:EE:FF", name="Avea Bulb"),
advertisement=generate_advertisement_data(
local_name="Avea Bulb",
manufacturer_data={},
service_data={},
service_uuids=[AVEA_SERVICE_UUID],
),
time=0,
connectable=True,
tx_power=-127,
)
NOT_AVEA_DISCOVERY_INFO = BluetoothServiceInfoBleak(
name="Not Avea Bulb",
address="11:22:33:44:55:66",
rssi=-60,
manufacturer_data={},
service_uuids=[],
service_data={},
source="local",
device=generate_ble_device(address="11:22:33:44:55:66", name="Not Avea Bulb"),
advertisement=generate_advertisement_data(
local_name="Not Avea Bulb",
manufacturer_data={},
service_data={},
service_uuids=[],
),
time=0,
connectable=True,
tx_power=-127,
)
+21
View File
@@ -0,0 +1,21 @@
"""Tests fixtures for the Avea integration."""
import pytest
from homeassistant.components.avea.const import DOMAIN
from homeassistant.const import CONF_ADDRESS
from . import AVEA_DISCOVERY_INFO
from tests.common import MockConfigEntry
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Create a mock Avea config entry."""
return MockConfigEntry(
domain=DOMAIN,
title="Bedroom",
unique_id=AVEA_DISCOVERY_INFO.address,
data={CONF_ADDRESS: AVEA_DISCOVERY_INFO.address},
)
+307
View File
@@ -0,0 +1,307 @@
"""Test the Avea config flow."""
from unittest.mock import MagicMock, patch
import pytest
from homeassistant.components.avea.const import DOMAIN
from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_IMPORT, SOURCE_USER
from homeassistant.const import CONF_ADDRESS, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from . import AVEA_DISCOVERY_INFO, NOT_AVEA_DISCOVERY_INFO
from tests.components.bluetooth import inject_bluetooth_service_info
pytestmark = pytest.mark.usefixtures("enable_bluetooth")
def _mock_bulb(name: str | Exception | None, brightness: int | None) -> MagicMock:
"""Create a mocked Avea bulb for validation."""
bulb = MagicMock()
bulb.connect.return_value = True
if isinstance(name, Exception):
bulb.get_name.side_effect = name
else:
bulb.get_name.return_value = name
bulb.get_brightness.return_value = brightness
return bulb
async def test_user_step_success(hass: HomeAssistant) -> None:
"""Test the user step success path."""
inject_bluetooth_service_info(hass, NOT_AVEA_DISCOVERY_INFO)
inject_bluetooth_service_info(hass, AVEA_DISCOVERY_INFO)
await hass.async_block_till_done(wait_background_tasks=True)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
with (
patch(
"homeassistant.components.avea.config_flow.avea.Bulb",
return_value=_mock_bulb("Living Room", 0),
),
patch("homeassistant.components.avea.async_setup_entry", return_value=True),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_ADDRESS: AVEA_DISCOVERY_INFO.address},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Living Room"
assert result["data"] == {CONF_ADDRESS: AVEA_DISCOVERY_INFO.address}
assert result["result"].unique_id == AVEA_DISCOVERY_INFO.address
async def test_user_step_no_devices_found(hass: HomeAssistant) -> None:
"""Test the user step when no devices are found."""
inject_bluetooth_service_info(hass, NOT_AVEA_DISCOVERY_INFO)
await hass.async_block_till_done(wait_background_tasks=True)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_devices_found"
async def test_user_step_cannot_connect_recovers(hass: HomeAssistant) -> None:
"""Test the user step recovers after a cannot connect error."""
inject_bluetooth_service_info(hass, AVEA_DISCOVERY_INFO)
await hass.async_block_till_done(wait_background_tasks=True)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
failing_bulb = _mock_bulb("Bedroom", 0)
failing_bulb.get_brightness.side_effect = RuntimeError
with patch(
"homeassistant.components.avea.config_flow.avea.Bulb",
return_value=failing_bulb,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_ADDRESS: AVEA_DISCOVERY_INFO.address},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": "cannot_connect"}
with (
patch(
"homeassistant.components.avea.config_flow.avea.Bulb",
return_value=_mock_bulb("Bedroom", 0),
),
patch("homeassistant.components.avea.async_setup_entry", return_value=True),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_ADDRESS: AVEA_DISCOVERY_INFO.address},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Bedroom"
async def test_user_step_unknown_error_recovers(hass: HomeAssistant) -> None:
"""Test the user step recovers after an unknown error."""
inject_bluetooth_service_info(hass, AVEA_DISCOVERY_INFO)
await hass.async_block_till_done(wait_background_tasks=True)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
with patch(
"homeassistant.components.avea.config_flow.avea.Bulb",
return_value=_mock_bulb(ValueError("boom"), 0),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_ADDRESS: AVEA_DISCOVERY_INFO.address},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": "unknown"}
with (
patch(
"homeassistant.components.avea.config_flow.avea.Bulb",
return_value=_mock_bulb("Bedroom", 0),
),
patch("homeassistant.components.avea.async_setup_entry", return_value=True),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_ADDRESS: AVEA_DISCOVERY_INFO.address},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Bedroom"
async def test_bluetooth_step_success(hass: HomeAssistant) -> None:
"""Test bluetooth discovery starts the flow and creates an entry."""
inject_bluetooth_service_info(hass, AVEA_DISCOVERY_INFO)
await hass.async_block_till_done(wait_background_tasks=True)
result = next(iter(hass.config_entries.flow.async_progress_by_handler(DOMAIN)))
assert result["step_id"] == "bluetooth_confirm"
with (
patch(
"homeassistant.components.avea.config_flow.avea.Bulb",
return_value=_mock_bulb(RuntimeError("name"), 0),
),
patch("homeassistant.components.avea.async_setup_entry", return_value=True),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == AVEA_DISCOVERY_INFO.name
assert result["data"] == {CONF_ADDRESS: AVEA_DISCOVERY_INFO.address}
assert result["result"].unique_id == AVEA_DISCOVERY_INFO.address
async def test_bluetooth_step_uses_discovery_name_for_unknown_bulb_name(
hass: HomeAssistant,
) -> None:
"""Test bluetooth discovery falls back from the library default name."""
inject_bluetooth_service_info(hass, AVEA_DISCOVERY_INFO)
await hass.async_block_till_done(wait_background_tasks=True)
progress = next(iter(hass.config_entries.flow.async_progress_by_handler(DOMAIN)))
with (
patch(
"homeassistant.components.avea.config_flow.avea.Bulb",
return_value=_mock_bulb("Unknown", 0),
),
patch("homeassistant.components.avea.async_setup_entry", return_value=True),
):
result = await hass.config_entries.flow.async_configure(
progress["flow_id"],
{},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == AVEA_DISCOVERY_INFO.name
async def test_bluetooth_step_cannot_connect_recovers(hass: HomeAssistant) -> None:
"""Test bluetooth confirmation recovers after cannot connect."""
inject_bluetooth_service_info(hass, AVEA_DISCOVERY_INFO)
await hass.async_block_till_done(wait_background_tasks=True)
progress = next(iter(hass.config_entries.flow.async_progress_by_handler(DOMAIN)))
failing_bulb = _mock_bulb("Avea Bulb", 0)
failing_bulb.get_brightness.side_effect = RuntimeError
with patch(
"homeassistant.components.avea.config_flow.avea.Bulb",
return_value=failing_bulb,
):
result = await hass.config_entries.flow.async_configure(
progress["flow_id"],
{},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "bluetooth_confirm"
assert result["errors"] == {"base": "cannot_connect"}
with (
patch(
"homeassistant.components.avea.config_flow.avea.Bulb",
return_value=_mock_bulb("Avea Bulb", 0),
),
patch("homeassistant.components.avea.async_setup_entry", return_value=True),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Avea Bulb"
async def test_import_step_success(hass: HomeAssistant) -> None:
"""Test the YAML import step."""
with patch("homeassistant.components.avea.async_setup_entry", return_value=True):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data={
CONF_ADDRESS: AVEA_DISCOVERY_INFO.address,
CONF_NAME: "Bedroom",
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Bedroom"
assert result["data"] == {CONF_ADDRESS: AVEA_DISCOVERY_INFO.address}
assert result["result"].unique_id == AVEA_DISCOVERY_INFO.address
async def test_import_step_aborts_bluetooth_flow_in_progress(
hass: HomeAssistant,
) -> None:
"""Test YAML import can complete while a Bluetooth flow is in progress."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_BLUETOOTH},
data=AVEA_DISCOVERY_INFO,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "bluetooth_confirm"
assert hass.config_entries.flow.async_progress_by_handler(DOMAIN)
with patch("homeassistant.components.avea.async_setup_entry", return_value=True):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data={
CONF_ADDRESS: AVEA_DISCOVERY_INFO.address,
CONF_NAME: "Bedroom",
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Bedroom"
assert result["data"] == {CONF_ADDRESS: AVEA_DISCOVERY_INFO.address}
assert result["result"].unique_id == AVEA_DISCOVERY_INFO.address
assert not hass.config_entries.flow.async_progress_by_handler(DOMAIN)
+175
View File
@@ -0,0 +1,175 @@
"""Tests for the Avea integration setup."""
from unittest.mock import AsyncMock, MagicMock, patch
from homeassistant.components.avea.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.helpers import issue_registry as ir
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
def _mock_discovered_bulb(
address: str,
name: str | None = None,
brightness: int | None = 0,
*,
name_side_effect: Exception | None = None,
) -> MagicMock:
"""Create a discovered Avea bulb for YAML import tests."""
bulb = MagicMock()
bulb.addr = address
bulb.name = name or address
if name_side_effect is not None:
bulb.get_name.side_effect = name_side_effect
else:
bulb.get_name.return_value = name
bulb.get_brightness.return_value = brightness
return bulb
async def _setup_yaml_import(hass: HomeAssistant, bulbs: list[MagicMock]) -> None:
"""Set up the YAML import path with mocked discovered bulbs."""
with (
patch(
"homeassistant.components.avea.light.avea.discover_avea_bulbs",
return_value=bulbs,
),
patch(
"homeassistant.components.avea.async_setup_entry",
new=AsyncMock(return_value=True),
),
):
assert await async_setup_component(
hass, "light", {"light": {"platform": DOMAIN}}
)
await hass.async_block_till_done()
async def test_setup_entry_retries_when_ble_device_is_missing(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test setup retries when the Bluetooth device is unavailable."""
mock_config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.avea.async_ble_device_from_address",
return_value=None,
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_yaml_import_creates_entries_for_discovered_bulbs(
hass: HomeAssistant,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test YAML import creates entries for each discovered bulb."""
bulbs = [
_mock_discovered_bulb("AA:BB:CC:DD:EE:FF", "Bedroom"),
_mock_discovered_bulb("11:22:33:44:55:66", "Desk"),
]
await _setup_yaml_import(hass, bulbs)
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 2
assert {(entry.unique_id, entry.title) for entry in entries} == {
("AA:BB:CC:DD:EE:FF", "Bedroom"),
("11:22:33:44:55:66", "Desk"),
}
for bulb in bulbs:
bulb.close.assert_called_once()
assert issue_registry.async_get_issue(
HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}"
)
async def test_yaml_import_skips_bulbs_that_fail_validation(
hass: HomeAssistant,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test YAML import skips bulbs that fail validation."""
bulbs = [
_mock_discovered_bulb(
"AA:BB:CC:DD:EE:FF",
"Bedroom",
name_side_effect=RuntimeError("boom"),
),
_mock_discovered_bulb("11:22:33:44:55:66", "Desk"),
]
await _setup_yaml_import(hass, bulbs)
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
assert entries[0].unique_id == "11:22:33:44:55:66"
assert entries[0].title == "Desk"
for bulb in bulbs:
bulb.close.assert_called_once()
assert issue_registry.async_get_issue(
HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}"
)
async def test_yaml_import_handles_when_no_bulbs_are_discovered(
hass: HomeAssistant,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test YAML import completes when no bulbs can be discovered."""
await _setup_yaml_import(hass, [])
assert hass.config_entries.async_entries(DOMAIN) == []
assert issue_registry.async_get_issue(
HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}"
)
assert issue_registry.async_get_issue(
DOMAIN, "deprecated_yaml_import_issue_no_bulbs"
)
async def test_yaml_import_handles_when_no_bulbs_can_be_imported(
hass: HomeAssistant,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test YAML import completes when all bulbs fail validation."""
bulbs = [
_mock_discovered_bulb(
"AA:BB:CC:DD:EE:FF",
"Bedroom",
name_side_effect=RuntimeError("boom"),
)
]
await _setup_yaml_import(hass, bulbs)
assert hass.config_entries.async_entries(DOMAIN) == []
bulbs[0].close.assert_called_once()
assert issue_registry.async_get_issue(
HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}"
)
assert issue_registry.async_get_issue(
DOMAIN, "deprecated_yaml_import_issue_no_bulbs"
)
async def test_yaml_import_ignores_unknown_bulb_name(hass: HomeAssistant) -> None:
"""Test YAML import ignores the library default name."""
bulbs = [
_mock_discovered_bulb(
"AA:BB:CC:DD:EE:FF",
"Unknown",
)
]
bulbs[0].name = "Bedroom"
await _setup_yaml_import(hass, bulbs)
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
assert entries[0].title == "Bedroom"
+129
View File
@@ -0,0 +1,129 @@
"""Test the Avea light platform."""
from collections.abc import AsyncGenerator
from datetime import timedelta
from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_HS_COLOR,
ATTR_SUPPORTED_COLOR_MODES,
ColorMode,
)
from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import AVEA_DISCOVERY_INFO
from tests.common import MockConfigEntry, async_fire_time_changed
@pytest.fixture
def mock_bulb() -> MagicMock:
"""Return a mocked Avea bulb."""
bulb = MagicMock()
bulb.name = "Bedroom"
bulb.brightness = 0
bulb.get_brightness.return_value = 0
return bulb
@pytest.fixture
async def setup_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_bulb: MagicMock,
) -> AsyncGenerator[MagicMock]:
"""Set up the integration."""
with (
patch(
"homeassistant.components.avea.async_ble_device_from_address",
return_value=AVEA_DISCOVERY_INFO.device,
),
patch("homeassistant.components.avea.avea.Bulb", return_value=mock_bulb),
):
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
yield mock_bulb
async def test_init_state(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
setup_integration: MagicMock,
) -> None:
"""Test the initial state."""
state = hass.states.get("light.bedroom")
assert state is not None
assert state.state == STATE_OFF
assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.HS]
async def test_turn_on_and_off(
hass: HomeAssistant,
setup_integration: MagicMock,
) -> None:
"""Test turning the light on and off."""
bulb = setup_integration
await hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: "light.bedroom"},
blocking=True,
)
bulb.set_brightness.assert_called_with(4095)
bulb.set_brightness.reset_mock()
await hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: "light.bedroom", ATTR_BRIGHTNESS: 128},
blocking=True,
)
bulb.set_brightness.assert_called_with(2056)
bulb.set_rgb.reset_mock()
await hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: "light.bedroom", ATTR_HS_COLOR: (0, 100)},
blocking=True,
)
bulb.set_rgb.assert_called_with(255, 0, 0)
bulb.set_brightness.reset_mock()
await hass.services.async_call(
"light",
"turn_off",
{ATTR_ENTITY_ID: "light.bedroom"},
blocking=True,
)
bulb.set_brightness.assert_called_with(0)
async def test_update_state(
hass: HomeAssistant, setup_integration: MagicMock, freezer: FrozenDateTimeFactory
) -> None:
"""Test updating the entity state."""
state = hass.states.get("light.bedroom")
assert state is not None
assert state.state == STATE_OFF
assert state.attributes[ATTR_BRIGHTNESS] is None
bulb = setup_integration
bulb.get_brightness.return_value = 2048
freezer.tick(timedelta(seconds=30))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
state = hass.states.get("light.bedroom")
assert state is not None
assert state.state == STATE_ON
assert state.attributes[ATTR_BRIGHTNESS] == 128
+1 -1
View File
@@ -149,7 +149,7 @@ async def test_device_unavailable(
mock_rtsp_event(
topic="tns1:AudioSource/tnsaxis:TriggerLevel",
data_type="triggered",
data_value="10",
data_value="0",
source_name="channel",
source_idx="1",
)
+1 -1
View File
@@ -262,7 +262,7 @@ async def test_device_setup_registry(
assert device_entry.name == device.name
assert device_entry.model == device.model
assert device_entry.manufacturer == device.manufacturer
assert device_entry.sw_version == device.fwversion
assert device_entry.sw_version == str(device.fwversion)
for entry in er.async_entries_for_device(entity_registry, device_entry.id):
assert (
+7 -21
View File
@@ -95,7 +95,7 @@ if TYPE_CHECKING:
from .conversation import MockAgent
from .device_tracker.common import MockScanner
from .infrared.common import MockInfraredEmitterEntity, MockInfraredReceiverEntity
from .infrared.common import MockInfraredEntity
from .light.common import MockLight
from .radio_frequency.common import MockRadioFrequencyEntity
from .sensor.common import MockSensor
@@ -234,28 +234,14 @@ async def init_infrared_fixture(hass: HomeAssistant) -> None:
await init_infrared_fixture_helper(hass)
@pytest.fixture(name="mock_infrared_emitter_entity")
async def mock_infrared_emitter_entity_fixture(
@pytest.fixture(name="mock_infrared_entity")
async def mock_infrared_entity_fixture(
hass: HomeAssistant, init_infrared: None
) -> MockInfraredEmitterEntity:
"""Return a mock infrared emitter entity."""
from .infrared.common import ( # noqa: PLC0415
mock_infrared_emitter_entity_fixture_helper,
)
) -> MockInfraredEntity:
"""Return a mock infrared entity."""
from .infrared.common import mock_infrared_entity_fixture_helper # noqa: PLC0415
return await mock_infrared_emitter_entity_fixture_helper(hass)
@pytest.fixture(name="mock_infrared_receiver_entity")
async def mock_infrared_receiver_entity_fixture(
hass: HomeAssistant, init_infrared: None
) -> MockInfraredReceiverEntity:
"""Return a mock infrared receiver entity."""
from .infrared.common import ( # noqa: PLC0415
mock_infrared_receiver_entity_fixture_helper,
)
return await mock_infrared_receiver_entity_fixture_helper(hass)
return await mock_infrared_entity_fixture_helper(hass)
@pytest.fixture(scope="session", autouse=find_spec("haffmpeg") is not None)
+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

Some files were not shown because too many files have changed in this diff Show More