mirror of
https://github.com/home-assistant/core.git
synced 2026-05-13 10:24:03 +00:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7dfef5c82a | |||
| b75cd0f6a7 | |||
| 09a08011d6 | |||
| 891e0aebb0 | |||
| ca9a7f6051 | |||
| 24dc206462 | |||
| 60e4f924a0 | |||
| 339703ca04 | |||
| 362cba91fb | |||
| 7859aba432 | |||
| a215b82bd9 | |||
| 3393598d91 | |||
| 676df1d2b2 | |||
| 36cc629faf | |||
| 99b1e7c229 | |||
| cfdb00bf36 | |||
| 9b8c81cba1 | |||
| 095cf07f43 | |||
| b275791a71 | |||
| e7dccd3ad3 | |||
| adab0d6486 | |||
| aad964889f | |||
| 9200658526 | |||
| 68f10249a5 | |||
| b5ee78aeac | |||
| 86a967ee7b | |||
| eeca75b937 | |||
| ce6b6601fa | |||
| 4641c829ca | |||
| 56fbd096e2 | |||
| c071c08f86 | |||
| e47c152222 |
@@ -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
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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."""
|
||||
@@ -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"
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["axis"],
|
||||
"requirements": ["axis==70"],
|
||||
"requirements": ["axis==71"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "AXIS"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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
@@ -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."""
|
||||
|
||||
Generated
+5
@@ -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",
|
||||
|
||||
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+2
-2
@@ -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
|
||||
|
||||
Generated
+10
-7
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+11
-5
@@ -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
|
||||
|
||||
@@ -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(
|
||||
# []
|
||||
# ),
|
||||
|
||||
@@ -461,7 +461,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
|
||||
"honeywell",
|
||||
"horizon",
|
||||
"hp_ilo",
|
||||
"html5",
|
||||
"http",
|
||||
"hue",
|
||||
"huisbaasje",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
Reference in New Issue
Block a user