Compare commits

..

20 Commits

Author SHA1 Message Date
Franck Nijhof 51162320cb Bump version to 2025.3.0b8 2025-03-05 17:25:33 +00:00
Michael Hansen b88eab8ba3 Bump intents to 2025.3.5 (#139851)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2025-03-05 17:23:04 +00:00
Josef Zweck 6c080ee650 Bump onedrive-personal-sdk to 0.0.13 (#139846) 2025-03-05 17:22:17 +00:00
Joost Lekkerkerker 8056b0df2b Bump aioecowitt to 2025.3.1 (#139841)
* Bump aioecowitt to 2025.3.1

* Bump aioecowitt to 2025.3.1
2025-03-05 17:22:14 +00:00
Allen Porter 3f94b7a61c Revert "Add scene support to roborock (#137203)" (#139840)
This reverts commit 379bf10675.
2025-03-05 17:22:11 +00:00
J. Nick Koston 1484e46317 Bump nexia to 2.2.1 (#139786)
* Bump nexia to 2.2.0

changelog: https://github.com/bdraco/nexia/compare/2.1.1...2.2.0

* Apply suggestions from code review
2025-03-05 17:22:08 +00:00
LG-ThinQ-Integration 2812c8a993 Get temperature data appropriate for hass.config.unit in LG ThinQ (#137626)
* Get temperature data appropriate for hass.config.unit

* Modify temperature_unit for init

* Modify unit's map

* Fix ruff error

---------

Co-authored-by: yunseon.park <yunseon.park@lge.com>
2025-03-05 17:22:04 +00:00
Franck Nijhof 5043e2ad10 Bump version to 2025.3.0b7 2025-03-05 11:01:06 +00:00
Bram Kragten 2c2fd76270 Update frontend to 20250305.0 (#139829) 2025-03-05 11:00:56 +00:00
Franck Nijhof 7001f8daaf Bump version to 2025.3.0b6 2025-03-05 09:39:26 +00:00
SteveDiks b41fc932c5 Split the energy and data retrieval in WeHeat (#139211)
* Split the energy and data logs

* Make sure that pump_info name is set to device name, bump weheat

* Adding config entry

* Fixed circular import

* parallelisation of awaits

* Update homeassistant/components/weheat/binary_sensor.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Fix undefined weheatdata

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-03-05 09:39:13 +00:00
Marcel van der Veldt 0872243297 Drop BETA postfix from Matter integration's title (#139816)
Drop BETA postfix from Matter title

Now that the whole Matter stack of Home Assistant is officially certified, we can drop the beta flag.
2025-03-05 08:44:44 +00:00
Shay Levy bba889975a Bump aiowebostv to 0.7.3 (#139788) 2025-03-05 08:44:39 +00:00
Franck Nijhof 01e8ca6495 Bump version to 2025.3.0b5 2025-03-04 20:25:14 +00:00
J. Nick Koston 7d82375f81 Bump nexia to 2.1.1 (#139772)
changelog: https://github.com/bdraco/nexia/compare/2.0.9...2.1.1

fixes #133368
2025-03-04 20:24:56 +00:00
Martin Hjelmare 47033e587b Fix home connect available (#139760)
* Fix home connect available

* Extend and clarify test

* Do not change connected state on stream interrupted
2025-03-04 20:24:47 +00:00
Joost Lekkerkerker e73b08b269 Bump pysmartthings to 2.5.0 (#139758)
* Bump pysmartthings to 2.5.0

* Bump pysmartthings to 2.5.0
2025-03-04 20:23:45 +00:00
Anthony Hou a195a9107b Fix incorrect weather state returned by HKO (#139757)
* Fix incorrect weather state

* Clean up unused import

---------

Co-authored-by: Franck Nijhof <frenck@frenck.nl>
Co-authored-by: Franck Nijhof <git@frenck.dev>
2025-03-04 20:12:25 +00:00
Joost Lekkerkerker 185949cc18 Add Apollo Automation virtual integration (#139751)
Co-authored-by: Robert Resch <robert@resch.dev>
2025-03-04 20:12:22 +00:00
J. Diego Rodríguez Royo c129f27c95 Bump aiohomeconnect to 0.16.2 (#139750) 2025-03-04 20:12:16 +00:00
45 changed files with 523 additions and 434 deletions
@@ -0,0 +1 @@
"""Virtual integration: Apollo Automation."""
@@ -0,0 +1,6 @@
{
"domain": "apollo_automation",
"name": "Apollo Automation",
"integration_type": "virtual",
"supported_by": "esphome"
}
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["hassil==2.2.3", "home-assistant-intents==2025.2.26"]
"requirements": ["hassil==2.2.3", "home-assistant-intents==2025.3.5"]
}
@@ -6,5 +6,5 @@
"dependencies": ["webhook"],
"documentation": "https://www.home-assistant.io/integrations/ecowitt",
"iot_class": "local_push",
"requirements": ["aioecowitt==2024.2.1"]
"requirements": ["aioecowitt==2025.3.1"]
}
@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20250228.0"]
"requirements": ["home-assistant-frontend==20250305.0"]
}
+1 -2
View File
@@ -11,7 +11,6 @@ from hko import HKO, HKOError
from homeassistant.components.weather import (
ATTR_CONDITION_CLOUDY,
ATTR_CONDITION_FOG,
ATTR_CONDITION_HAIL,
ATTR_CONDITION_LIGHTNING_RAINY,
ATTR_CONDITION_PARTLYCLOUDY,
ATTR_CONDITION_POURING,
@@ -145,7 +144,7 @@ class HKOUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Return the condition corresponding to the weather info."""
info = info.lower()
if WEATHER_INFO_RAIN in info:
return ATTR_CONDITION_HAIL
return ATTR_CONDITION_RAINY
if WEATHER_INFO_SNOW in info and WEATHER_INFO_RAIN in info:
return ATTR_CONDITION_SNOWY_RAINY
if WEATHER_INFO_SNOW in info:
@@ -98,6 +98,7 @@ class HomeConnectCoordinator(
CALLBACK_TYPE, tuple[CALLBACK_TYPE, tuple[EventKey, ...]]
] = {}
self.device_registry = dr.async_get(self.hass)
self.data = {}
@cached_property
def context_listeners(self) -> dict[tuple[str, EventKey], list[CALLBACK_TYPE]]:
@@ -161,6 +162,14 @@ class HomeConnectCoordinator(
async for event_message in self.client.stream_all_events():
retry_time = 10
event_message_ha_id = event_message.ha_id
if (
event_message_ha_id in self.data
and not self.data[event_message_ha_id].info.connected
):
self.data[event_message_ha_id].info.connected = True
self._call_all_event_listeners_for_appliance(
event_message_ha_id
)
match event_message.type:
case EventType.STATUS:
statuses = self.data[event_message_ha_id].status
@@ -295,6 +304,8 @@ class HomeConnectCoordinator(
translation_placeholders=get_dict_from_home_connect_error(error),
) from error
except HomeConnectError as error:
for appliance_data in self.data.values():
appliance_data.info.connected = False
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="fetch_api_error",
@@ -303,7 +314,7 @@ class HomeConnectCoordinator(
return {
appliance.ha_id: await self._get_appliance_data(
appliance, self.data.get(appliance.ha_id) if self.data else None
appliance, self.data.get(appliance.ha_id)
)
for appliance in appliances.homeappliances
}
@@ -8,6 +8,7 @@ from typing import cast
from aiohomeconnect.model import EventKey, OptionKey
from aiohomeconnect.model.error import ActiveProgramNotSetError, HomeConnectError
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
@@ -51,8 +52,10 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self.update_native_value()
available = self._attr_available = self.appliance.info.connected
self.async_write_ha_state()
_LOGGER.debug("Updated %s, new state: %s", self.entity_id, self.state)
state = STATE_UNAVAILABLE if not available else self.state
_LOGGER.debug("Updated %s, new state: %s", self.entity_id, state)
@property
def bsh_key(self) -> str:
@@ -61,10 +64,13 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
@property
def available(self) -> bool:
"""Return True if entity is available."""
return (
self.appliance.info.connected and self._attr_available and super().available
)
"""Return True if entity is available.
Do not use self.last_update_success for available state
as event updates should take precedence over the coordinator
refresh.
"""
return self._attr_available
class HomeConnectOptionEntity(HomeConnectEntity):
@@ -7,6 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/home_connect",
"iot_class": "cloud_push",
"loggers": ["aiohomeconnect"],
"requirements": ["aiohomeconnect==0.15.1"],
"requirements": ["aiohomeconnect==0.16.2"],
"single_config_entry": true
}
+8 -1
View File
@@ -110,7 +110,9 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity):
self._attr_hvac_modes = [HVACMode.OFF]
self._attr_hvac_mode = HVACMode.OFF
self._attr_preset_modes = []
self._attr_temperature_unit = UnitOfTemperature.CELSIUS
self._attr_temperature_unit = (
self._get_unit_of_measurement(self.data.unit) or UnitOfTemperature.CELSIUS
)
self._requested_hvac_mode: str | None = None
# Set up HVAC modes.
@@ -182,6 +184,11 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity):
self._attr_target_temperature_high = self.data.target_temp_high
self._attr_target_temperature_low = self.data.target_temp_low
# Update unit.
self._attr_temperature_unit = (
self._get_unit_of_measurement(self.data.unit) or UnitOfTemperature.CELSIUS
)
_LOGGER.debug(
"[%s:%s] update status: c:%s, t:%s, l:%s, h:%s, hvac:%s, unit:%s, step:%s",
self.coordinator.device_name,
@@ -3,6 +3,8 @@
from datetime import timedelta
from typing import Final
from homeassistant.const import UnitOfTemperature
# Config flow
DOMAIN = "lg_thinq"
COMPANY = "LGE"
@@ -18,3 +20,10 @@ MQTT_SUBSCRIPTION_INTERVAL: Final = timedelta(days=1)
# MQTT: Message types
DEVICE_PUSH_MESSAGE: Final = "DEVICE_PUSH"
DEVICE_STATUS_MESSAGE: Final = "DEVICE_STATUS"
# Unit conversion map
DEVICE_UNIT_TO_HA: dict[str, str] = {
"F": UnitOfTemperature.FAHRENHEIT,
"C": UnitOfTemperature.CELSIUS,
}
REVERSE_DEVICE_UNIT_TO_HA = {v: k for k, v in DEVICE_UNIT_TO_HA.items()}
@@ -2,19 +2,21 @@
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import TYPE_CHECKING, Any
from thinqconnect import ThinQAPIException
from thinqconnect.integration import HABridge
from homeassistant.core import HomeAssistant
from homeassistant.const import EVENT_CORE_CONFIG_UPDATE
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
if TYPE_CHECKING:
from . import ThinqConfigEntry
from .const import DOMAIN
from .const import DOMAIN, REVERSE_DEVICE_UNIT_TO_HA
_LOGGER = logging.getLogger(__name__)
@@ -54,6 +56,40 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
f"{self.device_id}_{self.sub_id}" if self.sub_id else self.device_id
)
# Set your preferred temperature unit. This will allow us to retrieve
# temperature values from the API in a converted value corresponding to
# preferred unit.
self._update_preferred_temperature_unit()
# Add a callback to handle core config update.
self.unit_system: str | None = None
self.hass.bus.async_listen(
event_type=EVENT_CORE_CONFIG_UPDATE,
listener=self._handle_update_config,
event_filter=self.async_config_update_filter,
)
async def _handle_update_config(self, _: Event) -> None:
"""Handle update core config."""
self._update_preferred_temperature_unit()
await self.async_refresh()
@callback
def async_config_update_filter(self, event_data: Mapping[str, Any]) -> bool:
"""Filter out unwanted events."""
if (unit_system := event_data.get("unit_system")) != self.unit_system:
self.unit_system = unit_system
return True
return False
def _update_preferred_temperature_unit(self) -> None:
"""Update preferred temperature unit."""
self.api.set_preferred_temperature_unit(
REVERSE_DEVICE_UNIT_TO_HA.get(self.hass.config.units.temperature_unit)
)
async def _async_update_data(self) -> dict[str, Any]:
"""Request to the server to update the status from full response data."""
try:
+2 -8
View File
@@ -10,25 +10,19 @@ from thinqconnect import ThinQAPIException
from thinqconnect.devices.const import Location
from thinqconnect.integration import PropertyState
from homeassistant.const import UnitOfTemperature
from homeassistant.core import callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import COMPANY, DOMAIN
from .const import COMPANY, DEVICE_UNIT_TO_HA, DOMAIN
from .coordinator import DeviceDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
EMPTY_STATE = PropertyState()
UNIT_CONVERSION_MAP: dict[str, str] = {
"F": UnitOfTemperature.FAHRENHEIT,
"C": UnitOfTemperature.CELSIUS,
}
class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]):
"""The base implementation of all lg thinq entities."""
@@ -75,7 +69,7 @@ class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]):
if unit is None:
return None
return UNIT_CONVERSION_MAP.get(unit)
return DEVICE_UNIT_TO_HA.get(unit)
def _update_status(self) -> None:
"""Update status itself.
@@ -1,6 +1,6 @@
{
"domain": "matter",
"name": "Matter (BETA)",
"name": "Matter",
"after_dependencies": ["hassio"],
"codeowners": ["@home-assistant/matter"],
"config_flow": true,
+1 -1
View File
@@ -12,5 +12,5 @@
"documentation": "https://www.home-assistant.io/integrations/nexia",
"iot_class": "cloud_polling",
"loggers": ["nexia"],
"requirements": ["nexia==2.0.9"]
"requirements": ["nexia==2.2.1"]
}
@@ -9,5 +9,5 @@
"iot_class": "cloud_polling",
"loggers": ["onedrive_personal_sdk"],
"quality_scale": "platinum",
"requirements": ["onedrive-personal-sdk==0.0.12"]
"requirements": ["onedrive-personal-sdk==0.0.13"]
}
+3 -21
View File
@@ -83,13 +83,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) ->
# Get a Coordinator if the device is available or if we have connected to the device before
coordinators = await asyncio.gather(
*build_setup_functions(
hass,
entry,
device_map,
user_data,
product_info,
home_data.rooms,
api_client,
hass, entry, device_map, user_data, product_info, home_data.rooms
),
return_exceptions=True,
)
@@ -141,7 +135,6 @@ def build_setup_functions(
user_data: UserData,
product_info: dict[str, HomeDataProduct],
home_data_rooms: list[HomeDataRoom],
api_client: RoborockApiClient,
) -> list[
Coroutine[
Any,
@@ -158,7 +151,6 @@ def build_setup_functions(
device,
product_info[device.product_id],
home_data_rooms,
api_client,
)
for device in device_map.values()
]
@@ -171,12 +163,11 @@ async def setup_device(
device: HomeDataDevice,
product_info: HomeDataProduct,
home_data_rooms: list[HomeDataRoom],
api_client: RoborockApiClient,
) -> RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01 | None:
"""Set up a coordinator for a given device."""
if device.pv == "1.0":
return await setup_device_v1(
hass, entry, user_data, device, product_info, home_data_rooms, api_client
hass, entry, user_data, device, product_info, home_data_rooms
)
if device.pv == "A01":
return await setup_device_a01(hass, entry, user_data, device, product_info)
@@ -196,7 +187,6 @@ async def setup_device_v1(
device: HomeDataDevice,
product_info: HomeDataProduct,
home_data_rooms: list[HomeDataRoom],
api_client: RoborockApiClient,
) -> RoborockDataUpdateCoordinator | None:
"""Set up a device Coordinator."""
mqtt_client = await hass.async_add_executor_job(
@@ -218,15 +208,7 @@ async def setup_device_v1(
await mqtt_client.async_release()
raise
coordinator = RoborockDataUpdateCoordinator(
hass,
entry,
device,
networking,
product_info,
mqtt_client,
home_data_rooms,
api_client,
user_data,
hass, entry, device, networking, product_info, mqtt_client, home_data_rooms
)
try:
await coordinator.async_config_entry_first_refresh()
@@ -36,7 +36,6 @@ PLATFORMS = [
Platform.BUTTON,
Platform.IMAGE,
Platform.NUMBER,
Platform.SCENE,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
@@ -10,26 +10,17 @@ import logging
from propcache.api import cached_property
from roborock import HomeDataRoom
from roborock.code_mappings import RoborockCategory
from roborock.containers import (
DeviceData,
HomeDataDevice,
HomeDataProduct,
HomeDataScene,
NetworkInfo,
UserData,
)
from roborock.containers import DeviceData, HomeDataDevice, HomeDataProduct, NetworkInfo
from roborock.exceptions import RoborockException
from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol
from roborock.roborock_typing import DeviceProp
from roborock.version_1_apis.roborock_local_client_v1 import RoborockLocalClientV1
from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1
from roborock.version_a01_apis import RoborockClientA01
from roborock.web_api import RoborockApiClient
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_CONNECTIONS
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.typing import StateType
@@ -76,8 +67,6 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
product_info: HomeDataProduct,
cloud_api: RoborockMqttClientV1,
home_data_rooms: list[HomeDataRoom],
api_client: RoborockApiClient,
user_data: UserData,
) -> None:
"""Initialize."""
super().__init__(
@@ -100,7 +89,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
self.cloud_api = cloud_api
self.device_info = DeviceInfo(
name=self.roborock_device_info.device.name,
identifiers={(DOMAIN, self.duid)},
identifiers={(DOMAIN, self.roborock_device_info.device.duid)},
manufacturer="Roborock",
model=self.roborock_device_info.product.model,
model_id=self.roborock_device_info.product.model,
@@ -114,10 +103,8 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
self.maps: dict[int, RoborockMapInfo] = {}
self._home_data_rooms = {str(room.id): room.name for room in home_data_rooms}
self.map_storage = RoborockMapStorage(
hass, self.config_entry.entry_id, self.duid_slug
hass, self.config_entry.entry_id, slugify(self.duid)
)
self._user_data = user_data
self._api_client = api_client
async def _async_setup(self) -> None:
"""Set up the coordinator."""
@@ -147,7 +134,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
except RoborockException:
_LOGGER.warning(
"Using the cloud API for device %s. This is not recommended as it can lead to rate limiting. We recommend making your vacuum accessible by your Home Assistant instance",
self.duid,
self.roborock_device_info.device.duid,
)
await self.api.async_disconnect()
# We use the cloud api if the local api fails to connect.
@@ -207,34 +194,6 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
for room in room_mapping or ()
}
async def get_scenes(self) -> list[HomeDataScene]:
"""Get scenes."""
try:
return await self._api_client.get_scenes(self._user_data, self.duid)
except RoborockException as err:
_LOGGER.error("Failed to get scenes %s", err)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_failed",
translation_placeholders={
"command": "get_scenes",
},
) from err
async def execute_scene(self, scene_id: int) -> None:
"""Execute scene."""
try:
await self._api_client.execute_scene(self._user_data, scene_id)
except RoborockException as err:
_LOGGER.error("Failed to execute scene %s %s", scene_id, err)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_failed",
translation_placeholders={
"command": "execute_scene",
},
) from err
@cached_property
def duid(self) -> str:
"""Get the unique id of the device as specified by Roborock."""
@@ -1,64 +0,0 @@
"""Support for Roborock scene."""
from __future__ import annotations
import asyncio
from typing import Any
from homeassistant.components.scene import Scene as SceneEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import RoborockConfigEntry
from .coordinator import RoborockDataUpdateCoordinator
from .entity import RoborockEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: RoborockConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up scene platform."""
scene_lists = await asyncio.gather(
*[coordinator.get_scenes() for coordinator in config_entry.runtime_data.v1],
)
async_add_entities(
RoborockSceneEntity(
coordinator,
EntityDescription(
key=str(scene.id),
name=scene.name,
),
)
for coordinator, scenes in zip(
config_entry.runtime_data.v1, scene_lists, strict=True
)
for scene in scenes
)
class RoborockSceneEntity(RoborockEntity, SceneEntity):
"""A class to define Roborock scene entities."""
entity_description: EntityDescription
def __init__(
self,
coordinator: RoborockDataUpdateCoordinator,
entity_description: EntityDescription,
) -> None:
"""Create a scene entity."""
super().__init__(
f"{entity_description.key}_{coordinator.duid_slug}",
coordinator.device_info,
coordinator.api,
)
self._scene_id = int(entity_description.key)
self._coordinator = coordinator
self.entity_description = entity_description
async def async_activate(self, **kwargs: Any) -> None:
"""Activate the scene."""
await self._coordinator.execute_scene(self._scene_id)
@@ -29,5 +29,5 @@
"documentation": "https://www.home-assistant.io/integrations/smartthings",
"iot_class": "cloud_push",
"loggers": ["pysmartthings"],
"requirements": ["pysmartthings==2.4.1"]
"requirements": ["pysmartthings==2.5.0"]
}
@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/webostv",
"iot_class": "local_push",
"loggers": ["aiowebostv"],
"requirements": ["aiowebostv==0.7.2"],
"requirements": ["aiowebostv==0.7.3"],
"ssdp": [
{
"st": "urn:lge-com:service:webos-second-screen:1"
+36 -5
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
from http import HTTPStatus
import aiohttp
@@ -18,7 +19,13 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
)
from .const import API_URL, LOGGER
from .coordinator import WeheatConfigEntry, WeheatDataUpdateCoordinator
from .coordinator import (
HeatPumpInfo,
WeheatConfigEntry,
WeheatData,
WeheatDataUpdateCoordinator,
WeheatEnergyUpdateCoordinator,
)
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
@@ -52,14 +59,38 @@ async def async_setup_entry(hass: HomeAssistant, entry: WeheatConfigEntry) -> bo
except UnauthorizedException as error:
raise ConfigEntryAuthFailed from error
nr_of_pumps = len(discovered_heat_pumps)
for pump_info in discovered_heat_pumps:
LOGGER.debug("Adding %s", pump_info)
# for each pump, add a coordinator
new_coordinator = WeheatDataUpdateCoordinator(hass, entry, session, pump_info)
# for each pump, add the coordinators
await new_coordinator.async_config_entry_first_refresh()
new_heat_pump = HeatPumpInfo(pump_info)
new_data_coordinator = WeheatDataUpdateCoordinator(
hass, entry, session, pump_info, nr_of_pumps
)
new_energy_coordinator = WeheatEnergyUpdateCoordinator(
hass, entry, session, pump_info
)
entry.runtime_data.append(new_coordinator)
entry.runtime_data.append(
WeheatData(
heat_pump_info=new_heat_pump,
data_coordinator=new_data_coordinator,
energy_coordinator=new_energy_coordinator,
)
)
await asyncio.gather(
*[
data.data_coordinator.async_config_entry_first_refresh()
for data in entry.runtime_data
],
*[
data.energy_coordinator.async_config_entry_first_refresh()
for data in entry.runtime_data
],
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import WeheatConfigEntry, WeheatDataUpdateCoordinator
from .coordinator import HeatPumpInfo, WeheatConfigEntry, WeheatDataUpdateCoordinator
from .entity import WeheatEntity
# Coordinator is used to centralize the data updates
@@ -68,10 +68,14 @@ async def async_setup_entry(
) -> None:
"""Set up the sensors for weheat heat pump."""
entities = [
WeheatHeatPumpBinarySensor(coordinator, entity_description)
WeheatHeatPumpBinarySensor(
weheatdata.heat_pump_info,
weheatdata.data_coordinator,
entity_description,
)
for weheatdata in entry.runtime_data
for entity_description in BINARY_SENSORS
for coordinator in entry.runtime_data
if entity_description.value_fn(coordinator.data) is not None
if entity_description.value_fn(weheatdata.data_coordinator.data) is not None
]
async_add_entities(entities)
@@ -80,20 +84,21 @@ async def async_setup_entry(
class WeheatHeatPumpBinarySensor(WeheatEntity, BinarySensorEntity):
"""Defines a Weheat heat pump binary sensor."""
heat_pump_info: HeatPumpInfo
coordinator: WeheatDataUpdateCoordinator
entity_description: WeHeatBinarySensorEntityDescription
def __init__(
self,
heat_pump_info: HeatPumpInfo,
coordinator: WeheatDataUpdateCoordinator,
entity_description: WeHeatBinarySensorEntityDescription,
) -> None:
"""Pass coordinator to CoordinatorEntity."""
super().__init__(coordinator)
super().__init__(heat_pump_info, coordinator)
self.entity_description = entity_description
self._attr_unique_id = f"{coordinator.heatpump_id}_{entity_description.key}"
self._attr_unique_id = f"{heat_pump_info.heatpump_id}_{entity_description.key}"
@property
def is_on(self) -> bool | None:
+2 -1
View File
@@ -17,7 +17,8 @@ API_URL = "https://api.weheat.nl"
OAUTH2_SCOPES = ["openid", "offline_access"]
UPDATE_INTERVAL = 30
LOG_UPDATE_INTERVAL = 120
ENERGY_UPDATE_INTERVAL = 1800
LOGGER: Logger = getLogger(__package__)
+95 -23
View File
@@ -1,5 +1,6 @@
"""Define a custom coordinator for the Weheat heatpump integration."""
from dataclasses import dataclass
from datetime import timedelta
from weheat.abstractions.discovery import HeatPumpDiscovery
@@ -10,6 +11,7 @@ from weheat.exceptions import (
ForbiddenException,
NotFoundException,
ServiceException,
TooManyRequestsException,
UnauthorizedException,
)
@@ -21,7 +23,9 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import API_URL, DOMAIN, LOGGER, UPDATE_INTERVAL
from .const import API_URL, DOMAIN, ENERGY_UPDATE_INTERVAL, LOG_UPDATE_INTERVAL, LOGGER
type WeheatConfigEntry = ConfigEntry[list[WeheatData]]
EXCEPTIONS = (
ServiceException,
@@ -29,9 +33,43 @@ EXCEPTIONS = (
ForbiddenException,
BadRequestException,
ApiException,
TooManyRequestsException,
)
type WeheatConfigEntry = ConfigEntry[list[WeheatDataUpdateCoordinator]]
class HeatPumpInfo(HeatPumpDiscovery.HeatPumpInfo):
"""Heat pump info with additional properties."""
def __init__(self, pump_info: HeatPumpDiscovery.HeatPumpInfo) -> None:
"""Initialize the HeatPump object with the provided pump information.
Args:
pump_info (HeatPumpDiscovery.HeatPumpInfo): An object containing the heat pump's discovery information, including:
- uuid (str): Unique identifier for the heat pump.
- uuid (str): Unique identifier for the heat pump.
- device_name (str): Name of the heat pump device.
- model (str): Model of the heat pump.
- sn (str): Serial number of the heat pump.
- has_dhw (bool): Indicates if the heat pump has domestic hot water functionality.
"""
super().__init__(
pump_info.uuid,
pump_info.device_name,
pump_info.model,
pump_info.sn,
pump_info.has_dhw,
)
@property
def readable_name(self) -> str | None:
"""Return the readable name of the heat pump."""
return self.device_name if self.device_name else self.model
@property
def heatpump_id(self) -> str:
"""Return the heat pump id."""
return self.uuid
class WeheatDataUpdateCoordinator(DataUpdateCoordinator[HeatPump]):
@@ -45,45 +83,28 @@ class WeheatDataUpdateCoordinator(DataUpdateCoordinator[HeatPump]):
config_entry: WeheatConfigEntry,
session: OAuth2Session,
heat_pump: HeatPumpDiscovery.HeatPumpInfo,
nr_of_heat_pumps: int,
) -> None:
"""Initialize the data coordinator."""
super().__init__(
hass,
logger=LOGGER,
config_entry=config_entry,
logger=LOGGER,
name=DOMAIN,
update_interval=timedelta(seconds=UPDATE_INTERVAL),
update_interval=timedelta(seconds=LOG_UPDATE_INTERVAL * nr_of_heat_pumps),
)
self.heat_pump_info = heat_pump
self._heat_pump_data = HeatPump(
API_URL, heat_pump.uuid, async_get_clientsession(hass)
)
self.session = session
@property
def heatpump_id(self) -> str:
"""Return the heat pump id."""
return self.heat_pump_info.uuid
@property
def readable_name(self) -> str | None:
"""Return the readable name of the heat pump."""
if self.heat_pump_info.name:
return self.heat_pump_info.name
return self.heat_pump_info.model
@property
def model(self) -> str:
"""Return the model of the heat pump."""
return self.heat_pump_info.model
async def _async_update_data(self) -> HeatPump:
"""Fetch data from the API."""
await self.session.async_ensure_token_valid()
try:
await self._heat_pump_data.async_get_status(
await self._heat_pump_data.async_get_logs(
self.session.token[CONF_ACCESS_TOKEN]
)
except UnauthorizedException as error:
@@ -92,3 +113,54 @@ class WeheatDataUpdateCoordinator(DataUpdateCoordinator[HeatPump]):
raise UpdateFailed(error) from error
return self._heat_pump_data
class WeheatEnergyUpdateCoordinator(DataUpdateCoordinator[HeatPump]):
"""A custom Energy coordinator for the Weheat heatpump integration."""
config_entry: WeheatConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: WeheatConfigEntry,
session: OAuth2Session,
heat_pump: HeatPumpDiscovery.HeatPumpInfo,
) -> None:
"""Initialize the data coordinator."""
super().__init__(
hass,
config_entry=config_entry,
logger=LOGGER,
name=DOMAIN,
update_interval=timedelta(seconds=ENERGY_UPDATE_INTERVAL),
)
self._heat_pump_data = HeatPump(
API_URL, heat_pump.uuid, async_get_clientsession(hass)
)
self.session = session
async def _async_update_data(self) -> HeatPump:
"""Fetch data from the API."""
await self.session.async_ensure_token_valid()
try:
await self._heat_pump_data.async_get_energy(
self.session.token[CONF_ACCESS_TOKEN]
)
except UnauthorizedException as error:
raise ConfigEntryAuthFailed from error
except EXCEPTIONS as error:
raise UpdateFailed(error) from error
return self._heat_pump_data
@dataclass
class WeheatData:
"""Data for the Weheat integration."""
heat_pump_info: HeatPumpInfo
data_coordinator: WeheatDataUpdateCoordinator
energy_coordinator: WeheatEnergyUpdateCoordinator
+11 -6
View File
@@ -3,25 +3,30 @@
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import HeatPumpInfo
from .const import DOMAIN, MANUFACTURER
from .coordinator import WeheatDataUpdateCoordinator
from .coordinator import WeheatDataUpdateCoordinator, WeheatEnergyUpdateCoordinator
class WeheatEntity(CoordinatorEntity[WeheatDataUpdateCoordinator]):
class WeheatEntity[
_WeheatEntityT: WeheatDataUpdateCoordinator | WeheatEnergyUpdateCoordinator
](CoordinatorEntity[_WeheatEntityT]):
"""Defines a base Weheat entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: WeheatDataUpdateCoordinator,
heat_pump_info: HeatPumpInfo,
coordinator: _WeheatEntityT,
) -> None:
"""Initialize the Weheat entity."""
super().__init__(coordinator)
self.heat_pump_info = heat_pump_info
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.heatpump_id)},
name=coordinator.readable_name,
identifiers={(DOMAIN, heat_pump_info.heatpump_id)},
name=heat_pump_info.readable_name,
manufacturer=MANUFACTURER,
model=coordinator.model,
model=heat_pump_info.model,
)
@@ -6,5 +6,5 @@
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/weheat",
"iot_class": "cloud_polling",
"requirements": ["weheat==2025.2.22"]
"requirements": ["weheat==2025.2.26"]
}
+64 -34
View File
@@ -27,7 +27,12 @@ from .const import (
DISPLAY_PRECISION_WATER_TEMP,
DISPLAY_PRECISION_WATTS,
)
from .coordinator import WeheatConfigEntry, WeheatDataUpdateCoordinator
from .coordinator import (
HeatPumpInfo,
WeheatConfigEntry,
WeheatDataUpdateCoordinator,
WeheatEnergyUpdateCoordinator,
)
from .entity import WeheatEntity
# Coordinator is used to centralize the data updates
@@ -142,22 +147,6 @@ SENSORS = [
else None
),
),
WeHeatSensorEntityDescription(
translation_key="electricity_used",
key="electricity_used",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda status: status.energy_total,
),
WeHeatSensorEntityDescription(
translation_key="energy_output",
key="energy_output",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda status: status.energy_output,
),
WeHeatSensorEntityDescription(
translation_key="compressor_rpm",
key="compressor_rpm",
@@ -174,7 +163,6 @@ SENSORS = [
),
]
DHW_SENSORS = [
WeHeatSensorEntityDescription(
translation_key="dhw_top_temperature",
@@ -196,6 +184,25 @@ DHW_SENSORS = [
),
]
ENERGY_SENSORS = [
WeHeatSensorEntityDescription(
translation_key="electricity_used",
key="electricity_used",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda status: status.energy_total,
),
WeHeatSensorEntityDescription(
translation_key="energy_output",
key="energy_output",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda status: status.energy_output,
),
]
async def async_setup_entry(
hass: HomeAssistant,
@@ -203,17 +210,39 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensors for weheat heat pump."""
entities = [
WeheatHeatPumpSensor(coordinator, entity_description)
for entity_description in SENSORS
for coordinator in entry.runtime_data
]
entities.extend(
WeheatHeatPumpSensor(coordinator, entity_description)
for entity_description in DHW_SENSORS
for coordinator in entry.runtime_data
if coordinator.heat_pump_info.has_dhw
)
entities: list[WeheatHeatPumpSensor] = []
for weheatdata in entry.runtime_data:
entities.extend(
WeheatHeatPumpSensor(
weheatdata.heat_pump_info,
weheatdata.data_coordinator,
entity_description,
)
for entity_description in SENSORS
if entity_description.value_fn(weheatdata.data_coordinator.data) is not None
)
if weheatdata.heat_pump_info.has_dhw:
entities.extend(
WeheatHeatPumpSensor(
weheatdata.heat_pump_info,
weheatdata.data_coordinator,
entity_description,
)
for entity_description in DHW_SENSORS
if entity_description.value_fn(weheatdata.data_coordinator.data)
is not None
)
entities.extend(
WeheatHeatPumpSensor(
weheatdata.heat_pump_info,
weheatdata.energy_coordinator,
entity_description,
)
for entity_description in ENERGY_SENSORS
if entity_description.value_fn(weheatdata.energy_coordinator.data)
is not None
)
async_add_entities(entities)
@@ -221,20 +250,21 @@ async def async_setup_entry(
class WeheatHeatPumpSensor(WeheatEntity, SensorEntity):
"""Defines a Weheat heat pump sensor."""
coordinator: WeheatDataUpdateCoordinator
heat_pump_info: HeatPumpInfo
coordinator: WeheatDataUpdateCoordinator | WeheatEnergyUpdateCoordinator
entity_description: WeHeatSensorEntityDescription
def __init__(
self,
coordinator: WeheatDataUpdateCoordinator,
heat_pump_info: HeatPumpInfo,
coordinator: WeheatDataUpdateCoordinator | WeheatEnergyUpdateCoordinator,
entity_description: WeHeatSensorEntityDescription,
) -> None:
"""Pass coordinator to CoordinatorEntity."""
super().__init__(coordinator)
super().__init__(heat_pump_info, coordinator)
self.entity_description = entity_description
self._attr_unique_id = f"{coordinator.heatpump_id}_{entity_description.key}"
self._attr_unique_id = f"{heat_pump_info.heatpump_id}_{entity_description.key}"
@property
def native_value(self) -> StateType:
+1 -1
View File
@@ -25,7 +25,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2025
MINOR_VERSION: Final = 3
PATCH_VERSION: Final = "0b4"
PATCH_VERSION: Final = "0b8"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0)
+6 -1
View File
@@ -345,6 +345,11 @@
"config_flow": true,
"iot_class": "local_polling"
},
"apollo_automation": {
"name": "Apollo Automation",
"integration_type": "virtual",
"supported_by": "esphome"
},
"appalachianpower": {
"name": "Appalachian Power",
"integration_type": "virtual",
@@ -3635,7 +3640,7 @@
"iot_class": "cloud_push"
},
"matter": {
"name": "Matter (BETA)",
"name": "Matter",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_push"
+2 -2
View File
@@ -37,8 +37,8 @@ habluetooth==3.24.1
hass-nabucasa==0.92.0
hassil==2.2.3
home-assistant-bluetooth==1.13.1
home-assistant-frontend==20250228.0
home-assistant-intents==2025.2.26
home-assistant-frontend==20250305.0
home-assistant-intents==2025.3.5
httpx==0.28.1
ifaddr==0.2.0
Jinja2==3.1.5
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2025.3.0b4"
version = "2025.3.0b8"
license = {text = "Apache-2.0"}
description = "Open-source home automation platform running on Python 3."
readme = "README.rst"
+9 -9
View File
@@ -234,7 +234,7 @@ aioeafm==0.1.2
aioeagle==1.1.0
# homeassistant.components.ecowitt
aioecowitt==2024.2.1
aioecowitt==2025.3.1
# homeassistant.components.co2signal
aioelectricitymaps==0.4.0
@@ -264,7 +264,7 @@ aioharmony==0.4.1
aiohasupervisor==0.3.0
# homeassistant.components.home_connect
aiohomeconnect==0.15.1
aiohomeconnect==0.16.2
# homeassistant.components.homekit_controller
aiohomekit==3.2.8
@@ -425,7 +425,7 @@ aiowatttime==0.1.1
aiowebdav2==0.3.1
# homeassistant.components.webostv
aiowebostv==0.7.2
aiowebostv==0.7.3
# homeassistant.components.withings
aiowithings==3.1.6
@@ -1152,10 +1152,10 @@ hole==0.8.0
holidays==0.68
# homeassistant.components.frontend
home-assistant-frontend==20250228.0
home-assistant-frontend==20250305.0
# homeassistant.components.conversation
home-assistant-intents==2025.2.26
home-assistant-intents==2025.3.5
# homeassistant.components.homematicip_cloud
homematicip==1.1.7
@@ -1483,7 +1483,7 @@ nettigo-air-monitor==4.0.0
neurio==0.3.1
# homeassistant.components.nexia
nexia==2.0.9
nexia==2.2.1
# homeassistant.components.nextcloud
nextcloudmonitor==1.5.1
@@ -1565,7 +1565,7 @@ omnilogic==0.4.5
ondilo==0.5.0
# homeassistant.components.onedrive
onedrive-personal-sdk==0.0.12
onedrive-personal-sdk==0.0.13
# homeassistant.components.onvif
onvif-zeep-async==3.2.5
@@ -2310,7 +2310,7 @@ pysma==0.7.5
pysmappee==0.2.29
# homeassistant.components.smartthings
pysmartthings==2.4.1
pysmartthings==2.5.0
# homeassistant.components.smarty
pysmarty2==0.10.2
@@ -3058,7 +3058,7 @@ webio-api==0.1.11
webmin-xmlrpc==0.0.2
# homeassistant.components.weheat
weheat==2025.2.22
weheat==2025.2.26
# homeassistant.components.whirlpool
whirlpool-sixth-sense==0.18.12
+9 -9
View File
@@ -222,7 +222,7 @@ aioeafm==0.1.2
aioeagle==1.1.0
# homeassistant.components.ecowitt
aioecowitt==2024.2.1
aioecowitt==2025.3.1
# homeassistant.components.co2signal
aioelectricitymaps==0.4.0
@@ -249,7 +249,7 @@ aioharmony==0.4.1
aiohasupervisor==0.3.0
# homeassistant.components.home_connect
aiohomeconnect==0.15.1
aiohomeconnect==0.16.2
# homeassistant.components.homekit_controller
aiohomekit==3.2.8
@@ -407,7 +407,7 @@ aiowatttime==0.1.1
aiowebdav2==0.3.1
# homeassistant.components.webostv
aiowebostv==0.7.2
aiowebostv==0.7.3
# homeassistant.components.withings
aiowithings==3.1.6
@@ -981,10 +981,10 @@ hole==0.8.0
holidays==0.68
# homeassistant.components.frontend
home-assistant-frontend==20250228.0
home-assistant-frontend==20250305.0
# homeassistant.components.conversation
home-assistant-intents==2025.2.26
home-assistant-intents==2025.3.5
# homeassistant.components.homematicip_cloud
homematicip==1.1.7
@@ -1246,7 +1246,7 @@ netmap==0.7.0.2
nettigo-air-monitor==4.0.0
# homeassistant.components.nexia
nexia==2.0.9
nexia==2.2.1
# homeassistant.components.nextcloud
nextcloudmonitor==1.5.1
@@ -1313,7 +1313,7 @@ omnilogic==0.4.5
ondilo==0.5.0
# homeassistant.components.onedrive
onedrive-personal-sdk==0.0.12
onedrive-personal-sdk==0.0.13
# homeassistant.components.onvif
onvif-zeep-async==3.2.5
@@ -1882,7 +1882,7 @@ pysma==0.7.5
pysmappee==0.2.29
# homeassistant.components.smartthings
pysmartthings==2.4.1
pysmartthings==2.5.0
# homeassistant.components.smarty
pysmarty2==0.10.2
@@ -2462,7 +2462,7 @@ webio-api==0.1.11
webmin-xmlrpc==0.0.2
# homeassistant.components.weheat
weheat==2025.2.22
weheat==2025.2.26
# homeassistant.components.whirlpool
whirlpool-sixth-sense==0.18.12
+1 -1
View File
@@ -25,7 +25,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.6.1,source=/uv,target=/bin/uv \
-c /usr/src/homeassistant/homeassistant/package_constraints.txt \
-r /usr/src/homeassistant/requirements.txt \
stdlib-list==0.10.0 pipdeptree==2.25.0 tqdm==4.67.1 ruff==0.9.7 \
PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.2.26 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2
PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.3.5 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2
LABEL "name"="hassfest"
LABEL "maintainer"="Home Assistant <hello@home-assistant.io>"
-1
View File
@@ -180,7 +180,6 @@ EXCEPTIONS = {
"PyMicroBot", # https://github.com/spycle/pyMicroBot/pull/3
"PySwitchmate", # https://github.com/Danielhiversen/pySwitchmate/pull/16
"PyXiaomiGateway", # https://github.com/Danielhiversen/PyXiaomiGateway/pull/201
"aioecowitt", # https://github.com/home-assistant-libs/aioecowitt/pull/180
"chacha20poly1305", # LGPL
"commentjson", # https://github.com/vaidik/commentjson/pull/55
"crownstone-cloud", # https://github.com/crownstone/crownstone-lib-python-cloud/pull/5
+18
View File
@@ -1 +1,19 @@
"""Tests for the Home Connect integration."""
from typing import Any
from aiohomeconnect.model import ArrayOfHomeAppliances, ArrayOfStatus
from tests.common import load_json_object_fixture
MOCK_APPLIANCES = ArrayOfHomeAppliances.from_dict(
load_json_object_fixture("home_connect/appliances.json")["data"] # type: ignore[arg-type]
)
MOCK_PROGRAMS: dict[str, Any] = load_json_object_fixture("home_connect/programs.json")
MOCK_SETTINGS: dict[str, Any] = load_json_object_fixture("home_connect/settings.json")
MOCK_STATUS = ArrayOfStatus.from_dict(
load_json_object_fixture("home_connect/status.json")["data"] # type: ignore[arg-type]
)
MOCK_AVAILABLE_COMMANDS: dict[str, Any] = load_json_object_fixture(
"home_connect/available_commands.json"
)
+7 -14
View File
@@ -11,11 +11,9 @@ from aiohomeconnect.client import Client as HomeConnectClient
from aiohomeconnect.model import (
ArrayOfCommands,
ArrayOfEvents,
ArrayOfHomeAppliances,
ArrayOfOptions,
ArrayOfPrograms,
ArrayOfSettings,
ArrayOfStatus,
Event,
EventKey,
EventMessage,
@@ -41,20 +39,15 @@ from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry, load_json_object_fixture
MOCK_APPLIANCES = ArrayOfHomeAppliances.from_dict(
load_json_object_fixture("home_connect/appliances.json")["data"]
)
MOCK_PROGRAMS: dict[str, Any] = load_json_object_fixture("home_connect/programs.json")
MOCK_SETTINGS: dict[str, Any] = load_json_object_fixture("home_connect/settings.json")
MOCK_STATUS = ArrayOfStatus.from_dict(
load_json_object_fixture("home_connect/status.json")["data"]
)
MOCK_AVAILABLE_COMMANDS: dict[str, Any] = load_json_object_fixture(
"home_connect/available_commands.json"
from . import (
MOCK_APPLIANCES,
MOCK_AVAILABLE_COMMANDS,
MOCK_PROGRAMS,
MOCK_SETTINGS,
MOCK_STATUS,
)
from tests.common import MockConfigEntry
CLIENT_ID = "1234"
CLIENT_SECRET = "5678"
@@ -1,6 +1,7 @@
"""Test for Home Connect coordinator."""
from collections.abc import Awaitable, Callable
import copy
from datetime import timedelta
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
@@ -20,6 +21,7 @@ from aiohomeconnect.model.error import (
HomeConnectError,
HomeConnectRequestError,
)
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components.home_connect.const import (
@@ -36,8 +38,11 @@ from homeassistant.core import (
callback,
)
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from . import MOCK_APPLIANCES
from tests.common import MockConfigEntry, async_fire_time_changed
@@ -74,6 +79,123 @@ async def test_coordinator_update_failing_get_appliances(
assert config_entry.state == ConfigEntryState.SETUP_RETRY
@pytest.mark.usefixtures("setup_credentials")
@pytest.mark.parametrize("platforms", [("binary_sensor",)])
@pytest.mark.parametrize("appliance_ha_id", ["Washer"], indirect=True)
async def test_coordinator_failure_refresh_and_stream(
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
client: MagicMock,
freezer: FrozenDateTimeFactory,
appliance_ha_id: str,
) -> None:
"""Test entity available state via coordinator refresh and event stream."""
entity_id_1 = "binary_sensor.washer_remote_control"
entity_id_2 = "binary_sensor.washer_remote_start"
await async_setup_component(hass, "homeassistant", {})
await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED
state = hass.states.get(entity_id_1)
assert state
assert state.state != "unavailable"
state = hass.states.get(entity_id_2)
assert state
assert state.state != "unavailable"
client.get_home_appliances.side_effect = HomeConnectError()
# Force a coordinator refresh.
await hass.services.async_call(
"homeassistant", "update_entity", {"entity_id": entity_id_1}, blocking=True
)
await hass.async_block_till_done()
state = hass.states.get(entity_id_1)
assert state
assert state.state == "unavailable"
state = hass.states.get(entity_id_2)
assert state
assert state.state == "unavailable"
# Test that the entity becomes available again after a successful update.
client.get_home_appliances.side_effect = None
client.get_home_appliances.return_value = copy.deepcopy(MOCK_APPLIANCES)
# Move time forward to pass the debounce time.
freezer.tick(timedelta(hours=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
# Force a coordinator refresh.
await hass.services.async_call(
"homeassistant", "update_entity", {"entity_id": entity_id_1}, blocking=True
)
await hass.async_block_till_done()
state = hass.states.get(entity_id_1)
assert state
assert state.state != "unavailable"
state = hass.states.get(entity_id_2)
assert state
assert state.state != "unavailable"
# Test that the event stream makes the entity go available too.
# First make the entity unavailable.
client.get_home_appliances.side_effect = HomeConnectError()
# Move time forward to pass the debounce time
freezer.tick(timedelta(hours=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
# Force a coordinator refresh
await hass.services.async_call(
"homeassistant", "update_entity", {"entity_id": entity_id_1}, blocking=True
)
await hass.async_block_till_done()
state = hass.states.get(entity_id_1)
assert state
assert state.state == "unavailable"
state = hass.states.get(entity_id_2)
assert state
assert state.state == "unavailable"
# Now make the entity available again.
client.get_home_appliances.side_effect = None
client.get_home_appliances.return_value = copy.deepcopy(MOCK_APPLIANCES)
# One event should make all entities for this appliance available again.
event_message = EventMessage(
appliance_ha_id,
EventType.STATUS,
ArrayOfEvents(
[
Event(
key=EventKey.BSH_COMMON_STATUS_REMOTE_CONTROL_ACTIVE,
raw_key=EventKey.BSH_COMMON_STATUS_REMOTE_CONTROL_ACTIVE.value,
timestamp=0,
level="",
handling="",
value=False,
)
],
),
)
await client.add_events([event_message])
await hass.async_block_till_done()
state = hass.states.get(entity_id_1)
assert state
assert state.state != "unavailable"
state = hass.states.get(entity_id_2)
assert state
assert state.state != "unavailable"
@pytest.mark.parametrize(
"mock_method",
[
@@ -330,11 +452,13 @@ async def test_event_listener_resilience(
assert config_entry.state == ConfigEntryState.LOADED
assert len(config_entry._background_tasks) == 1
assert hass.states.is_state(entity_id, initial_state)
state = hass.states.get(entity_id)
assert state
assert state.state == initial_state
await hass.async_block_till_done()
future.set_exception(exception)
await hass.async_block_till_done()
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30))
await hass.async_block_till_done()
@@ -362,4 +486,6 @@ async def test_event_listener_resilience(
)
await hass.async_block_till_done()
assert hass.states.is_state(entity_id, after_event_expected_state)
state = hass.states.get(entity_id)
assert state
assert state.state == after_event_expected_state
@@ -15,8 +15,8 @@
<HVACMode.COOL: 'cool'>,
<HVACMode.DRY: 'dry'>,
]),
'max_temp': 30,
'min_temp': 18,
'max_temp': 86,
'min_temp': 64,
'preset_modes': list([
'air_clean',
]),
@@ -28,7 +28,7 @@
'on',
'off',
]),
'target_temp_step': 1,
'target_temp_step': 2,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
@@ -62,7 +62,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'current_humidity': 40,
'current_temperature': 25,
'current_temperature': 77,
'fan_mode': 'mid',
'fan_modes': list([
'low',
@@ -75,8 +75,8 @@
<HVACMode.COOL: 'cool'>,
<HVACMode.DRY: 'dry'>,
]),
'max_temp': 30,
'min_temp': 18,
'max_temp': 86,
'min_temp': 64,
'preset_mode': None,
'preset_modes': list([
'air_clean',
@@ -94,8 +94,8 @@
]),
'target_temp_high': None,
'target_temp_low': None,
'target_temp_step': 1,
'temperature': 19,
'target_temp_step': 2,
'temperature': 66,
}),
'context': <ANY>,
'entity_id': 'climate.test_air_conditioner',
+2 -1
View File
@@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch
import pytest
from syrupy import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.const import Platform, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@@ -23,6 +23,7 @@ async def test_all_entities(
entity_registry: er.EntityRegistry,
) -> None:
"""Test all entities."""
hass.config.units.temperature_unit = UnitOfTemperature.FAHRENHEIT
with patch("homeassistant.components.lg_thinq.PLATFORMS", [Platform.CLIMATE]):
await setup_integration(hass, mock_config_entry)
+5 -18
View File
@@ -30,7 +30,6 @@ from .mock_data import (
MULTI_MAP_LIST,
NETWORK_INFO,
PROP,
SCENES,
USER_DATA,
USER_EMAIL,
)
@@ -68,24 +67,8 @@ class A01Mock(RoborockMqttClientA01):
return {prot: self.protocol_responses[prot] for prot in dyad_data_protocols}
@pytest.fixture(name="bypass_api_client_fixture")
def bypass_api_client_fixture() -> None:
"""Skip calls to the API client."""
with (
patch(
"homeassistant.components.roborock.RoborockApiClient.get_home_data_v2",
return_value=HOME_DATA,
),
patch(
"homeassistant.components.roborock.RoborockApiClient.get_scenes",
return_value=SCENES,
),
):
yield
@pytest.fixture(name="bypass_api_fixture")
def bypass_api_fixture(bypass_api_client_fixture: Any) -> None:
def bypass_api_fixture() -> None:
"""Skip calls to the API."""
with (
patch("homeassistant.components.roborock.RoborockMqttClientV1.async_connect"),
@@ -93,6 +76,10 @@ def bypass_api_fixture(bypass_api_client_fixture: Any) -> None:
patch(
"homeassistant.components.roborock.coordinator.RoborockMqttClientV1._send_command"
),
patch(
"homeassistant.components.roborock.RoborockApiClient.get_home_data_v2",
return_value=HOME_DATA,
),
patch(
"homeassistant.components.roborock.RoborockMqttClientV1.get_networking",
return_value=NETWORK_INFO,
-17
View File
@@ -9,7 +9,6 @@ from roborock.containers import (
Consumable,
DnDTimer,
HomeData,
HomeDataScene,
MultiMapsList,
NetworkInfo,
S7Status,
@@ -1151,19 +1150,3 @@ MAP_DATA = MapData(0, 0)
MAP_DATA.image = ImageData(
100, 10, 10, 10, 10, ImageConfig(), Image.new("RGB", (1, 1)), lambda p: p
)
SCENES = [
HomeDataScene.from_dict(
{
"name": "sc1",
"id": 12,
},
),
HomeDataScene.from_dict(
{
"name": "sc2",
"id": 24,
},
),
]
-112
View File
@@ -1,112 +0,0 @@
"""Test Roborock Scene platform."""
from unittest.mock import ANY, patch
import pytest
from roborock import RoborockException
from homeassistant.const import SERVICE_TURN_ON, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from tests.common import MockConfigEntry
@pytest.fixture
def bypass_api_client_get_scenes_fixture(bypass_api_fixture) -> None:
"""Fixture to raise when getting scenes."""
with (
patch(
"homeassistant.components.roborock.RoborockApiClient.get_scenes",
side_effect=RoborockException(),
),
):
yield
@pytest.mark.parametrize(
("entity_id"),
[
("scene.roborock_s7_maxv_sc1"),
("scene.roborock_s7_maxv_sc2"),
],
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_get_scenes_failure(
hass: HomeAssistant,
bypass_api_client_get_scenes_fixture,
setup_entry: MockConfigEntry,
entity_id: str,
) -> None:
"""Test that if scene retrieval fails, no entity is being created."""
# Ensure that the entity does not exist
assert hass.states.get(entity_id) is None
@pytest.fixture
def platforms() -> list[Platform]:
"""Fixture to set platforms used in the test."""
return [Platform.SCENE]
@pytest.mark.parametrize(
("entity_id", "scene_id"),
[
("scene.roborock_s7_maxv_sc1", 12),
("scene.roborock_s7_maxv_sc2", 24),
],
)
@pytest.mark.freeze_time("2023-10-30 08:50:00")
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_execute_success(
hass: HomeAssistant,
bypass_api_fixture,
setup_entry: MockConfigEntry,
entity_id: str,
scene_id: int,
) -> None:
"""Test activating the scene entities."""
with patch(
"homeassistant.components.roborock.RoborockApiClient.execute_scene"
) as mock_execute_scene:
await hass.services.async_call(
"scene",
SERVICE_TURN_ON,
blocking=True,
target={"entity_id": entity_id},
)
mock_execute_scene.assert_called_once_with(ANY, scene_id)
assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00"
@pytest.mark.parametrize(
("entity_id", "scene_id"),
[
("scene.roborock_s7_maxv_sc1", 12),
],
)
@pytest.mark.freeze_time("2023-10-30 08:50:00")
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_execute_failure(
hass: HomeAssistant,
bypass_api_fixture,
setup_entry: MockConfigEntry,
entity_id: str,
scene_id: int,
) -> None:
"""Test failure while activating the scene entity."""
with (
patch(
"homeassistant.components.roborock.RoborockApiClient.execute_scene",
side_effect=RoborockException,
) as mock_execute_scene,
pytest.raises(HomeAssistantError, match="Error while calling execute_scene"),
):
await hass.services.async_call(
"scene",
SERVICE_TURN_ON,
blocking=True,
target={"entity_id": entity_id},
)
mock_execute_scene.assert_called_once_with(ANY, scene_id)
assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00"