mirror of
https://github.com/home-assistant/core.git
synced 2026-02-05 14:55:35 +01:00
Compare commits
108 Commits
infrared
...
overkiz_su
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85336eadd6 | ||
|
|
d037b2073d | ||
|
|
ff3abb5b0b | ||
|
|
c014d32cac | ||
|
|
fa58fe5f4e | ||
|
|
46f230c487 | ||
|
|
13a987aba3 | ||
|
|
9cef323581 | ||
|
|
7ea7576188 | ||
|
|
f8abbfd42b | ||
|
|
5cd1821bc9 | ||
|
|
2ef7f26ffb | ||
|
|
184bea49e2 | ||
|
|
c853fb2068 | ||
|
|
79e0a93e48 | ||
|
|
3867c1d7d1 | ||
|
|
b9b6b050cc | ||
|
|
d960736b3d | ||
|
|
3e8923f105 | ||
|
|
17cca3e69d | ||
|
|
12714c489f | ||
|
|
f788d61b4a | ||
|
|
5c726af00b | ||
|
|
d1d207fbb2 | ||
|
|
6c7f8df7f7 | ||
|
|
6f8c9b1504 | ||
|
|
4f9aedbc84 | ||
|
|
afa0f572ce | ||
|
|
a6a1b9ddbd | ||
|
|
c1f5b4593f | ||
|
|
f1de4dc1cc | ||
|
|
4ae0d9a9c6 | ||
|
|
fcd0b579cf | ||
|
|
52fb0343e4 | ||
|
|
1050b4580a | ||
|
|
344c42172e | ||
|
|
93cc0fd7f1 | ||
|
|
05fe636b55 | ||
|
|
f22467d099 | ||
|
|
4bc3899b32 | ||
|
|
fc4d6bf5f1 | ||
|
|
8ed0672a8f | ||
|
|
282e347a1b | ||
|
|
1bfb02b440 | ||
|
|
71b03bd9ae | ||
|
|
cbd69822eb | ||
|
|
db900f4dd2 | ||
|
|
a707e695bc | ||
|
|
4feceac205 | ||
|
|
10c20faaca | ||
|
|
abcd512401 | ||
|
|
fdf8edf474 | ||
|
|
47e1a98bee | ||
|
|
2d8572b943 | ||
|
|
660cfdbd50 | ||
|
|
4208595da6 | ||
|
|
b6b2d2fc6f | ||
|
|
6c4c632848 | ||
|
|
78cf62176f | ||
|
|
df971c7a42 | ||
|
|
1fcabb7f2d | ||
|
|
9fb60c9ea2 | ||
|
|
9c11a4646f | ||
|
|
b036a78776 | ||
|
|
60bb3cb704 | ||
|
|
0e770958ac | ||
|
|
2a54c71b6c | ||
|
|
50463291ab | ||
|
|
43cc34042a | ||
|
|
a02244ccda | ||
|
|
a739619121 | ||
|
|
5db97a5f1c | ||
|
|
804ba9c9cc | ||
|
|
5ecbcea946 | ||
|
|
11be2b6289 | ||
|
|
eefae0307b | ||
|
|
d397ee28ea | ||
|
|
02c821128e | ||
|
|
71dc15d45f | ||
|
|
1078387b22 | ||
|
|
35fab27d15 | ||
|
|
915dc7a908 | ||
|
|
e5a9738983 | ||
|
|
2ff73219a2 | ||
|
|
5dc1270ed1 | ||
|
|
9e95ad5a85 | ||
|
|
9a5d4610f7 | ||
|
|
41c524fce4 | ||
|
|
5f9fa95554 | ||
|
|
6950be8ea9 | ||
|
|
c5a8bf64d0 | ||
|
|
a2b9a6e9df | ||
|
|
a0c567f0da | ||
|
|
c7feafdde6 | ||
|
|
e1e74b0aeb | ||
|
|
673411ef97 | ||
|
|
f7e5af7cb1 | ||
|
|
0ee56ce708 | ||
|
|
f93a176398 | ||
|
|
cd2394bc12 | ||
|
|
5c20b8eaff | ||
|
|
4bd499d3a6 | ||
|
|
8a53b94c5a | ||
|
|
d5aff326e3 | ||
|
|
22f66abbe7 | ||
|
|
f635228b1f | ||
|
|
4c708c143d | ||
|
|
3369459d41 |
1
.github/workflows/builder.yml
vendored
1
.github/workflows/builder.yml
vendored
@@ -235,6 +235,7 @@ jobs:
|
||||
build-args: |
|
||||
BUILD_FROM=${{ steps.vars.outputs.base_image }}
|
||||
tags: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
|
||||
outputs: type=image,push=true,compression=zstd,compression-level=9,force-compression=true,oci-mediatypes=true
|
||||
labels: |
|
||||
io.hass.arch=${{ matrix.arch }}
|
||||
io.hass.version=${{ needs.init.outputs.version }}
|
||||
|
||||
@@ -435,6 +435,7 @@ homeassistant.components.raspberry_pi.*
|
||||
homeassistant.components.rdw.*
|
||||
homeassistant.components.recollect_waste.*
|
||||
homeassistant.components.recorder.*
|
||||
homeassistant.components.redgtech.*
|
||||
homeassistant.components.remember_the_milk.*
|
||||
homeassistant.components.remote.*
|
||||
homeassistant.components.remote_calendar.*
|
||||
|
||||
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@@ -1355,6 +1355,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/recorder/ @home-assistant/core
|
||||
/homeassistant/components/recovery_mode/ @home-assistant/core
|
||||
/tests/components/recovery_mode/ @home-assistant/core
|
||||
/homeassistant/components/redgtech/ @jonhsady @luan-nvg
|
||||
/tests/components/redgtech/ @jonhsady @luan-nvg
|
||||
/homeassistant/components/refoss/ @ashionky
|
||||
/tests/components/refoss/ @ashionky
|
||||
/homeassistant/components/rehlko/ @bdraco @peterager
|
||||
|
||||
@@ -59,13 +59,15 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
# Replace unique id for "DND" switch and remove from Speaker Group
|
||||
await async_update_unique_id(
|
||||
hass, coordinator, SWITCH_DOMAIN, "do_not_disturb", "dnd"
|
||||
)
|
||||
# DND keys
|
||||
old_key = "do_not_disturb"
|
||||
new_key = "dnd"
|
||||
|
||||
# Remove DND switch from virtual groups
|
||||
await async_remove_dnd_from_virtual_group(hass, coordinator)
|
||||
# Remove old DND switch from virtual groups
|
||||
await async_remove_dnd_from_virtual_group(hass, coordinator, old_key)
|
||||
|
||||
# Replace unique id for DND switch
|
||||
await async_update_unique_id(hass, coordinator, SWITCH_DOMAIN, old_key, new_key)
|
||||
|
||||
known_devices: set[str] = set()
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ def alexa_api_call[_T: AmazonEntity, **_P](
|
||||
async def async_update_unique_id(
|
||||
hass: HomeAssistant,
|
||||
coordinator: AmazonDevicesCoordinator,
|
||||
domain: str,
|
||||
platform: str,
|
||||
old_key: str,
|
||||
new_key: str,
|
||||
) -> None:
|
||||
@@ -63,7 +63,9 @@ async def async_update_unique_id(
|
||||
|
||||
for serial_num in coordinator.data:
|
||||
unique_id = f"{serial_num}-{old_key}"
|
||||
if entity_id := entity_registry.async_get_entity_id(domain, DOMAIN, unique_id):
|
||||
if entity_id := entity_registry.async_get_entity_id(
|
||||
DOMAIN, platform, unique_id
|
||||
):
|
||||
_LOGGER.debug("Updating unique_id for %s", entity_id)
|
||||
new_unique_id = unique_id.replace(old_key, new_key)
|
||||
|
||||
@@ -74,12 +76,13 @@ async def async_update_unique_id(
|
||||
async def async_remove_dnd_from_virtual_group(
|
||||
hass: HomeAssistant,
|
||||
coordinator: AmazonDevicesCoordinator,
|
||||
key: str,
|
||||
) -> None:
|
||||
"""Remove entity DND from virtual group."""
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
for serial_num in coordinator.data:
|
||||
unique_id = f"{serial_num}-do_not_disturb"
|
||||
unique_id = f"{serial_num}-{key}"
|
||||
entity_id = entity_registry.async_get_entity_id(
|
||||
DOMAIN, SWITCH_DOMAIN, unique_id
|
||||
)
|
||||
@@ -104,7 +107,7 @@ async def async_remove_unsupported_notification_sensors(
|
||||
):
|
||||
unique_id = f"{serial_num}-{notification_key}"
|
||||
entity_id = entity_registry.async_get_entity_id(
|
||||
domain=SENSOR_DOMAIN, platform=DOMAIN, unique_id=unique_id
|
||||
DOMAIN, SENSOR_DOMAIN, unique_id=unique_id
|
||||
)
|
||||
is_unsupported = not coordinator.data[serial_num].notifications_supported
|
||||
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pyatv.const import KeyboardFocusState
|
||||
from pyatv.const import FeatureName, FeatureState, KeyboardFocusState
|
||||
from pyatv.interface import AppleTV, KeyboardListener
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AppleTvConfigEntry
|
||||
from . import SIGNAL_CONNECTED, AppleTvConfigEntry
|
||||
from .entity import AppleTVEntity
|
||||
|
||||
|
||||
@@ -21,10 +22,22 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Load Apple TV binary sensor based on a config entry."""
|
||||
# apple_tv config entries always have a unique id
|
||||
assert config_entry.unique_id is not None
|
||||
name: str = config_entry.data[CONF_NAME]
|
||||
manager = config_entry.runtime_data
|
||||
async_add_entities([AppleTVKeyboardFocused(name, config_entry.unique_id, manager)])
|
||||
cb: CALLBACK_TYPE
|
||||
|
||||
def setup_entities(atv: AppleTV) -> None:
|
||||
if atv.features.in_state(FeatureState.Available, FeatureName.TextFocusState):
|
||||
assert config_entry.unique_id is not None
|
||||
name: str = config_entry.data[CONF_NAME]
|
||||
async_add_entities(
|
||||
[AppleTVKeyboardFocused(name, config_entry.unique_id, manager)]
|
||||
)
|
||||
cb()
|
||||
|
||||
cb = async_dispatcher_connect(
|
||||
hass, f"{SIGNAL_CONNECTED}_{config_entry.unique_id}", setup_entities
|
||||
)
|
||||
config_entry.async_on_unload(cb)
|
||||
|
||||
|
||||
class AppleTVKeyboardFocused(AppleTVEntity, BinarySensorEntity, KeyboardListener):
|
||||
|
||||
@@ -58,14 +58,14 @@
|
||||
"name": "Enable motion detection"
|
||||
},
|
||||
"play_stream": {
|
||||
"description": "Plays the camera stream on a supported media player.",
|
||||
"description": "Plays a camera stream on a supported media player.",
|
||||
"fields": {
|
||||
"format": {
|
||||
"description": "Stream format supported by the media player.",
|
||||
"name": "Format"
|
||||
},
|
||||
"media_player": {
|
||||
"description": "Media players to stream to.",
|
||||
"description": "Media player to stream to.",
|
||||
"name": "Media player"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -13,6 +13,6 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["acme", "hass_nabucasa", "snitun"],
|
||||
"requirements": ["hass-nabucasa==1.12.0", "openai==2.15.0"],
|
||||
"requirements": ["hass-nabucasa==1.13.0", "openai==2.15.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.2.3"]
|
||||
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.1.28"]
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ class EvoChild(EvoEntity):
|
||||
|
||||
self._device_state_attrs = {
|
||||
"activeFaults": self._evo_device.active_faults,
|
||||
"setpoints": self._setpoints,
|
||||
"setpoints": self.setpoints,
|
||||
}
|
||||
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"codeowners": ["@zxdavb"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/evohome",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["evohome", "evohomeasync", "evohomeasync2"],
|
||||
"loggers": ["evohomeasync", "evohomeasync2"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["evohome-async==1.0.6"]
|
||||
"requirements": ["evohome-async==1.1.3"]
|
||||
}
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
"""The Fressnapf Tracker integration."""
|
||||
|
||||
from fressnapftracker import AuthClient, FressnapfTrackerAuthenticationError
|
||||
import logging
|
||||
|
||||
from fressnapftracker import (
|
||||
ApiClient,
|
||||
AuthClient,
|
||||
Device,
|
||||
FressnapfTrackerAuthenticationError,
|
||||
FressnapfTrackerError,
|
||||
FressnapfTrackerInvalidTrackerResponseError,
|
||||
Tracker,
|
||||
)
|
||||
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
|
||||
from .const import CONF_USER_ID, DOMAIN
|
||||
from .coordinator import (
|
||||
@@ -21,6 +32,43 @@ PLATFORMS: list[Platform] = [
|
||||
Platform.SWITCH,
|
||||
]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _get_valid_tracker(hass: HomeAssistant, device: Device) -> Tracker | None:
|
||||
"""Test if the tracker returns valid data and return it.
|
||||
|
||||
Malformed data might indicate the tracker is broken or hasn't been properly registered with the app.
|
||||
"""
|
||||
client = ApiClient(
|
||||
serial_number=device.serialnumber,
|
||||
device_token=device.token,
|
||||
client=get_async_client(hass),
|
||||
)
|
||||
try:
|
||||
return await client.get_tracker()
|
||||
except FressnapfTrackerInvalidTrackerResponseError:
|
||||
_LOGGER.warning(
|
||||
"Tracker with serialnumber %s is invalid. Consider removing it via the App",
|
||||
device.serialnumber,
|
||||
)
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"invalid_fressnapf_tracker_{device.serialnumber}",
|
||||
issue_domain=DOMAIN,
|
||||
learn_more_url="https://www.home-assistant.io/integrations/fressnapf_tracker/",
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="invalid_fressnapf_tracker",
|
||||
translation_placeholders={
|
||||
"tracker_id": device.serialnumber,
|
||||
},
|
||||
)
|
||||
return None
|
||||
except FressnapfTrackerError as err:
|
||||
raise ConfigEntryNotReady(err) from err
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: FressnapfTrackerConfigEntry
|
||||
@@ -40,12 +88,15 @@ async def async_setup_entry(
|
||||
|
||||
coordinators: list[FressnapfTrackerDataUpdateCoordinator] = []
|
||||
for device in devices:
|
||||
tracker = await _get_valid_tracker(hass, device)
|
||||
if tracker is None:
|
||||
continue
|
||||
coordinator = FressnapfTrackerDataUpdateCoordinator(
|
||||
hass,
|
||||
entry,
|
||||
device,
|
||||
initial_data=tracker,
|
||||
)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
coordinators.append(coordinator)
|
||||
|
||||
entry.runtime_data = coordinators
|
||||
|
||||
@@ -34,6 +34,7 @@ class FressnapfTrackerDataUpdateCoordinator(DataUpdateCoordinator[Tracker]):
|
||||
hass: HomeAssistant,
|
||||
config_entry: FressnapfTrackerConfigEntry,
|
||||
device: Device,
|
||||
initial_data: Tracker,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(
|
||||
@@ -49,6 +50,7 @@ class FressnapfTrackerDataUpdateCoordinator(DataUpdateCoordinator[Tracker]):
|
||||
device_token=device.token,
|
||||
client=get_async_client(hass),
|
||||
)
|
||||
self.data = initial_data
|
||||
|
||||
async def _async_update_data(self) -> Tracker:
|
||||
try:
|
||||
|
||||
@@ -92,5 +92,11 @@
|
||||
"not_seen_recently": {
|
||||
"message": "The flashlight cannot be activated when the tracker has not moved recently."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"invalid_fressnapf_tracker": {
|
||||
"description": "The Fressnapf GPS tracker with the serial number `{tracker_id}` is sending invalid data. This most likely indicates the tracker is broken or hasn't been properly registered with the app. Please remove the tracker via the Fressnapf app. You can then close this issue.",
|
||||
"title": "Invalid Fressnapf GPS tracker detected"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["google_air_quality_api"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["google_air_quality_api==3.0.0"]
|
||||
"requirements": ["google_air_quality_api==3.0.1"]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyliebherrhomeapi"],
|
||||
"quality_scale": "bronze",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pyliebherrhomeapi==0.2.1"],
|
||||
"zeroconf": [
|
||||
{
|
||||
|
||||
@@ -34,7 +34,7 @@ rules:
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
@@ -5,10 +5,9 @@ from __future__ import annotations
|
||||
from collections.abc import Iterable
|
||||
import csv
|
||||
import dataclasses
|
||||
from functools import partial
|
||||
import logging
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Any, Final, Self, cast, final
|
||||
from typing import TYPE_CHECKING, Any, Self, cast, final
|
||||
|
||||
from propcache.api import cached_property
|
||||
import voluptuous as vol
|
||||
@@ -23,13 +22,6 @@ from homeassistant.const import (
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers.deprecation import (
|
||||
DeprecatedConstant,
|
||||
DeprecatedConstantEnum,
|
||||
all_with_deprecated_constants,
|
||||
check_if_deprecated_constant,
|
||||
dir_with_deprecated_constants,
|
||||
)
|
||||
from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.frame import ReportBehavior, report_usage
|
||||
@@ -56,27 +48,6 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
|
||||
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
|
||||
|
||||
|
||||
# These SUPPORT_* constants are deprecated as of Home Assistant 2022.5.
|
||||
# Please use the LightEntityFeature enum instead.
|
||||
_DEPRECATED_SUPPORT_BRIGHTNESS: Final = DeprecatedConstant(
|
||||
1, "supported_color_modes", "2026.1"
|
||||
) # Deprecated, replaced by color modes
|
||||
_DEPRECATED_SUPPORT_COLOR_TEMP: Final = DeprecatedConstant(
|
||||
2, "supported_color_modes", "2026.1"
|
||||
) # Deprecated, replaced by color modes
|
||||
_DEPRECATED_SUPPORT_EFFECT: Final = DeprecatedConstantEnum(
|
||||
LightEntityFeature.EFFECT, "2026.1"
|
||||
)
|
||||
_DEPRECATED_SUPPORT_FLASH: Final = DeprecatedConstantEnum(
|
||||
LightEntityFeature.FLASH, "2026.1"
|
||||
)
|
||||
_DEPRECATED_SUPPORT_COLOR: Final = DeprecatedConstant(
|
||||
16, "supported_color_modes", "2026.1"
|
||||
) # Deprecated, replaced by color modes
|
||||
_DEPRECATED_SUPPORT_TRANSITION: Final = DeprecatedConstantEnum(
|
||||
LightEntityFeature.TRANSITION, "2026.1"
|
||||
)
|
||||
|
||||
# Color mode of the light
|
||||
ATTR_COLOR_MODE = "color_mode"
|
||||
# List of color modes supported by the light
|
||||
@@ -291,7 +262,7 @@ def filter_turn_off_params(
|
||||
if not params:
|
||||
return params
|
||||
|
||||
supported_features = light.supported_features_compat
|
||||
supported_features = light.supported_features
|
||||
|
||||
if LightEntityFeature.FLASH not in supported_features:
|
||||
params.pop(ATTR_FLASH, None)
|
||||
@@ -303,7 +274,7 @@ def filter_turn_off_params(
|
||||
|
||||
def filter_turn_on_params(light: LightEntity, params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Filter out params not supported by the light."""
|
||||
supported_features = light.supported_features_compat
|
||||
supported_features = light.supported_features
|
||||
|
||||
if LightEntityFeature.EFFECT not in supported_features:
|
||||
params.pop(ATTR_EFFECT, None)
|
||||
@@ -956,7 +927,7 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
def capability_attributes(self) -> dict[str, Any]:
|
||||
"""Return capability attributes."""
|
||||
data: dict[str, Any] = {}
|
||||
supported_features = self.supported_features_compat
|
||||
supported_features = self.supported_features
|
||||
supported_color_modes = self._light_internal_supported_color_modes
|
||||
|
||||
if ColorMode.COLOR_TEMP in supported_color_modes:
|
||||
@@ -1106,12 +1077,12 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
def state_attributes(self) -> dict[str, Any] | None:
|
||||
"""Return state attributes."""
|
||||
data: dict[str, Any] = {}
|
||||
supported_features = self.supported_features_compat
|
||||
supported_features = self.supported_features
|
||||
supported_color_modes = self.supported_color_modes
|
||||
legacy_supported_color_modes = (
|
||||
supported_color_modes or self._light_internal_supported_color_modes
|
||||
)
|
||||
supported_features_value = supported_features.value
|
||||
|
||||
_is_on = self.is_on
|
||||
color_mode = self._light_internal_color_mode if _is_on else None
|
||||
|
||||
@@ -1130,26 +1101,12 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
data[ATTR_BRIGHTNESS] = self.brightness
|
||||
else:
|
||||
data[ATTR_BRIGHTNESS] = None
|
||||
elif supported_features_value & _DEPRECATED_SUPPORT_BRIGHTNESS.value:
|
||||
# Backwards compatibility for ambiguous / incomplete states
|
||||
# Warning is printed by supported_features_compat, remove in 2025.1
|
||||
if _is_on:
|
||||
data[ATTR_BRIGHTNESS] = self.brightness
|
||||
else:
|
||||
data[ATTR_BRIGHTNESS] = None
|
||||
|
||||
if color_temp_supported(supported_color_modes):
|
||||
if color_mode == ColorMode.COLOR_TEMP:
|
||||
data[ATTR_COLOR_TEMP_KELVIN] = self.color_temp_kelvin
|
||||
else:
|
||||
data[ATTR_COLOR_TEMP_KELVIN] = None
|
||||
elif supported_features_value & _DEPRECATED_SUPPORT_COLOR_TEMP.value:
|
||||
# Backwards compatibility
|
||||
# Warning is printed by supported_features_compat, remove in 2025.1
|
||||
if _is_on:
|
||||
data[ATTR_COLOR_TEMP_KELVIN] = self.color_temp_kelvin
|
||||
else:
|
||||
data[ATTR_COLOR_TEMP_KELVIN] = None
|
||||
|
||||
if color_supported(legacy_supported_color_modes) or color_temp_supported(
|
||||
legacy_supported_color_modes
|
||||
@@ -1187,24 +1144,8 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
type(self),
|
||||
report_issue,
|
||||
)
|
||||
supported_features = self.supported_features_compat
|
||||
supported_features_value = supported_features.value
|
||||
supported_color_modes: set[ColorMode] = set()
|
||||
|
||||
if supported_features_value & _DEPRECATED_SUPPORT_COLOR_TEMP.value:
|
||||
supported_color_modes.add(ColorMode.COLOR_TEMP)
|
||||
if supported_features_value & _DEPRECATED_SUPPORT_COLOR.value:
|
||||
supported_color_modes.add(ColorMode.HS)
|
||||
if (
|
||||
not supported_color_modes
|
||||
and supported_features_value & _DEPRECATED_SUPPORT_BRIGHTNESS.value
|
||||
):
|
||||
supported_color_modes = {ColorMode.BRIGHTNESS}
|
||||
|
||||
if not supported_color_modes:
|
||||
supported_color_modes = {ColorMode.ONOFF}
|
||||
|
||||
return supported_color_modes
|
||||
return {ColorMode.ONOFF}
|
||||
|
||||
@cached_property
|
||||
def supported_color_modes(self) -> set[ColorMode] | None:
|
||||
@@ -1216,48 +1157,9 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
"""Flag supported features."""
|
||||
return self._attr_supported_features
|
||||
|
||||
@property
|
||||
def supported_features_compat(self) -> LightEntityFeature:
|
||||
"""Return the supported features as LightEntityFeature.
|
||||
|
||||
Remove this compatibility shim in 2025.1 or later.
|
||||
"""
|
||||
features = self.supported_features
|
||||
if type(features) is not int:
|
||||
return features
|
||||
new_features = LightEntityFeature(features)
|
||||
if self._deprecated_supported_features_reported is True:
|
||||
return new_features
|
||||
self._deprecated_supported_features_reported = True
|
||||
report_issue = self._suggest_report_issue()
|
||||
report_issue += (
|
||||
" and reference "
|
||||
"https://developers.home-assistant.io/blog/2023/12/28/support-feature-magic-numbers-deprecation"
|
||||
)
|
||||
_LOGGER.warning(
|
||||
(
|
||||
"Entity %s (%s) is using deprecated supported features"
|
||||
" values which will be removed in HA Core 2025.1. Instead it should use"
|
||||
" %s and color modes, please %s"
|
||||
),
|
||||
self.entity_id,
|
||||
type(self),
|
||||
repr(new_features),
|
||||
report_issue,
|
||||
)
|
||||
return new_features
|
||||
|
||||
def __should_report_light_issue(self) -> bool:
|
||||
"""Return if light color mode issues should be reported."""
|
||||
if not self.platform:
|
||||
return True
|
||||
# philips_js has known issues, we don't need users to open issues
|
||||
return self.platform.platform_name not in {"philips_js"}
|
||||
|
||||
|
||||
# These can be removed if no deprecated constant are in this module anymore
|
||||
__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
|
||||
__dir__ = partial(
|
||||
dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
|
||||
)
|
||||
__all__ = all_with_deprecated_constants(globals())
|
||||
|
||||
@@ -58,18 +58,95 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]):
|
||||
"""Return Overkiz device linked to this entity."""
|
||||
return self.coordinator.data[self.device_url]
|
||||
|
||||
def _get_sibling_devices(self) -> list[Device]:
|
||||
"""Return sibling devices sharing the same base device URL."""
|
||||
prefix = f"{self.base_device_url}#"
|
||||
return [
|
||||
device
|
||||
for device in self.coordinator.data.values()
|
||||
if device.device_url != self.device_url
|
||||
and device.device_url.startswith(prefix)
|
||||
]
|
||||
|
||||
def _has_siblings_with_different_place_oid(self) -> bool:
|
||||
"""Check if sibling devices have different placeOIDs.
|
||||
|
||||
Returns True if siblings have different place_oid values, indicating
|
||||
devices should be grouped by placeOID rather than by base URL.
|
||||
"""
|
||||
my_place_oid = self.device.place_oid
|
||||
if not my_place_oid:
|
||||
return False
|
||||
|
||||
return any(
|
||||
sibling.place_oid and sibling.place_oid != my_place_oid
|
||||
for sibling in self._get_sibling_devices()
|
||||
)
|
||||
|
||||
def _get_device_index(self, device_url: str) -> int | None:
|
||||
"""Extract numeric index from device URL (e.g., 'io://gw/123#4' -> 4)."""
|
||||
suffix = device_url.split("#")[-1]
|
||||
return int(suffix) if suffix.isdigit() else None
|
||||
|
||||
def _is_main_device_for_place_oid(self) -> bool:
|
||||
"""Check if this device is the main device for its placeOID group.
|
||||
|
||||
The device with the lowest URL index among siblings sharing the same
|
||||
placeOID is considered the main device and provides full device info.
|
||||
"""
|
||||
my_place_oid = self.device.place_oid
|
||||
if not my_place_oid:
|
||||
return True
|
||||
|
||||
my_index = self._get_device_index(self.device_url)
|
||||
if my_index is None:
|
||||
return True
|
||||
|
||||
return not any(
|
||||
(sibling_index := self._get_device_index(sibling.device_url)) is not None
|
||||
and sibling_index < my_index
|
||||
for sibling in self._get_sibling_devices()
|
||||
if sibling.place_oid == my_place_oid
|
||||
)
|
||||
|
||||
def _get_via_device_id(self, use_place_oid_grouping: bool) -> str:
|
||||
"""Return the via_device identifier for device registry hierarchy.
|
||||
|
||||
Sub-devices link to the main actuator (#1 device) when using placeOID
|
||||
grouping, otherwise they link directly to the gateway.
|
||||
"""
|
||||
gateway_id = self.executor.get_gateway_id()
|
||||
|
||||
if not use_place_oid_grouping or self.device_url.endswith("#1"):
|
||||
return gateway_id
|
||||
|
||||
main_device = self.coordinator.data.get(f"{self.base_device_url}#1")
|
||||
if main_device and main_device.place_oid:
|
||||
return f"{self.base_device_url}#{main_device.place_oid}"
|
||||
|
||||
return gateway_id
|
||||
|
||||
def generate_device_info(self) -> DeviceInfo:
|
||||
"""Return device registry information for this entity."""
|
||||
# Some devices, such as the Smart Thermostat have several devices
|
||||
# in one physical device, with same device url, terminated by '#' and a number.
|
||||
# In this case, we use the base device url as the device identifier.
|
||||
if self.is_sub_device:
|
||||
# Only return the url of the base device, to inherit device name
|
||||
# and model from parent device.
|
||||
# Some devices, such as the Smart Thermostat, have several sub-devices
|
||||
# sharing the same base URL (terminated by '#' and a number).
|
||||
use_place_oid_grouping = self._has_siblings_with_different_place_oid()
|
||||
|
||||
# Sub-devices without placeOID grouping inherit info from parent device
|
||||
if self.is_sub_device and not use_place_oid_grouping:
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, self.executor.base_device_url)},
|
||||
)
|
||||
|
||||
# Determine identifier based on grouping strategy
|
||||
if use_place_oid_grouping:
|
||||
identifier = f"{self.base_device_url}#{self.device.place_oid}"
|
||||
# Non-main devices only reference the identifier
|
||||
if not self._is_main_device_for_place_oid():
|
||||
return DeviceInfo(identifiers={(DOMAIN, identifier)})
|
||||
else:
|
||||
identifier = self.executor.base_device_url
|
||||
|
||||
manufacturer = (
|
||||
self.executor.select_attribute(OverkizAttribute.CORE_MANUFACTURER)
|
||||
or self.executor.select_state(OverkizState.CORE_MANUFACTURER_NAME)
|
||||
@@ -92,7 +169,7 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]):
|
||||
)
|
||||
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, self.executor.base_device_url)},
|
||||
identifiers={(DOMAIN, identifier)},
|
||||
name=self.device.label,
|
||||
manufacturer=str(manufacturer),
|
||||
model=str(model),
|
||||
@@ -102,7 +179,7 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]):
|
||||
),
|
||||
hw_version=self.device.controllable_name,
|
||||
suggested_area=suggested_area,
|
||||
via_device=(DOMAIN, self.executor.get_gateway_id()),
|
||||
via_device=(DOMAIN, self._get_via_device_id(use_place_oid_grouping)),
|
||||
configuration_url=self.coordinator.client.server.configuration_url,
|
||||
)
|
||||
|
||||
|
||||
35
homeassistant/components/redgtech/__init__.py
Normal file
35
homeassistant/components/redgtech/__init__.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Initialize the Redgtech integration for Home Assistant."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import RedgtechConfigEntry, RedgtechDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
PLATFORMS = [Platform.SWITCH]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: RedgtechConfigEntry) -> bool:
|
||||
"""Set up Redgtech from a config entry."""
|
||||
_LOGGER.debug("Setting up Redgtech entry: %s", entry.entry_id)
|
||||
coordinator = RedgtechDataUpdateCoordinator(hass, entry)
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
_LOGGER.debug("Successfully set up Redgtech entry: %s", entry.entry_id)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: RedgtechConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
_LOGGER.debug("Unloading Redgtech entry: %s", entry.entry_id)
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
67
homeassistant/components/redgtech/config_flow.py
Normal file
67
homeassistant/components/redgtech/config_flow.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Config flow for the Redgtech integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from redgtech_api.api import RedgtechAPI, RedgtechAuthError, RedgtechConnectionError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
|
||||
|
||||
from .const import DOMAIN, INTEGRATION_NAME
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RedgtechConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Config Flow for Redgtech integration."""
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial user step for login."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
email = user_input[CONF_EMAIL]
|
||||
password = user_input[CONF_PASSWORD]
|
||||
|
||||
self._async_abort_entries_match({CONF_EMAIL: user_input[CONF_EMAIL]})
|
||||
|
||||
api = RedgtechAPI()
|
||||
try:
|
||||
await api.login(email, password)
|
||||
except RedgtechAuthError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except RedgtechConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error during login")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
_LOGGER.debug("Login successful, token received")
|
||||
return self.async_create_entry(
|
||||
title=email,
|
||||
data={
|
||||
CONF_EMAIL: email,
|
||||
CONF_PASSWORD: password,
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_EMAIL): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
),
|
||||
user_input,
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders={"integration_name": INTEGRATION_NAME},
|
||||
)
|
||||
4
homeassistant/components/redgtech/const.py
Normal file
4
homeassistant/components/redgtech/const.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""Constants for the Redgtech integration."""
|
||||
|
||||
DOMAIN = "redgtech"
|
||||
INTEGRATION_NAME = "Redgtech"
|
||||
130
homeassistant/components/redgtech/coordinator.py
Normal file
130
homeassistant/components/redgtech/coordinator.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""Coordinator for Redgtech integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from redgtech_api.api import RedgtechAPI, RedgtechAuthError, RedgtechConnectionError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
UPDATE_INTERVAL = timedelta(seconds=15)
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RedgtechDevice:
|
||||
"""Representation of a Redgtech device."""
|
||||
|
||||
unique_id: str
|
||||
name: str
|
||||
state: bool
|
||||
|
||||
|
||||
type RedgtechConfigEntry = ConfigEntry[RedgtechDataUpdateCoordinator]
|
||||
|
||||
|
||||
class RedgtechDataUpdateCoordinator(DataUpdateCoordinator[dict[str, RedgtechDevice]]):
|
||||
"""Coordinator to manage fetching data from the Redgtech API.
|
||||
|
||||
Uses a dictionary keyed by unique_id for O(1) device lookup instead of O(n) list iteration.
|
||||
"""
|
||||
|
||||
config_entry: RedgtechConfigEntry
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config_entry: RedgtechConfigEntry) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
self.api = RedgtechAPI()
|
||||
self.access_token: str | None = None
|
||||
self.email = config_entry.data[CONF_EMAIL]
|
||||
self.password = config_entry.data[CONF_PASSWORD]
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=UPDATE_INTERVAL,
|
||||
config_entry=config_entry,
|
||||
)
|
||||
|
||||
async def login(self, email: str, password: str) -> str | None:
|
||||
"""Login to the Redgtech API and return the access token."""
|
||||
try:
|
||||
self.access_token = await self.api.login(email, password)
|
||||
except RedgtechAuthError as e:
|
||||
raise ConfigEntryError("Authentication error during login") from e
|
||||
except RedgtechConnectionError as e:
|
||||
raise UpdateFailed("Connection error during login") from e
|
||||
else:
|
||||
_LOGGER.debug("Access token obtained successfully")
|
||||
return self.access_token
|
||||
|
||||
async def renew_token(self, email: str, password: str) -> None:
|
||||
"""Renew the access token."""
|
||||
self.access_token = await self.api.login(email, password)
|
||||
_LOGGER.debug("Access token renewed successfully")
|
||||
|
||||
async def call_api_with_valid_token[_R, *_Ts](
|
||||
self, api_call: Callable[[*_Ts], Coroutine[Any, Any, _R]], *args: *_Ts
|
||||
) -> _R:
|
||||
"""Make an API call with a valid token.
|
||||
|
||||
Ensure we have a valid access token, renewing it if necessary.
|
||||
"""
|
||||
if not self.access_token:
|
||||
_LOGGER.debug("No access token, logging in")
|
||||
self.access_token = await self.login(self.email, self.password)
|
||||
else:
|
||||
_LOGGER.debug("Using existing access token")
|
||||
try:
|
||||
return await api_call(*args)
|
||||
except RedgtechAuthError:
|
||||
_LOGGER.debug("Auth failed, trying to renew token")
|
||||
await self.renew_token(
|
||||
self.config_entry.data[CONF_EMAIL],
|
||||
self.config_entry.data[CONF_PASSWORD],
|
||||
)
|
||||
return await api_call(*args)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, RedgtechDevice]:
|
||||
"""Fetch data from the API on demand.
|
||||
|
||||
Returns a dictionary keyed by unique_id for efficient device lookup.
|
||||
"""
|
||||
_LOGGER.debug("Fetching data from Redgtech API on demand")
|
||||
try:
|
||||
data = await self.call_api_with_valid_token(
|
||||
self.api.get_data, self.access_token
|
||||
)
|
||||
except RedgtechAuthError as e:
|
||||
raise ConfigEntryError("Authentication failed") from e
|
||||
except RedgtechConnectionError as e:
|
||||
raise UpdateFailed("Failed to connect to Redgtech API") from e
|
||||
|
||||
devices: dict[str, RedgtechDevice] = {}
|
||||
|
||||
for item in data["boards"]:
|
||||
display_categories = {cat.lower() for cat in item["displayCategories"]}
|
||||
|
||||
if "light" in display_categories or "switch" not in display_categories:
|
||||
continue
|
||||
|
||||
device = RedgtechDevice(
|
||||
unique_id=item["endpointId"],
|
||||
name=item["friendlyName"],
|
||||
state=item["value"],
|
||||
)
|
||||
_LOGGER.debug("Processing device: %s", device)
|
||||
devices[device.unique_id] = device
|
||||
|
||||
return devices
|
||||
11
homeassistant/components/redgtech/manifest.json
Normal file
11
homeassistant/components/redgtech/manifest.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"domain": "redgtech",
|
||||
"name": "Redgtech",
|
||||
"codeowners": ["@jonhsady", "@luan-nvg"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/redgtech",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["redgtech-api==0.1.38"]
|
||||
}
|
||||
72
homeassistant/components/redgtech/quality_scale.yaml
Normal file
72
homeassistant/components/redgtech/quality_scale.yaml
Normal file
@@ -0,0 +1,72 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: No custom actions
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: No custom actions
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: No explicit signature for events
|
||||
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: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: No options flow
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: todo
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: Only essential entities
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: No repair issues
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: todo
|
||||
strict-typing: done
|
||||
40
homeassistant/components/redgtech/strings.json
Normal file
40
homeassistant/components/redgtech/strings.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"email": "[%key:common::config_flow::data::email%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"email": "Enter the email address associated with your {integration_name} account.",
|
||||
"password": "Enter the password for your {integration_name} account."
|
||||
},
|
||||
"description": "Please enter your credentials to connect to the {integration_name} API.",
|
||||
"title": "Set up {integration_name}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"api_error": {
|
||||
"message": "Error while communicating with the {integration_name} API"
|
||||
},
|
||||
"authentication_failed": {
|
||||
"message": "Authentication failed. Please check your credentials."
|
||||
},
|
||||
"connection_error": {
|
||||
"message": "Connection error with {integration_name} API"
|
||||
},
|
||||
"switch_auth_error": {
|
||||
"message": "Authentication failed when controlling {integration_name} switch"
|
||||
}
|
||||
}
|
||||
}
|
||||
95
homeassistant/components/redgtech/switch.py
Normal file
95
homeassistant/components/redgtech/switch.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Integration for Redgtech switches."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from redgtech_api.api import RedgtechAuthError, RedgtechConnectionError
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, INTEGRATION_NAME
|
||||
from .coordinator import (
|
||||
RedgtechConfigEntry,
|
||||
RedgtechDataUpdateCoordinator,
|
||||
RedgtechDevice,
|
||||
)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: RedgtechConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the switch platform."""
|
||||
coordinator = config_entry.runtime_data
|
||||
async_add_entities(
|
||||
RedgtechSwitch(coordinator, device) for device in coordinator.data.values()
|
||||
)
|
||||
|
||||
|
||||
class RedgtechSwitch(CoordinatorEntity[RedgtechDataUpdateCoordinator], SwitchEntity):
|
||||
"""Representation of a Redgtech switch."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
|
||||
def __init__(
|
||||
self, coordinator: RedgtechDataUpdateCoordinator, device: RedgtechDevice
|
||||
) -> None:
|
||||
"""Initialize the switch."""
|
||||
super().__init__(coordinator)
|
||||
self.coordinator = coordinator
|
||||
self.device = device
|
||||
self._attr_unique_id = device.unique_id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.unique_id)},
|
||||
name=device.name,
|
||||
manufacturer=INTEGRATION_NAME,
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if the switch is on."""
|
||||
if device := self.coordinator.data.get(self.device.unique_id):
|
||||
return bool(device.state)
|
||||
return False
|
||||
|
||||
async def _set_state(self, new_state: bool) -> None:
|
||||
"""Set state of the switch."""
|
||||
try:
|
||||
await self.coordinator.call_api_with_valid_token(
|
||||
self.coordinator.api.set_switch_state,
|
||||
self.device.unique_id,
|
||||
new_state,
|
||||
self.coordinator.access_token,
|
||||
)
|
||||
except RedgtechAuthError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="switch_auth_error",
|
||||
translation_placeholders={"integration_name": INTEGRATION_NAME},
|
||||
) from err
|
||||
except RedgtechConnectionError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="connection_error",
|
||||
translation_placeholders={"integration_name": INTEGRATION_NAME},
|
||||
) from err
|
||||
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
await self._set_state(True)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch off."""
|
||||
await self._set_state(False)
|
||||
@@ -1,6 +1,7 @@
|
||||
"""The syncthing integration."""
|
||||
|
||||
import asyncio
|
||||
from asyncio import Task
|
||||
import logging
|
||||
|
||||
import aiosyncthing
|
||||
@@ -13,7 +14,7 @@ from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
|
||||
@@ -57,7 +58,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
async def cancel_listen_task(_):
|
||||
async def cancel_listen_task(event: Event) -> None:
|
||||
"""Cancel the listen task on Home Assistant stop."""
|
||||
await syncthing.unsubscribe()
|
||||
|
||||
entry.async_on_unload(
|
||||
@@ -80,44 +82,46 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
class SyncthingClient:
|
||||
"""A Syncthing client."""
|
||||
|
||||
def __init__(self, hass, client, server_id):
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, client: aiosyncthing.Syncthing, server_id: str
|
||||
) -> None:
|
||||
"""Initialize the client."""
|
||||
self._hass = hass
|
||||
self._client = client
|
||||
self._server_id = server_id
|
||||
self._listen_task = None
|
||||
self._listen_task: Task[None] | None = None
|
||||
|
||||
@property
|
||||
def server_id(self):
|
||||
def server_id(self) -> str:
|
||||
"""Get server id."""
|
||||
return self._server_id
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
def url(self) -> str:
|
||||
"""Get server URL."""
|
||||
return self._client.url
|
||||
|
||||
@property
|
||||
def database(self):
|
||||
def database(self) -> aiosyncthing.Database:
|
||||
"""Get database namespace client."""
|
||||
return self._client.database
|
||||
|
||||
@property
|
||||
def system(self):
|
||||
def system(self) -> aiosyncthing.System:
|
||||
"""Get system namespace client."""
|
||||
return self._client.system
|
||||
|
||||
def subscribe(self):
|
||||
def subscribe(self) -> None:
|
||||
"""Start event listener coroutine."""
|
||||
self._listen_task = asyncio.create_task(self._listen())
|
||||
|
||||
async def unsubscribe(self):
|
||||
async def unsubscribe(self) -> None:
|
||||
"""Stop event listener coroutine."""
|
||||
if self._listen_task:
|
||||
self._listen_task.cancel()
|
||||
await self._client.close()
|
||||
|
||||
async def _listen(self):
|
||||
async def _listen(self) -> None:
|
||||
"""Listen to Syncthing events."""
|
||||
events = self._client.events
|
||||
server_was_unavailable = False
|
||||
@@ -142,11 +146,7 @@ class SyncthingClient:
|
||||
continue
|
||||
|
||||
signal_name = EVENTS[event["type"]]
|
||||
folder = None
|
||||
if "folder" in event["data"]:
|
||||
folder = event["data"]["folder"]
|
||||
else: # A workaround, some events store folder id under `id` key
|
||||
folder = event["data"]["id"]
|
||||
folder = event["data"].get("folder") or event["data"]["id"]
|
||||
async_dispatcher_send(
|
||||
self._hass,
|
||||
f"{signal_name}-{self._server_id}-{folder}",
|
||||
@@ -168,7 +168,8 @@ class SyncthingClient:
|
||||
server_was_unavailable = True
|
||||
continue
|
||||
|
||||
async def _server_available(self):
|
||||
async def _server_available(self) -> bool:
|
||||
"""Check if the Syncthing server is available."""
|
||||
try:
|
||||
await self._client.system.ping()
|
||||
except aiosyncthing.exceptions.SyncthingError:
|
||||
|
||||
@@ -21,7 +21,7 @@ DATA_SCHEMA = vol.Schema(
|
||||
)
|
||||
|
||||
|
||||
async def validate_input(hass: HomeAssistant, data):
|
||||
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]:
|
||||
"""Validate the user input allows us to connect."""
|
||||
|
||||
try:
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
"""Support for monitoring the Syncthing instance."""
|
||||
"""Support for Syncthing sensors."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import aiosyncthing
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.exceptions import PlatformNotReady
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
|
||||
from . import SyncthingClient
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
FOLDER_PAUSED_RECEIVED,
|
||||
@@ -86,14 +90,21 @@ class FolderSensor(SensorEntity):
|
||||
"stateChanged": "state_changed",
|
||||
}
|
||||
|
||||
def __init__(self, syncthing, server_id, folder_id, folder_label, version):
|
||||
def __init__(
|
||||
self,
|
||||
syncthing: SyncthingClient,
|
||||
server_id: str,
|
||||
folder_id: str,
|
||||
folder_label: str,
|
||||
version: str,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
self._syncthing = syncthing
|
||||
self._server_id = server_id
|
||||
self._folder_id = folder_id
|
||||
self._folder_label = folder_label
|
||||
self._state = None
|
||||
self._unsub_timer = None
|
||||
self._state: dict[str, Any] | None = None
|
||||
self._unsub_timer: CALLBACK_TYPE | None = None
|
||||
|
||||
self._short_server_id = server_id.split("-")[0]
|
||||
self._attr_name = f"{self._short_server_id} {folder_id} {folder_label}"
|
||||
@@ -107,9 +118,9 @@ class FolderSensor(SensorEntity):
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
def native_value(self) -> str | None:
|
||||
"""Return the state of the sensor."""
|
||||
return self._state["state"]
|
||||
return self._state["state"] if self._state else None
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
@@ -117,11 +128,11 @@ class FolderSensor(SensorEntity):
|
||||
return self._state is not None
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
def extra_state_attributes(self) -> Mapping[str, Any] | None:
|
||||
"""Return the state attributes."""
|
||||
return self._state
|
||||
|
||||
async def async_update_status(self):
|
||||
async def async_update_status(self) -> None:
|
||||
"""Request folder status and update state."""
|
||||
try:
|
||||
state = await self._syncthing.database.status(self._folder_id)
|
||||
@@ -131,11 +142,11 @@ class FolderSensor(SensorEntity):
|
||||
self._state = self._filter_state(state)
|
||||
self.async_write_ha_state()
|
||||
|
||||
def subscribe(self):
|
||||
def subscribe(self) -> None:
|
||||
"""Start polling syncthing folder status."""
|
||||
if self._unsub_timer is None:
|
||||
|
||||
async def refresh(event_time):
|
||||
async def refresh(event_time) -> None:
|
||||
"""Get the latest data from Syncthing."""
|
||||
await self.async_update_status()
|
||||
|
||||
@@ -144,7 +155,7 @@ class FolderSensor(SensorEntity):
|
||||
)
|
||||
|
||||
@callback
|
||||
def unsubscribe(self):
|
||||
def unsubscribe(self) -> None:
|
||||
"""Stop polling syncthing folder status."""
|
||||
if self._unsub_timer is not None:
|
||||
self._unsub_timer()
|
||||
@@ -154,8 +165,9 @@ class FolderSensor(SensorEntity):
|
||||
"""Handle entity which will be added."""
|
||||
|
||||
@callback
|
||||
def handle_folder_summary(event):
|
||||
if self._state is not None:
|
||||
def handle_folder_summary(event: dict[str, Any]) -> None:
|
||||
"""Handle folder summary event."""
|
||||
if self._state:
|
||||
self._state = self._filter_state(event["data"]["summary"])
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -168,8 +180,9 @@ class FolderSensor(SensorEntity):
|
||||
)
|
||||
|
||||
@callback
|
||||
def handle_state_changed(event):
|
||||
if self._state is not None:
|
||||
def handle_state_changed(event: dict[str, Any]) -> None:
|
||||
"""Handle folder state changed event."""
|
||||
if self._state:
|
||||
self._state["state"] = event["data"]["to"]
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -182,8 +195,9 @@ class FolderSensor(SensorEntity):
|
||||
)
|
||||
|
||||
@callback
|
||||
def handle_folder_paused(event):
|
||||
if self._state is not None:
|
||||
def handle_folder_paused(event: dict[str, Any]) -> None:
|
||||
"""Handle folder paused event."""
|
||||
if self._state:
|
||||
self._state["state"] = "paused"
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -196,7 +210,8 @@ class FolderSensor(SensorEntity):
|
||||
)
|
||||
|
||||
@callback
|
||||
def handle_server_unavailable():
|
||||
def handle_server_unavailable() -> None:
|
||||
"""Handle server becoming unavailable."""
|
||||
self._state = None
|
||||
self.unsubscribe()
|
||||
self.async_write_ha_state()
|
||||
@@ -209,7 +224,8 @@ class FolderSensor(SensorEntity):
|
||||
)
|
||||
)
|
||||
|
||||
async def handle_server_available():
|
||||
async def handle_server_available() -> None:
|
||||
"""Handle server becoming available."""
|
||||
self.subscribe()
|
||||
await self.async_update_status()
|
||||
|
||||
@@ -226,20 +242,20 @@ class FolderSensor(SensorEntity):
|
||||
|
||||
await self.async_update_status()
|
||||
|
||||
def _filter_state(self, state):
|
||||
# Select only needed state attributes and map their names
|
||||
state = {
|
||||
def _filter_state(self, state: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Filter and map state attributes."""
|
||||
filtered_state: dict[str, Any] = {
|
||||
self.STATE_ATTRIBUTES[key]: value
|
||||
for key, value in state.items()
|
||||
if key in self.STATE_ATTRIBUTES
|
||||
}
|
||||
|
||||
# A workaround, for some reason, state of paused folders is an empty string
|
||||
if state["state"] == "":
|
||||
state["state"] = "paused"
|
||||
if filtered_state["state"] == "":
|
||||
filtered_state["state"] = "paused"
|
||||
|
||||
# Add some useful attributes
|
||||
state["id"] = self._folder_id
|
||||
state["label"] = self._folder_label
|
||||
filtered_state["id"] = self._folder_id
|
||||
filtered_state["label"] = self._folder_label
|
||||
|
||||
return state
|
||||
return filtered_state
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import voluptuous as vol
|
||||
@@ -54,23 +53,11 @@ from .schemas import (
|
||||
from .template_entity import TemplateEntity
|
||||
from .trigger_entity import TriggerEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
OPEN_STATE = "open"
|
||||
OPENING_STATE = "opening"
|
||||
CLOSED_STATE = "closed"
|
||||
CLOSING_STATE = "closing"
|
||||
|
||||
_VALID_STATES = [
|
||||
OPEN_STATE,
|
||||
OPENING_STATE,
|
||||
CLOSED_STATE,
|
||||
CLOSING_STATE,
|
||||
"true",
|
||||
"false",
|
||||
"none",
|
||||
]
|
||||
|
||||
CONF_POSITION = "position"
|
||||
CONF_POSITION_TEMPLATE = "position_template"
|
||||
CONF_TILT = "tilt"
|
||||
|
||||
@@ -14,5 +14,6 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyvesync"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyvesync==3.4.1"]
|
||||
}
|
||||
|
||||
15
homeassistant/components/waterfurnace/icons.json
Normal file
15
homeassistant/components/waterfurnace/icons.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"actual_compressor_speed": {
|
||||
"default": "mdi:speedometer"
|
||||
},
|
||||
"airflow_current_speed": {
|
||||
"default": "mdi:fan"
|
||||
},
|
||||
"mode": {
|
||||
"default": "mdi:gauge"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,89 +18,93 @@ from homeassistant.util import slugify
|
||||
from . import UPDATE_TOPIC, WaterFurnaceConfigEntry, WaterFurnaceData
|
||||
|
||||
SENSORS = [
|
||||
SensorEntityDescription(name="Furnace Mode", key="mode", icon="mdi:gauge"),
|
||||
SensorEntityDescription(
|
||||
name="Total Power",
|
||||
key="mode",
|
||||
translation_key="mode",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="totalunitpower",
|
||||
translation_key="total_unit_power",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
name="Active Setpoint",
|
||||
key="tstatactivesetpoint",
|
||||
translation_key="tstat_active_setpoint",
|
||||
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
name="Leaving Air",
|
||||
key="leavingairtemp",
|
||||
translation_key="leaving_air_temp",
|
||||
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
name="Room Temp",
|
||||
key="tstatroomtemp",
|
||||
translation_key="room_temp",
|
||||
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
name="Loop Temp",
|
||||
key="enteringwatertemp",
|
||||
translation_key="entering_water_temp",
|
||||
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
name="Humidity Set Point",
|
||||
key="tstathumidsetpoint",
|
||||
icon="mdi:water-percent",
|
||||
translation_key="tstat_humid_setpoint",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
name="Humidity",
|
||||
key="tstatrelativehumidity",
|
||||
icon="mdi:water-percent",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
name="Compressor Power",
|
||||
key="compressorpower",
|
||||
translation_key="compressor_power",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
name="Fan Power",
|
||||
key="fanpower",
|
||||
translation_key="fan_power",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
name="Aux Power",
|
||||
key="auxpower",
|
||||
translation_key="aux_power",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
name="Loop Pump Power",
|
||||
key="looppumppower",
|
||||
translation_key="loop_pump_power",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
name="Compressor Speed", key="actualcompressorspeed", icon="mdi:speedometer"
|
||||
key="actualcompressorspeed",
|
||||
translation_key="actual_compressor_speed",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
name="Fan Speed", key="airflowcurrentspeed", icon="mdi:fan"
|
||||
key="airflowcurrentspeed",
|
||||
translation_key="airflow_current_speed",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -124,6 +128,7 @@ class WaterFurnaceSensor(SensorEntity):
|
||||
"""Implementing the Waterfurnace sensor."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self, client: WaterFurnaceData, description: SensorEntityDescription
|
||||
|
||||
@@ -26,6 +26,49 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"actual_compressor_speed": {
|
||||
"name": "Compressor speed"
|
||||
},
|
||||
"airflow_current_speed": {
|
||||
"name": "Fan speed"
|
||||
},
|
||||
"aux_power": {
|
||||
"name": "Aux power"
|
||||
},
|
||||
"compressor_power": {
|
||||
"name": "Compressor power"
|
||||
},
|
||||
"entering_water_temp": {
|
||||
"name": "Loop temperature"
|
||||
},
|
||||
"fan_power": {
|
||||
"name": "Fan power"
|
||||
},
|
||||
"leaving_air_temp": {
|
||||
"name": "Leaving air temperature"
|
||||
},
|
||||
"loop_pump_power": {
|
||||
"name": "Loop pump power"
|
||||
},
|
||||
"mode": {
|
||||
"name": "Furnace mode"
|
||||
},
|
||||
"room_temp": {
|
||||
"name": "Room temperature"
|
||||
},
|
||||
"total_unit_power": {
|
||||
"name": "Total power"
|
||||
},
|
||||
"tstat_active_setpoint": {
|
||||
"name": "Active setpoint"
|
||||
},
|
||||
"tstat_humid_setpoint": {
|
||||
"name": "Humidity setpoint"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml_import_issue_cannot_connect": {
|
||||
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, we could not connect to {integration_title}. Please check your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
|
||||
|
||||
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@@ -570,6 +570,7 @@ FLOWS = {
|
||||
"rapt_ble",
|
||||
"rdw",
|
||||
"recollect_waste",
|
||||
"redgtech",
|
||||
"refoss",
|
||||
"rehlko",
|
||||
"remote_calendar",
|
||||
|
||||
@@ -5583,6 +5583,12 @@
|
||||
"config_flow": false,
|
||||
"iot_class": "cloud_polling"
|
||||
},
|
||||
"redgtech": {
|
||||
"name": "Redgtech",
|
||||
"integration_type": "service",
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling"
|
||||
},
|
||||
"refoss": {
|
||||
"name": "Refoss",
|
||||
"integration_type": "hub",
|
||||
|
||||
@@ -37,11 +37,11 @@ fnv-hash-fast==1.6.0
|
||||
go2rtc-client==0.4.0
|
||||
ha-ffmpeg==3.2.2
|
||||
habluetooth==5.8.0
|
||||
hass-nabucasa==1.12.0
|
||||
hass-nabucasa==1.13.0
|
||||
hassil==3.5.0
|
||||
home-assistant-bluetooth==1.13.1
|
||||
home-assistant-frontend==20260128.6
|
||||
home-assistant-intents==2026.2.3
|
||||
home-assistant-intents==2026.1.28
|
||||
httpx==0.28.1
|
||||
ifaddr==0.2.0
|
||||
Jinja2==3.1.6
|
||||
|
||||
10
mypy.ini
generated
10
mypy.ini
generated
@@ -4106,6 +4106,16 @@ disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.redgtech.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
disallow_subclassing_any = true
|
||||
disallow_untyped_calls = true
|
||||
disallow_untyped_decorators = true
|
||||
disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.remember_the_milk.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
|
||||
@@ -51,7 +51,7 @@ dependencies = [
|
||||
"fnv-hash-fast==1.6.0",
|
||||
# hass-nabucasa is imported by helpers which don't depend on the cloud
|
||||
# integration
|
||||
"hass-nabucasa==1.12.0",
|
||||
"hass-nabucasa==1.13.0",
|
||||
# When bumping httpx, please check the version pins of
|
||||
# httpcore, anyio, and h11 in gen_requirements_all
|
||||
"httpx==0.28.1",
|
||||
|
||||
4
requirements.txt
generated
4
requirements.txt
generated
@@ -25,10 +25,10 @@ cronsim==2.7
|
||||
cryptography==46.0.2
|
||||
fnv-hash-fast==1.6.0
|
||||
ha-ffmpeg==3.2.2
|
||||
hass-nabucasa==1.12.0
|
||||
hass-nabucasa==1.13.0
|
||||
hassil==3.5.0
|
||||
home-assistant-bluetooth==1.13.1
|
||||
home-assistant-intents==2026.2.3
|
||||
home-assistant-intents==2026.1.28
|
||||
httpx==0.28.1
|
||||
ifaddr==0.2.0
|
||||
Jinja2==3.1.6
|
||||
|
||||
11
requirements_all.txt
generated
11
requirements_all.txt
generated
@@ -938,7 +938,7 @@ eufylife-ble-client==0.1.8
|
||||
# evdev==1.6.1
|
||||
|
||||
# homeassistant.components.evohome
|
||||
evohome-async==1.0.6
|
||||
evohome-async==1.1.3
|
||||
|
||||
# homeassistant.components.bryant_evolution
|
||||
evolutionhttp==0.0.18
|
||||
@@ -1105,7 +1105,7 @@ google-nest-sdm==9.1.2
|
||||
google-photos-library-api==0.12.1
|
||||
|
||||
# homeassistant.components.google_air_quality
|
||||
google_air_quality_api==3.0.0
|
||||
google_air_quality_api==3.0.1
|
||||
|
||||
# homeassistant.components.slide
|
||||
# homeassistant.components.slide_local
|
||||
@@ -1175,7 +1175,7 @@ habluetooth==5.8.0
|
||||
hanna-cloud==0.0.7
|
||||
|
||||
# homeassistant.components.cloud
|
||||
hass-nabucasa==1.12.0
|
||||
hass-nabucasa==1.13.0
|
||||
|
||||
# homeassistant.components.splunk
|
||||
hass-splunk==0.1.1
|
||||
@@ -1222,7 +1222,7 @@ holidays==0.84
|
||||
home-assistant-frontend==20260128.6
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2026.2.3
|
||||
home-assistant-intents==2026.1.28
|
||||
|
||||
# homeassistant.components.gentex_homelink
|
||||
homelink-integration-api==0.0.1
|
||||
@@ -2744,6 +2744,9 @@ rapt-ble==0.1.2
|
||||
# homeassistant.components.raspyrfm
|
||||
raspyrfm-client==1.2.9
|
||||
|
||||
# homeassistant.components.redgtech
|
||||
redgtech-api==0.1.38
|
||||
|
||||
# homeassistant.components.refoss
|
||||
refoss-ha==1.2.5
|
||||
|
||||
|
||||
11
requirements_test_all.txt
generated
11
requirements_test_all.txt
generated
@@ -826,7 +826,7 @@ eternalegypt==0.0.18
|
||||
eufylife-ble-client==0.1.8
|
||||
|
||||
# homeassistant.components.evohome
|
||||
evohome-async==1.0.6
|
||||
evohome-async==1.1.3
|
||||
|
||||
# homeassistant.components.bryant_evolution
|
||||
evolutionhttp==0.0.18
|
||||
@@ -981,7 +981,7 @@ google-nest-sdm==9.1.2
|
||||
google-photos-library-api==0.12.1
|
||||
|
||||
# homeassistant.components.google_air_quality
|
||||
google_air_quality_api==3.0.0
|
||||
google_air_quality_api==3.0.1
|
||||
|
||||
# homeassistant.components.slide
|
||||
# homeassistant.components.slide_local
|
||||
@@ -1045,7 +1045,7 @@ habluetooth==5.8.0
|
||||
hanna-cloud==0.0.7
|
||||
|
||||
# homeassistant.components.cloud
|
||||
hass-nabucasa==1.12.0
|
||||
hass-nabucasa==1.13.0
|
||||
|
||||
# homeassistant.components.assist_satellite
|
||||
# homeassistant.components.conversation
|
||||
@@ -1080,7 +1080,7 @@ holidays==0.84
|
||||
home-assistant-frontend==20260128.6
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2026.2.3
|
||||
home-assistant-intents==2026.1.28
|
||||
|
||||
# homeassistant.components.gentex_homelink
|
||||
homelink-integration-api==0.0.1
|
||||
@@ -2310,6 +2310,9 @@ radiotherm==2.1.0
|
||||
# homeassistant.components.rapt_ble
|
||||
rapt-ble==0.1.2
|
||||
|
||||
# homeassistant.components.redgtech
|
||||
redgtech-api==0.1.38
|
||||
|
||||
# homeassistant.components.refoss
|
||||
refoss-ha==1.2.5
|
||||
|
||||
|
||||
@@ -2039,7 +2039,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
|
||||
"versasense",
|
||||
"version",
|
||||
"vicare",
|
||||
"vesync",
|
||||
"viaggiatreno",
|
||||
"vilfo",
|
||||
"vivotek",
|
||||
|
||||
@@ -81,8 +81,8 @@ async def test_alexa_unique_id_migration(
|
||||
)
|
||||
|
||||
entity = entity_registry.async_get_or_create(
|
||||
SWITCH_DOMAIN,
|
||||
DOMAIN,
|
||||
SWITCH_DOMAIN,
|
||||
unique_id=f"{TEST_DEVICE_1_SN}-do_not_disturb",
|
||||
device_id=device.id,
|
||||
config_entry=mock_config_entry,
|
||||
|
||||
@@ -814,7 +814,9 @@ async def test_put_light_state(
|
||||
|
||||
# mock light.turn_on call
|
||||
attributes = hass.states.get("light.ceiling_lights").attributes
|
||||
supported_features = attributes[ATTR_SUPPORTED_FEATURES] | light.SUPPORT_TRANSITION
|
||||
supported_features = (
|
||||
attributes[ATTR_SUPPORTED_FEATURES] | light.LightEntityFeature.TRANSITION
|
||||
)
|
||||
attributes = {**attributes, ATTR_SUPPORTED_FEATURES: supported_features}
|
||||
hass.states.async_set("light.ceiling_lights", STATE_ON, attributes)
|
||||
call_turn_on = async_mock_service(hass, "light", "turn_on")
|
||||
|
||||
@@ -168,7 +168,7 @@ async def setup_evohome(
|
||||
"evohomeasync2.auth.CredentialsManagerBase._post_request",
|
||||
mock_post_request(install),
|
||||
),
|
||||
patch("evohome.auth.AbstractAuth._make_request", mock_make_request(install)),
|
||||
patch("_evohome.auth.AbstractAuth._make_request", mock_make_request(install)),
|
||||
):
|
||||
evo: EvohomeClient | None = None
|
||||
|
||||
|
||||
@@ -167,6 +167,836 @@
|
||||
),
|
||||
])
|
||||
# ---
|
||||
# name: test_entities_update_over_time[default][climate.bathroom_dn-state-initial]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': 20.0,
|
||||
'friendly_name': 'Bathroom Dn',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_mode': 'none',
|
||||
'preset_modes': list([
|
||||
'none',
|
||||
'temporary',
|
||||
'permanent',
|
||||
]),
|
||||
'status': dict({
|
||||
'activeFaults': tuple(
|
||||
),
|
||||
'setpoint_status': dict({
|
||||
'setpoint_mode': 'FollowSchedule',
|
||||
'target_heat_temperature': 16.0,
|
||||
}),
|
||||
'setpoints': dict({
|
||||
'next_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'next_sp_temp': 18.1,
|
||||
'this_sp_from': HAFakeDatetime(2024, 7, 9, 23, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'this_sp_temp': 15.9,
|
||||
}),
|
||||
'temperature_status': dict({
|
||||
'is_available': True,
|
||||
'temperature': 20.0,
|
||||
}),
|
||||
'zone_id': '3432579',
|
||||
}),
|
||||
'supported_features': <ClimateEntityFeature: 401>,
|
||||
'temperature': 16.0,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.bathroom_dn',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'heat',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities_update_over_time[default][climate.bathroom_dn-state-updated]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': 20.0,
|
||||
'friendly_name': 'Bathroom Dn',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_mode': 'none',
|
||||
'preset_modes': list([
|
||||
'none',
|
||||
'temporary',
|
||||
'permanent',
|
||||
]),
|
||||
'status': dict({
|
||||
'activeFaults': tuple(
|
||||
),
|
||||
'setpoint_status': dict({
|
||||
'setpoint_mode': 'FollowSchedule',
|
||||
'target_heat_temperature': 16.0,
|
||||
}),
|
||||
'setpoints': dict({
|
||||
'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'next_sp_temp': 18.6,
|
||||
'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'this_sp_temp': 16.0,
|
||||
}),
|
||||
'temperature_status': dict({
|
||||
'is_available': True,
|
||||
'temperature': 20.0,
|
||||
}),
|
||||
'zone_id': '3432579',
|
||||
}),
|
||||
'supported_features': <ClimateEntityFeature: 401>,
|
||||
'temperature': 16.0,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.bathroom_dn',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'heat',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities_update_over_time[default][climate.dead_zone-state-initial]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': None,
|
||||
'friendly_name': 'Dead Zone',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_mode': 'none',
|
||||
'preset_modes': list([
|
||||
'none',
|
||||
'temporary',
|
||||
'permanent',
|
||||
]),
|
||||
'status': dict({
|
||||
'activeFaults': tuple(
|
||||
),
|
||||
'setpoint_status': dict({
|
||||
'setpoint_mode': 'FollowSchedule',
|
||||
'target_heat_temperature': 17.0,
|
||||
}),
|
||||
'setpoints': dict({
|
||||
'next_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'next_sp_temp': 18.1,
|
||||
'this_sp_from': HAFakeDatetime(2024, 7, 9, 23, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'this_sp_temp': 15.9,
|
||||
}),
|
||||
'temperature_status': dict({
|
||||
'is_available': False,
|
||||
}),
|
||||
'zone_id': '3432521',
|
||||
}),
|
||||
'supported_features': <ClimateEntityFeature: 401>,
|
||||
'temperature': 17.0,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.dead_zone',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'heat',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities_update_over_time[default][climate.dead_zone-state-updated]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': None,
|
||||
'friendly_name': 'Dead Zone',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_mode': 'none',
|
||||
'preset_modes': list([
|
||||
'none',
|
||||
'temporary',
|
||||
'permanent',
|
||||
]),
|
||||
'status': dict({
|
||||
'activeFaults': tuple(
|
||||
),
|
||||
'setpoint_status': dict({
|
||||
'setpoint_mode': 'FollowSchedule',
|
||||
'target_heat_temperature': 17.0,
|
||||
}),
|
||||
'setpoints': dict({
|
||||
'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'next_sp_temp': 18.6,
|
||||
'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'this_sp_temp': 16.0,
|
||||
}),
|
||||
'temperature_status': dict({
|
||||
'is_available': False,
|
||||
}),
|
||||
'zone_id': '3432521',
|
||||
}),
|
||||
'supported_features': <ClimateEntityFeature: 401>,
|
||||
'temperature': 17.0,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.dead_zone',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'heat',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities_update_over_time[default][climate.front_room-state-initial]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': 19.0,
|
||||
'friendly_name': 'Front Room',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_mode': 'temporary',
|
||||
'preset_modes': list([
|
||||
'none',
|
||||
'temporary',
|
||||
'permanent',
|
||||
]),
|
||||
'status': dict({
|
||||
'activeFaults': tuple(
|
||||
),
|
||||
'setpoint_status': dict({
|
||||
'setpoint_mode': 'TemporaryOverride',
|
||||
'target_heat_temperature': 21.0,
|
||||
'until': '2022-03-07T19:00:00+00:00',
|
||||
}),
|
||||
'setpoints': dict({
|
||||
'next_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'next_sp_temp': 18.1,
|
||||
'this_sp_from': HAFakeDatetime(2024, 7, 9, 23, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'this_sp_temp': 15.9,
|
||||
}),
|
||||
'temperature_status': dict({
|
||||
'is_available': True,
|
||||
'temperature': 19.0,
|
||||
}),
|
||||
'zone_id': '3432577',
|
||||
}),
|
||||
'supported_features': <ClimateEntityFeature: 401>,
|
||||
'temperature': 21.0,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.front_room',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'heat',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities_update_over_time[default][climate.front_room-state-updated]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': 19.0,
|
||||
'friendly_name': 'Front Room',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_mode': 'temporary',
|
||||
'preset_modes': list([
|
||||
'none',
|
||||
'temporary',
|
||||
'permanent',
|
||||
]),
|
||||
'status': dict({
|
||||
'activeFaults': tuple(
|
||||
),
|
||||
'setpoint_status': dict({
|
||||
'setpoint_mode': 'TemporaryOverride',
|
||||
'target_heat_temperature': 21.0,
|
||||
'until': '2022-03-07T19:00:00+00:00',
|
||||
}),
|
||||
'setpoints': dict({
|
||||
'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'next_sp_temp': 18.6,
|
||||
'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'this_sp_temp': 16.0,
|
||||
}),
|
||||
'temperature_status': dict({
|
||||
'is_available': True,
|
||||
'temperature': 19.0,
|
||||
}),
|
||||
'zone_id': '3432577',
|
||||
}),
|
||||
'supported_features': <ClimateEntityFeature: 401>,
|
||||
'temperature': 21.0,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.front_room',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'heat',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities_update_over_time[default][climate.kids_room-state-initial]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': 19.5,
|
||||
'friendly_name': 'Kids Room',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_mode': 'none',
|
||||
'preset_modes': list([
|
||||
'none',
|
||||
'temporary',
|
||||
'permanent',
|
||||
]),
|
||||
'status': dict({
|
||||
'activeFaults': tuple(
|
||||
),
|
||||
'setpoint_status': dict({
|
||||
'setpoint_mode': 'FollowSchedule',
|
||||
'target_heat_temperature': 17.0,
|
||||
}),
|
||||
'setpoints': dict({
|
||||
'next_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'next_sp_temp': 18.1,
|
||||
'this_sp_from': HAFakeDatetime(2024, 7, 9, 23, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'this_sp_temp': 15.9,
|
||||
}),
|
||||
'temperature_status': dict({
|
||||
'is_available': True,
|
||||
'temperature': 19.5,
|
||||
}),
|
||||
'zone_id': '3449703',
|
||||
}),
|
||||
'supported_features': <ClimateEntityFeature: 401>,
|
||||
'temperature': 17.0,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.kids_room',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'heat',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities_update_over_time[default][climate.kids_room-state-updated]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': 19.5,
|
||||
'friendly_name': 'Kids Room',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_mode': 'none',
|
||||
'preset_modes': list([
|
||||
'none',
|
||||
'temporary',
|
||||
'permanent',
|
||||
]),
|
||||
'status': dict({
|
||||
'activeFaults': tuple(
|
||||
),
|
||||
'setpoint_status': dict({
|
||||
'setpoint_mode': 'FollowSchedule',
|
||||
'target_heat_temperature': 17.0,
|
||||
}),
|
||||
'setpoints': dict({
|
||||
'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'next_sp_temp': 18.6,
|
||||
'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'this_sp_temp': 16.0,
|
||||
}),
|
||||
'temperature_status': dict({
|
||||
'is_available': True,
|
||||
'temperature': 19.5,
|
||||
}),
|
||||
'zone_id': '3449703',
|
||||
}),
|
||||
'supported_features': <ClimateEntityFeature: 401>,
|
||||
'temperature': 17.0,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.kids_room',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'heat',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities_update_over_time[default][climate.kitchen-state-initial]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': 20.0,
|
||||
'friendly_name': 'Kitchen',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_mode': 'none',
|
||||
'preset_modes': list([
|
||||
'none',
|
||||
'temporary',
|
||||
'permanent',
|
||||
]),
|
||||
'status': dict({
|
||||
'activeFaults': tuple(
|
||||
),
|
||||
'setpoint_status': dict({
|
||||
'setpoint_mode': 'FollowSchedule',
|
||||
'target_heat_temperature': 17.0,
|
||||
}),
|
||||
'setpoints': dict({
|
||||
'next_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'next_sp_temp': 18.1,
|
||||
'this_sp_from': HAFakeDatetime(2024, 7, 9, 23, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'this_sp_temp': 15.9,
|
||||
}),
|
||||
'temperature_status': dict({
|
||||
'is_available': True,
|
||||
'temperature': 20.0,
|
||||
}),
|
||||
'zone_id': '3432578',
|
||||
}),
|
||||
'supported_features': <ClimateEntityFeature: 401>,
|
||||
'temperature': 17.0,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.kitchen',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'heat',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities_update_over_time[default][climate.kitchen-state-updated]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': 20.0,
|
||||
'friendly_name': 'Kitchen',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_mode': 'none',
|
||||
'preset_modes': list([
|
||||
'none',
|
||||
'temporary',
|
||||
'permanent',
|
||||
]),
|
||||
'status': dict({
|
||||
'activeFaults': tuple(
|
||||
),
|
||||
'setpoint_status': dict({
|
||||
'setpoint_mode': 'FollowSchedule',
|
||||
'target_heat_temperature': 17.0,
|
||||
}),
|
||||
'setpoints': dict({
|
||||
'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'next_sp_temp': 18.6,
|
||||
'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'this_sp_temp': 16.0,
|
||||
}),
|
||||
'temperature_status': dict({
|
||||
'is_available': True,
|
||||
'temperature': 20.0,
|
||||
}),
|
||||
'zone_id': '3432578',
|
||||
}),
|
||||
'supported_features': <ClimateEntityFeature: 401>,
|
||||
'temperature': 17.0,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.kitchen',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'heat',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities_update_over_time[default][climate.main_bedroom-state-initial]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': 21.0,
|
||||
'friendly_name': 'Main Bedroom',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_mode': 'none',
|
||||
'preset_modes': list([
|
||||
'none',
|
||||
'temporary',
|
||||
'permanent',
|
||||
]),
|
||||
'status': dict({
|
||||
'activeFaults': tuple(
|
||||
),
|
||||
'setpoint_status': dict({
|
||||
'setpoint_mode': 'FollowSchedule',
|
||||
'target_heat_temperature': 16.0,
|
||||
}),
|
||||
'setpoints': dict({
|
||||
'next_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'next_sp_temp': 18.1,
|
||||
'this_sp_from': HAFakeDatetime(2024, 7, 9, 23, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'this_sp_temp': 15.9,
|
||||
}),
|
||||
'temperature_status': dict({
|
||||
'is_available': True,
|
||||
'temperature': 21.0,
|
||||
}),
|
||||
'zone_id': '3432580',
|
||||
}),
|
||||
'supported_features': <ClimateEntityFeature: 401>,
|
||||
'temperature': 16.0,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.main_bedroom',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'heat',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities_update_over_time[default][climate.main_bedroom-state-updated]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': 21.0,
|
||||
'friendly_name': 'Main Bedroom',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_mode': 'none',
|
||||
'preset_modes': list([
|
||||
'none',
|
||||
'temporary',
|
||||
'permanent',
|
||||
]),
|
||||
'status': dict({
|
||||
'activeFaults': tuple(
|
||||
),
|
||||
'setpoint_status': dict({
|
||||
'setpoint_mode': 'FollowSchedule',
|
||||
'target_heat_temperature': 16.0,
|
||||
}),
|
||||
'setpoints': dict({
|
||||
'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'next_sp_temp': 18.6,
|
||||
'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'this_sp_temp': 16.0,
|
||||
}),
|
||||
'temperature_status': dict({
|
||||
'is_available': True,
|
||||
'temperature': 21.0,
|
||||
}),
|
||||
'zone_id': '3432580',
|
||||
}),
|
||||
'supported_features': <ClimateEntityFeature: 401>,
|
||||
'temperature': 16.0,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.main_bedroom',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'heat',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities_update_over_time[default][climate.main_room-state-initial]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': 19.0,
|
||||
'friendly_name': 'Main Room',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_mode': 'permanent',
|
||||
'preset_modes': list([
|
||||
'none',
|
||||
'temporary',
|
||||
'permanent',
|
||||
]),
|
||||
'status': dict({
|
||||
'activeFaults': tuple(
|
||||
),
|
||||
'setpoint_status': dict({
|
||||
'setpoint_mode': 'PermanentOverride',
|
||||
'target_heat_temperature': 17.0,
|
||||
}),
|
||||
'setpoints': dict({
|
||||
'next_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'next_sp_temp': 18.1,
|
||||
'this_sp_from': HAFakeDatetime(2024, 7, 9, 23, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'this_sp_temp': 15.9,
|
||||
}),
|
||||
'temperature_status': dict({
|
||||
'is_available': True,
|
||||
'temperature': 19.0,
|
||||
}),
|
||||
'zone_id': '3432576',
|
||||
}),
|
||||
'supported_features': <ClimateEntityFeature: 401>,
|
||||
'temperature': 17.0,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.main_room',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'heat',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities_update_over_time[default][climate.main_room-state-updated]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': 19.0,
|
||||
'friendly_name': 'Main Room',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_mode': 'permanent',
|
||||
'preset_modes': list([
|
||||
'none',
|
||||
'temporary',
|
||||
'permanent',
|
||||
]),
|
||||
'status': dict({
|
||||
'activeFaults': tuple(
|
||||
),
|
||||
'setpoint_status': dict({
|
||||
'setpoint_mode': 'PermanentOverride',
|
||||
'target_heat_temperature': 17.0,
|
||||
}),
|
||||
'setpoints': dict({
|
||||
'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'next_sp_temp': 18.6,
|
||||
'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'this_sp_temp': 16.0,
|
||||
}),
|
||||
'temperature_status': dict({
|
||||
'is_available': True,
|
||||
'temperature': 19.0,
|
||||
}),
|
||||
'zone_id': '3432576',
|
||||
}),
|
||||
'supported_features': <ClimateEntityFeature: 401>,
|
||||
'temperature': 17.0,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.main_room',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'heat',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities_update_over_time[default][climate.my_home-state-initial]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': 19.7,
|
||||
'friendly_name': 'My Home',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'icon': 'mdi:thermostat',
|
||||
'max_temp': 35,
|
||||
'min_temp': 7,
|
||||
'preset_mode': 'eco',
|
||||
'preset_modes': list([
|
||||
'Reset',
|
||||
'eco',
|
||||
'away',
|
||||
'home',
|
||||
'Custom',
|
||||
]),
|
||||
'status': dict({
|
||||
'activeSystemFaults': tuple(
|
||||
),
|
||||
'system_id': '3432522',
|
||||
'system_mode_status': dict({
|
||||
'is_permanent': True,
|
||||
'mode': 'AutoWithEco',
|
||||
}),
|
||||
}),
|
||||
'supported_features': <ClimateEntityFeature: 400>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.my_home',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'heat',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities_update_over_time[default][climate.my_home-state-updated]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': 19.7,
|
||||
'friendly_name': 'My Home',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'icon': 'mdi:thermostat',
|
||||
'max_temp': 35,
|
||||
'min_temp': 7,
|
||||
'preset_mode': 'eco',
|
||||
'preset_modes': list([
|
||||
'Reset',
|
||||
'eco',
|
||||
'away',
|
||||
'home',
|
||||
'Custom',
|
||||
]),
|
||||
'status': dict({
|
||||
'activeSystemFaults': tuple(
|
||||
),
|
||||
'system_id': '3432522',
|
||||
'system_mode_status': dict({
|
||||
'is_permanent': True,
|
||||
'mode': 'AutoWithEco',
|
||||
}),
|
||||
}),
|
||||
'supported_features': <ClimateEntityFeature: 400>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.my_home',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'heat',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities_update_over_time[default][climate.spare_room-state-initial]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': 19.5,
|
||||
'friendly_name': 'Spare Room',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_mode': 'permanent',
|
||||
'preset_modes': list([
|
||||
'none',
|
||||
'temporary',
|
||||
'permanent',
|
||||
]),
|
||||
'status': dict({
|
||||
'activeFaults': tuple(
|
||||
),
|
||||
'setpoint_status': dict({
|
||||
'setpoint_mode': 'PermanentOverride',
|
||||
'target_heat_temperature': 14.0,
|
||||
}),
|
||||
'setpoints': dict({
|
||||
'next_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'next_sp_temp': 18.1,
|
||||
'this_sp_from': HAFakeDatetime(2024, 7, 9, 23, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'this_sp_temp': 15.9,
|
||||
}),
|
||||
'temperature_status': dict({
|
||||
'is_available': True,
|
||||
'temperature': 19.5,
|
||||
}),
|
||||
'zone_id': '3450733',
|
||||
}),
|
||||
'supported_features': <ClimateEntityFeature: 401>,
|
||||
'temperature': 14.0,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.spare_room',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'heat',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities_update_over_time[default][climate.spare_room-state-updated]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': 19.5,
|
||||
'friendly_name': 'Spare Room',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_mode': 'permanent',
|
||||
'preset_modes': list([
|
||||
'none',
|
||||
'temporary',
|
||||
'permanent',
|
||||
]),
|
||||
'status': dict({
|
||||
'activeFaults': tuple(
|
||||
),
|
||||
'setpoint_status': dict({
|
||||
'setpoint_mode': 'PermanentOverride',
|
||||
'target_heat_temperature': 14.0,
|
||||
}),
|
||||
'setpoints': dict({
|
||||
'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'next_sp_temp': 18.6,
|
||||
'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'this_sp_temp': 16.0,
|
||||
}),
|
||||
'temperature_status': dict({
|
||||
'is_available': True,
|
||||
'temperature': 19.5,
|
||||
}),
|
||||
'zone_id': '3450733',
|
||||
}),
|
||||
'supported_features': <ClimateEntityFeature: 401>,
|
||||
'temperature': 14.0,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.spare_room',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'heat',
|
||||
})
|
||||
# ---
|
||||
# name: test_setup_platform[botched][climate.bathroom_dn-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
|
||||
@@ -5,6 +5,7 @@ All evohome systems have controllers and at least one zone.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
@@ -32,6 +33,8 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from .conftest import setup_evohome
|
||||
from .const import TEST_INSTALLS
|
||||
|
||||
from tests.common import async_fire_time_changed
|
||||
|
||||
|
||||
@pytest.mark.parametrize("install", [*TEST_INSTALLS, "botched"])
|
||||
async def test_setup_platform(
|
||||
@@ -43,7 +46,7 @@ async def test_setup_platform(
|
||||
) -> None:
|
||||
"""Test entities and their states after setup of evohome."""
|
||||
|
||||
# Cannot use the evohome fixture, as need to set dtm first
|
||||
# Cannot use the evohome fixture here, as need to set dtm first
|
||||
# - some extended state attrs are relative the current time
|
||||
freezer.move_to("2024-07-10T12:00:00Z")
|
||||
|
||||
@@ -54,6 +57,36 @@ async def test_setup_platform(
|
||||
assert x == snapshot(name=f"{x.entity_id}-state")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("install", ["default"])
|
||||
async def test_entities_update_over_time(
|
||||
hass: HomeAssistant,
|
||||
config: dict[str, str],
|
||||
install: str,
|
||||
snapshot: SnapshotAssertion,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test extended attributes update as time passes.
|
||||
|
||||
Verifies that time-dependent state attrs (e.g. schedules) vary as time advances.
|
||||
"""
|
||||
|
||||
# Cannot use the evohome fixture here, as need to set dtm first
|
||||
# - some extended state attrs are relative the current time
|
||||
freezer.move_to("2024-07-10T05:30:00Z")
|
||||
|
||||
# stay inside this context to have the mocked RESTful API
|
||||
async for _ in setup_evohome(hass, config, install=install):
|
||||
for x in hass.states.async_all(Platform.CLIMATE):
|
||||
assert x == snapshot(name=f"{x.entity_id}-state-initial")
|
||||
|
||||
freezer.tick(timedelta(hours=12))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
for x in hass.states.async_all(Platform.CLIMATE):
|
||||
assert x == snapshot(name=f"{x.entity_id}-state-updated")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("install", TEST_INSTALLS)
|
||||
async def test_ctl_set_hvac_mode(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -31,13 +31,9 @@ _MSG_USR = (
|
||||
"special characters accepted via the vendor's website are not valid here."
|
||||
)
|
||||
|
||||
LOG_HINT_429_CREDS = ("evohome.credentials", logging.ERROR, _MSG_429)
|
||||
LOG_HINT_OTH_CREDS = ("evohome.credentials", logging.ERROR, _MSG_OTH)
|
||||
LOG_HINT_USR_CREDS = ("evohome.credentials", logging.ERROR, _MSG_USR)
|
||||
|
||||
LOG_HINT_429_AUTH = ("evohome.auth", logging.ERROR, _MSG_429)
|
||||
LOG_HINT_OTH_AUTH = ("evohome.auth", logging.ERROR, _MSG_OTH)
|
||||
LOG_HINT_USR_AUTH = ("evohome.auth", logging.ERROR, _MSG_USR)
|
||||
LOG_HINT_429_AUTH = ("evohomeasync2.auth", logging.ERROR, _MSG_429)
|
||||
LOG_HINT_OTH_AUTH = ("evohomeasync2.auth", logging.ERROR, _MSG_OTH)
|
||||
LOG_HINT_USR_AUTH = ("evohomeasync2.auth", logging.ERROR, _MSG_USR)
|
||||
|
||||
LOG_FAIL_CONNECTION = (
|
||||
"homeassistant.components.evohome",
|
||||
@@ -110,10 +106,10 @@ EXC_BAD_GATEWAY = aiohttp.ClientResponseError(
|
||||
)
|
||||
|
||||
AUTHENTICATION_TESTS: dict[Exception, list] = {
|
||||
EXC_BAD_CONNECTION: [LOG_HINT_OTH_CREDS, LOG_FAIL_CONNECTION, LOG_SETUP_FAILED],
|
||||
EXC_BAD_CREDENTIALS: [LOG_HINT_USR_CREDS, LOG_FAIL_CREDENTIALS, LOG_SETUP_FAILED],
|
||||
EXC_BAD_GATEWAY: [LOG_HINT_OTH_CREDS, LOG_FAIL_GATEWAY, LOG_SETUP_FAILED],
|
||||
EXC_TOO_MANY_REQUESTS: [LOG_HINT_429_CREDS, LOG_FAIL_TOO_MANY, LOG_SETUP_FAILED],
|
||||
EXC_BAD_CONNECTION: [LOG_HINT_OTH_AUTH, LOG_FAIL_CONNECTION, LOG_SETUP_FAILED],
|
||||
EXC_BAD_CREDENTIALS: [LOG_HINT_USR_AUTH, LOG_FAIL_CREDENTIALS, LOG_SETUP_FAILED],
|
||||
EXC_BAD_GATEWAY: [LOG_HINT_OTH_AUTH, LOG_FAIL_GATEWAY, LOG_SETUP_FAILED],
|
||||
EXC_TOO_MANY_REQUESTS: [LOG_HINT_429_AUTH, LOG_FAIL_TOO_MANY, LOG_SETUP_FAILED],
|
||||
}
|
||||
|
||||
CLIENT_REQUEST_TESTS: dict[Exception, list] = {
|
||||
@@ -137,7 +133,8 @@ async def test_authentication_failure_v2(
|
||||
|
||||
with (
|
||||
patch(
|
||||
"evohome.credentials.CredentialsManagerBase._request", side_effect=exception
|
||||
"_evohome.credentials.CredentialsManagerBase._request",
|
||||
side_effect=exception,
|
||||
),
|
||||
caplog.at_level(logging.WARNING),
|
||||
):
|
||||
@@ -165,7 +162,7 @@ async def test_client_request_failure_v2(
|
||||
"evohomeasync2.auth.CredentialsManagerBase._post_request",
|
||||
mock_post_request("default"),
|
||||
),
|
||||
patch("evohome.auth.AbstractAuth._request", side_effect=exception),
|
||||
patch("_evohome.auth.AbstractAuth._request", side_effect=exception),
|
||||
caplog.at_level(logging.WARNING),
|
||||
):
|
||||
result = await async_setup_component(hass, DOMAIN, {DOMAIN: config})
|
||||
|
||||
@@ -35,6 +35,38 @@ MOCK_SERIAL_NUMBER = "ABC123456"
|
||||
MOCK_DEVICE_TOKEN = "mock_device_token"
|
||||
|
||||
|
||||
def create_mock_tracker() -> Tracker:
|
||||
"""Create a fresh mock Tracker instance."""
|
||||
return Tracker(
|
||||
name="Fluffy",
|
||||
battery=85,
|
||||
charging=False,
|
||||
position=Position(
|
||||
lat=52.520008,
|
||||
lng=13.404954,
|
||||
accuracy=10,
|
||||
timestamp="2024-01-15T12:00:00Z",
|
||||
),
|
||||
tracker_settings=TrackerSettings(
|
||||
generation="GPS Tracker 2.0",
|
||||
features=TrackerFeatures(
|
||||
flash_light=True, energy_saving_mode=True, live_tracking=True
|
||||
),
|
||||
),
|
||||
led_brightness=LedBrightness(status="ok", value=50),
|
||||
energy_saving=EnergySaving(status="ok", value=1),
|
||||
deep_sleep=None,
|
||||
led_activatable=LedActivatable(
|
||||
has_led=True,
|
||||
seen_recently=True,
|
||||
nonempty_battery=True,
|
||||
not_charging=True,
|
||||
overall=True,
|
||||
),
|
||||
icon="http://res.cloudinary.com/iot-venture/image/upload/v1717594357/kyaqq7nfitrdvaoakb8s.jpg",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_setup_entry() -> Generator[AsyncMock]:
|
||||
"""Override async_setup_entry."""
|
||||
@@ -102,42 +134,26 @@ def mock_auth_client(mock_device: Device) -> Generator[MagicMock]:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_api_client() -> Generator[MagicMock]:
|
||||
"""Mock the ApiClient."""
|
||||
def mock_api_client_init() -> Generator[MagicMock]:
|
||||
"""Mock the ApiClient used by _tracker_is_valid in __init__.py."""
|
||||
with patch(
|
||||
"homeassistant.components.fressnapf_tracker.coordinator.ApiClient"
|
||||
) as mock_api_client:
|
||||
client = mock_api_client.return_value
|
||||
client.get_tracker = AsyncMock(
|
||||
return_value=Tracker(
|
||||
name="Fluffy",
|
||||
battery=85,
|
||||
charging=False,
|
||||
position=Position(
|
||||
lat=52.520008,
|
||||
lng=13.404954,
|
||||
accuracy=10,
|
||||
timestamp="2024-01-15T12:00:00Z",
|
||||
),
|
||||
tracker_settings=TrackerSettings(
|
||||
generation="GPS Tracker 2.0",
|
||||
features=TrackerFeatures(
|
||||
flash_light=True, energy_saving_mode=True, live_tracking=True
|
||||
),
|
||||
),
|
||||
led_brightness=LedBrightness(status="ok", value=50),
|
||||
energy_saving=EnergySaving(status="ok", value=1),
|
||||
deep_sleep=None,
|
||||
led_activatable=LedActivatable(
|
||||
has_led=True,
|
||||
seen_recently=True,
|
||||
nonempty_battery=True,
|
||||
not_charging=True,
|
||||
overall=True,
|
||||
),
|
||||
icon="http://res.cloudinary.com/iot-venture/image/upload/v1717594357/kyaqq7nfitrdvaoakb8s.jpg",
|
||||
)
|
||||
)
|
||||
"homeassistant.components.fressnapf_tracker.ApiClient",
|
||||
autospec=True,
|
||||
) as mock_client:
|
||||
client = mock_client.return_value
|
||||
client.get_tracker = AsyncMock(return_value=create_mock_tracker())
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_api_client_coordinator() -> Generator[MagicMock]:
|
||||
"""Mock the ApiClient used by the coordinator."""
|
||||
with patch(
|
||||
"homeassistant.components.fressnapf_tracker.coordinator.ApiClient",
|
||||
autospec=True,
|
||||
) as mock_client:
|
||||
client = mock_client.return_value
|
||||
client.get_tracker = AsyncMock(return_value=create_mock_tracker())
|
||||
client.set_led_brightness = AsyncMock(return_value=None)
|
||||
client.set_energy_saving = AsyncMock(return_value=None)
|
||||
yield client
|
||||
@@ -162,7 +178,8 @@ def mock_config_entry() -> MockConfigEntry:
|
||||
async def init_integration(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api_client: MagicMock,
|
||||
mock_api_client_init: MagicMock,
|
||||
mock_api_client_coordinator: MagicMock,
|
||||
mock_auth_client: MagicMock,
|
||||
) -> MockConfigEntry:
|
||||
"""Set up the integration for testing."""
|
||||
|
||||
@@ -216,7 +216,9 @@ async def test_user_flow_duplicate_phone_number(
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("mock_api_client", "mock_auth_client")
|
||||
@pytest.mark.usefixtures(
|
||||
"mock_api_client_init", "mock_api_client_coordinator", "mock_auth_client"
|
||||
)
|
||||
async def test_reauth_reconfigure_flow(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
@@ -270,7 +272,7 @@ async def test_reauth_reconfigure_flow(
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("mock_api_client")
|
||||
@pytest.mark.usefixtures("mock_api_client_init", "mock_api_client_coordinator")
|
||||
async def test_reauth_reconfigure_flow_invalid_phone_number(
|
||||
hass: HomeAssistant,
|
||||
mock_auth_client: MagicMock,
|
||||
@@ -333,7 +335,7 @@ async def test_reauth_reconfigure_flow_invalid_phone_number(
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("mock_api_client")
|
||||
@pytest.mark.usefixtures("mock_api_client_init", "mock_api_client_coordinator")
|
||||
async def test_reauth_reconfigure_flow_invalid_sms_code(
|
||||
hass: HomeAssistant,
|
||||
mock_auth_client: MagicMock,
|
||||
@@ -393,7 +395,7 @@ async def test_reauth_reconfigure_flow_invalid_sms_code(
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("mock_api_client")
|
||||
@pytest.mark.usefixtures("mock_api_client_init", "mock_api_client_coordinator")
|
||||
async def test_reauth_reconfigure_flow_invalid_user_id(
|
||||
hass: HomeAssistant,
|
||||
mock_auth_client: MagicMock,
|
||||
|
||||
@@ -40,12 +40,12 @@ async def test_device_tracker_no_position(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_tracker_no_position: Tracker,
|
||||
mock_api_client: MagicMock,
|
||||
mock_api_client_init: MagicMock,
|
||||
) -> None:
|
||||
"""Test device tracker is unavailable when position is None."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
mock_api_client.get_tracker = AsyncMock(return_value=mock_tracker_no_position)
|
||||
mock_api_client_init.get_tracker = AsyncMock(return_value=mock_tracker_no_position)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
@@ -1,19 +1,40 @@
|
||||
"""Test the Fressnapf Tracker integration init."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from fressnapftracker import (
|
||||
FressnapfTrackerError,
|
||||
FressnapfTrackerInvalidTrackerResponseError,
|
||||
)
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.fressnapf_tracker.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import device_registry as dr, issue_registry as ir
|
||||
|
||||
from .conftest import MOCK_SERIAL_NUMBER
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_auth_client")
|
||||
@pytest.mark.usefixtures("mock_api_client")
|
||||
@pytest.fixture
|
||||
def mock_api_client_malformed_tracker() -> Generator[MagicMock]:
|
||||
"""Mock the ApiClient for a malformed tracker response in _tracker_is_valid."""
|
||||
with patch(
|
||||
"homeassistant.components.fressnapf_tracker.ApiClient",
|
||||
autospec=True,
|
||||
) as mock_api_client:
|
||||
client = mock_api_client.return_value
|
||||
client.get_tracker = AsyncMock(
|
||||
side_effect=FressnapfTrackerInvalidTrackerResponseError("Invalid tracker")
|
||||
)
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_auth_client", "mock_api_client_init")
|
||||
async def test_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
@@ -27,8 +48,7 @@ async def test_setup_entry(
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_auth_client")
|
||||
@pytest.mark.usefixtures("mock_api_client")
|
||||
@pytest.mark.usefixtures("mock_auth_client", "mock_api_client_init")
|
||||
async def test_unload_entry(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
@@ -48,15 +68,18 @@ async def test_unload_entry(
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_auth_client")
|
||||
async def test_setup_entry_api_error(
|
||||
async def test_setup_entry_tracker_is_valid_api_error(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api_client: MagicMock,
|
||||
mock_api_client_init: MagicMock,
|
||||
) -> None:
|
||||
"""Test setup fails when API returns error."""
|
||||
"""Test setup retries when API returns error during _tracker_is_valid."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
mock_api_client.get_tracker = AsyncMock(side_effect=Exception("API Error"))
|
||||
mock_api_client_init.get_tracker = AsyncMock(
|
||||
side_effect=FressnapfTrackerError("API Error")
|
||||
)
|
||||
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@@ -78,3 +101,48 @@ async def test_state_entity_device_snapshots(
|
||||
assert device_entry == snapshot(name=f"{device_entry.name}-entry"), (
|
||||
f"device entry snapshot failed for {device_entry.name}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_auth_client", "mock_api_client_malformed_tracker")
|
||||
async def test_invalid_tracker(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
) -> None:
|
||||
"""Test that an issue is created when an invalid tracker is detected."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
assert len(issue_registry.issues) == 1
|
||||
|
||||
issue_id = f"invalid_fressnapf_tracker_{MOCK_SERIAL_NUMBER}"
|
||||
assert issue_registry.async_get_issue(DOMAIN, issue_id)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_auth_client", "mock_api_client_malformed_tracker")
|
||||
async def test_invalid_tracker_already_exists(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
) -> None:
|
||||
"""Test that an existing issue is not duplicated."""
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"invalid_fressnapf_tracker_{MOCK_SERIAL_NUMBER}",
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="invalid_fressnapf_tracker",
|
||||
translation_placeholders={"tracker_id": MOCK_SERIAL_NUMBER},
|
||||
)
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
assert len(issue_registry.issues) == 1
|
||||
|
||||
@@ -63,10 +63,10 @@ async def test_not_added_when_no_led(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api_client: MagicMock,
|
||||
mock_api_client_init: MagicMock,
|
||||
) -> None:
|
||||
"""Test light entity is created correctly."""
|
||||
mock_api_client.get_tracker.return_value = TRACKER_NO_LED
|
||||
mock_api_client_init.get_tracker.return_value = TRACKER_NO_LED
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
@@ -81,7 +81,7 @@ async def test_not_added_when_no_led(
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_turn_on(
|
||||
hass: HomeAssistant,
|
||||
mock_api_client: MagicMock,
|
||||
mock_api_client_coordinator: MagicMock,
|
||||
) -> None:
|
||||
"""Test turning the light on."""
|
||||
entity_id = "light.fluffy_flashlight"
|
||||
@@ -97,13 +97,13 @@ async def test_turn_on(
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_api_client.set_led_brightness.assert_called_once_with(100)
|
||||
mock_api_client_coordinator.set_led_brightness.assert_called_once_with(100)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_turn_on_with_brightness(
|
||||
hass: HomeAssistant,
|
||||
mock_api_client: MagicMock,
|
||||
mock_api_client_coordinator: MagicMock,
|
||||
) -> None:
|
||||
"""Test turning the light on with brightness."""
|
||||
entity_id = "light.fluffy_flashlight"
|
||||
@@ -116,13 +116,13 @@ async def test_turn_on_with_brightness(
|
||||
)
|
||||
|
||||
# 128/255 * 100 = 50
|
||||
mock_api_client.set_led_brightness.assert_called_once_with(50)
|
||||
mock_api_client_coordinator.set_led_brightness.assert_called_once_with(50)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_turn_off(
|
||||
hass: HomeAssistant,
|
||||
mock_api_client: MagicMock,
|
||||
mock_api_client_coordinator: MagicMock,
|
||||
) -> None:
|
||||
"""Test turning the light off."""
|
||||
entity_id = "light.fluffy_flashlight"
|
||||
@@ -138,7 +138,7 @@ async def test_turn_off(
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_api_client.set_led_brightness.assert_called_once_with(0)
|
||||
mock_api_client_coordinator.set_led_brightness.assert_called_once_with(0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -153,12 +153,13 @@ async def test_turn_off(
|
||||
async def test_turn_on_led_not_activatable(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api_client: MagicMock,
|
||||
mock_api_client_init: MagicMock,
|
||||
mock_api_client_coordinator: MagicMock,
|
||||
activatable_parameter: str,
|
||||
) -> None:
|
||||
"""Test turning on the light when LED is not activatable raises."""
|
||||
setattr(
|
||||
mock_api_client.get_tracker.return_value.led_activatable,
|
||||
mock_api_client_init.get_tracker.return_value.led_activatable,
|
||||
activatable_parameter,
|
||||
False,
|
||||
)
|
||||
@@ -177,7 +178,7 @@ async def test_turn_on_led_not_activatable(
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_api_client.set_led_brightness.assert_not_called()
|
||||
mock_api_client_coordinator.set_led_brightness.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -191,11 +192,11 @@ async def test_turn_on_led_not_activatable(
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("service", [SERVICE_TURN_ON, SERVICE_TURN_OFF])
|
||||
@pytest.mark.usefixtures("mock_auth_client")
|
||||
@pytest.mark.usefixtures("mock_auth_client", "mock_api_client_init")
|
||||
async def test_turn_on_off_error(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api_client: MagicMock,
|
||||
mock_api_client_coordinator: MagicMock,
|
||||
api_exception: FressnapfTrackerError,
|
||||
expected_exception: type[HomeAssistantError],
|
||||
service: str,
|
||||
@@ -208,7 +209,7 @@ async def test_turn_on_off_error(
|
||||
|
||||
entity_id = "light.fluffy_flashlight"
|
||||
|
||||
mock_api_client.set_led_brightness.side_effect = api_exception
|
||||
mock_api_client_coordinator.set_led_brightness.side_effect = api_exception
|
||||
with pytest.raises(expected_exception):
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
|
||||
@@ -62,10 +62,10 @@ async def test_not_added_when_no_energy_saving_mode(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api_client: MagicMock,
|
||||
mock_api_client_init: MagicMock,
|
||||
) -> None:
|
||||
"""Test switch entity is created correctly."""
|
||||
mock_api_client.get_tracker.return_value = TRACKER_NO_ENERGY_SAVING_MODE
|
||||
mock_api_client_init.get_tracker.return_value = TRACKER_NO_ENERGY_SAVING_MODE
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
@@ -80,7 +80,7 @@ async def test_not_added_when_no_energy_saving_mode(
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_turn_on(
|
||||
hass: HomeAssistant,
|
||||
mock_api_client: MagicMock,
|
||||
mock_api_client_coordinator: MagicMock,
|
||||
) -> None:
|
||||
"""Test turning the switch on."""
|
||||
entity_id = "switch.fluffy_sleep_mode"
|
||||
@@ -96,13 +96,13 @@ async def test_turn_on(
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_api_client.set_energy_saving.assert_called_once_with(True)
|
||||
mock_api_client_coordinator.set_energy_saving.assert_called_once_with(True)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_turn_off(
|
||||
hass: HomeAssistant,
|
||||
mock_api_client: MagicMock,
|
||||
mock_api_client_coordinator: MagicMock,
|
||||
) -> None:
|
||||
"""Test turning the switch off."""
|
||||
entity_id = "switch.fluffy_sleep_mode"
|
||||
@@ -118,7 +118,7 @@ async def test_turn_off(
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_api_client.set_energy_saving.assert_called_once_with(False)
|
||||
mock_api_client_coordinator.set_energy_saving.assert_called_once_with(False)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -132,11 +132,11 @@ async def test_turn_off(
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("service", [SERVICE_TURN_ON, SERVICE_TURN_OFF])
|
||||
@pytest.mark.usefixtures("mock_auth_client")
|
||||
@pytest.mark.usefixtures("mock_auth_client", "mock_api_client_init")
|
||||
async def test_turn_on_off_error(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api_client: MagicMock,
|
||||
mock_api_client_coordinator: MagicMock,
|
||||
api_exception: FressnapfTrackerError,
|
||||
expected_exception: type[HomeAssistantError],
|
||||
service: str,
|
||||
@@ -149,7 +149,7 @@ async def test_turn_on_off_error(
|
||||
|
||||
entity_id = "switch.fluffy_sleep_mode"
|
||||
|
||||
mock_api_client.set_energy_saving.side_effect = api_exception
|
||||
mock_api_client_coordinator.set_energy_saving.side_effect = api_exception
|
||||
with pytest.raises(expected_exception):
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
|
||||
@@ -26,6 +26,7 @@ from homeassistant.components.light import (
|
||||
DOMAIN,
|
||||
ColorMode,
|
||||
LightEntity,
|
||||
LightEntityFeature,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
@@ -156,7 +157,7 @@ class MockLight(MockToggleEntity, LightEntity):
|
||||
|
||||
_attr_max_color_temp_kelvin = DEFAULT_MAX_KELVIN
|
||||
_attr_min_color_temp_kelvin = DEFAULT_MIN_KELVIN
|
||||
supported_features = 0
|
||||
supported_features = LightEntityFeature(0)
|
||||
|
||||
brightness = None
|
||||
color_temp_kelvin = None
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""The tests for the Light component."""
|
||||
|
||||
from types import ModuleType
|
||||
from typing import Literal
|
||||
from unittest.mock import MagicMock, mock_open, patch
|
||||
|
||||
import pytest
|
||||
@@ -30,9 +28,6 @@ from tests.common import (
|
||||
MockEntityPlatform,
|
||||
MockUser,
|
||||
async_mock_service,
|
||||
help_test_all,
|
||||
import_and_test_deprecated_constant,
|
||||
import_and_test_deprecated_constant_enum,
|
||||
setup_test_component_platform,
|
||||
)
|
||||
|
||||
@@ -137,13 +132,10 @@ async def test_services(
|
||||
ent3.supported_color_modes = [light.ColorMode.HS]
|
||||
ent1.supported_features = light.LightEntityFeature.TRANSITION
|
||||
ent2.supported_features = (
|
||||
light.SUPPORT_COLOR
|
||||
| light.LightEntityFeature.EFFECT
|
||||
| light.LightEntityFeature.TRANSITION
|
||||
light.LightEntityFeature.EFFECT | light.LightEntityFeature.TRANSITION
|
||||
)
|
||||
# Set color modes to none to trigger backwards compatibility in LightEntity
|
||||
ent2.supported_color_modes = None
|
||||
ent2.color_mode = None
|
||||
ent2.supported_color_modes = [light.ColorMode.HS]
|
||||
ent2.color_mode = light.ColorMode.HS
|
||||
ent3.supported_features = (
|
||||
light.LightEntityFeature.FLASH | light.LightEntityFeature.TRANSITION
|
||||
)
|
||||
@@ -903,16 +895,12 @@ async def test_light_brightness_step(hass: HomeAssistant) -> None:
|
||||
setup_test_component_platform(hass, light.DOMAIN, entities)
|
||||
|
||||
entity0 = entities[0]
|
||||
entity0.supported_features = light.SUPPORT_BRIGHTNESS
|
||||
# Set color modes to none to trigger backwards compatibility in LightEntity
|
||||
entity0.supported_color_modes = None
|
||||
entity0.color_mode = None
|
||||
entity0.supported_color_modes = {light.ColorMode.BRIGHTNESS}
|
||||
entity0.color_mode = light.ColorMode.BRIGHTNESS
|
||||
entity0.brightness = 100
|
||||
entity1 = entities[1]
|
||||
entity1.supported_features = light.SUPPORT_BRIGHTNESS
|
||||
# Set color modes to none to trigger backwards compatibility in LightEntity
|
||||
entity1.supported_color_modes = None
|
||||
entity1.color_mode = None
|
||||
entity1.supported_color_modes = {light.ColorMode.BRIGHTNESS}
|
||||
entity1.color_mode = light.ColorMode.BRIGHTNESS
|
||||
entity1.brightness = 50
|
||||
assert await async_setup_component(hass, "light", {"light": {"platform": "test"}})
|
||||
await hass.async_block_till_done()
|
||||
@@ -955,10 +943,8 @@ async def test_light_brightness_step_pct(hass: HomeAssistant) -> None:
|
||||
|
||||
setup_test_component_platform(hass, light.DOMAIN, [entity])
|
||||
|
||||
entity.supported_features = light.SUPPORT_BRIGHTNESS
|
||||
# Set color modes to none to trigger backwards compatibility in LightEntity
|
||||
entity.supported_color_modes = None
|
||||
entity.color_mode = None
|
||||
entity.supported_color_modes = {light.ColorMode.BRIGHTNESS}
|
||||
entity.color_mode = light.ColorMode.BRIGHTNESS
|
||||
entity.brightness = 255
|
||||
assert await async_setup_component(hass, "light", {"light": {"platform": "test"}})
|
||||
await hass.async_block_till_done()
|
||||
@@ -1000,10 +986,8 @@ async def test_light_brightness_pct_conversion(
|
||||
setup_test_component_platform(hass, light.DOMAIN, mock_light_entities)
|
||||
|
||||
entity = mock_light_entities[0]
|
||||
entity.supported_features = light.SUPPORT_BRIGHTNESS
|
||||
# Set color modes to none to trigger backwards compatibility in LightEntity
|
||||
entity.supported_color_modes = None
|
||||
entity.color_mode = None
|
||||
entity.supported_color_modes = {light.ColorMode.BRIGHTNESS}
|
||||
entity.color_mode = light.ColorMode.BRIGHTNESS
|
||||
entity.brightness = 100
|
||||
assert await async_setup_component(hass, "light", {"light": {"platform": "test"}})
|
||||
await hass.async_block_till_done()
|
||||
@@ -1152,167 +1136,6 @@ invalid_no_brightness_no_color_no_transition,,,
|
||||
assert invalid_profile_name not in profiles.data
|
||||
|
||||
|
||||
@pytest.mark.parametrize("light_state", [STATE_ON, STATE_OFF])
|
||||
async def test_light_backwards_compatibility_supported_color_modes(
|
||||
hass: HomeAssistant, light_state: Literal["on", "off"]
|
||||
) -> None:
|
||||
"""Test supported_color_modes if not implemented by the entity."""
|
||||
entities = [
|
||||
MockLight("Test_0", light_state),
|
||||
MockLight("Test_1", light_state),
|
||||
MockLight("Test_2", light_state),
|
||||
MockLight("Test_3", light_state),
|
||||
MockLight("Test_4", light_state),
|
||||
]
|
||||
|
||||
entity0 = entities[0]
|
||||
|
||||
entity1 = entities[1]
|
||||
entity1.supported_features = light.SUPPORT_BRIGHTNESS
|
||||
# Set color modes to none to trigger backwards compatibility in LightEntity
|
||||
entity1.supported_color_modes = None
|
||||
entity1.color_mode = None
|
||||
|
||||
entity2 = entities[2]
|
||||
entity2.supported_features = light.SUPPORT_BRIGHTNESS | light.SUPPORT_COLOR_TEMP
|
||||
# Set color modes to none to trigger backwards compatibility in LightEntity
|
||||
entity2.supported_color_modes = None
|
||||
entity2.color_mode = None
|
||||
|
||||
entity3 = entities[3]
|
||||
entity3.supported_features = light.SUPPORT_BRIGHTNESS | light.SUPPORT_COLOR
|
||||
# Set color modes to none to trigger backwards compatibility in LightEntity
|
||||
entity3.supported_color_modes = None
|
||||
entity3.color_mode = None
|
||||
|
||||
entity4 = entities[4]
|
||||
entity4.supported_features = (
|
||||
light.SUPPORT_BRIGHTNESS | light.SUPPORT_COLOR | light.SUPPORT_COLOR_TEMP
|
||||
)
|
||||
# Set color modes to none to trigger backwards compatibility in LightEntity
|
||||
entity4.supported_color_modes = None
|
||||
entity4.color_mode = None
|
||||
|
||||
setup_test_component_platform(hass, light.DOMAIN, entities)
|
||||
|
||||
assert await async_setup_component(hass, "light", {"light": {"platform": "test"}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(entity0.entity_id)
|
||||
assert state.attributes["supported_color_modes"] == [light.ColorMode.ONOFF]
|
||||
if light_state == STATE_OFF:
|
||||
assert state.attributes["color_mode"] is None
|
||||
else:
|
||||
assert state.attributes["color_mode"] == light.ColorMode.ONOFF
|
||||
|
||||
state = hass.states.get(entity1.entity_id)
|
||||
assert state.attributes["supported_color_modes"] == [light.ColorMode.BRIGHTNESS]
|
||||
if light_state == STATE_OFF:
|
||||
assert state.attributes["color_mode"] is None
|
||||
else:
|
||||
assert state.attributes["color_mode"] == light.ColorMode.UNKNOWN
|
||||
|
||||
state = hass.states.get(entity2.entity_id)
|
||||
assert state.attributes["supported_color_modes"] == [light.ColorMode.COLOR_TEMP]
|
||||
if light_state == STATE_OFF:
|
||||
assert state.attributes["color_mode"] is None
|
||||
else:
|
||||
assert state.attributes["color_mode"] == light.ColorMode.UNKNOWN
|
||||
|
||||
state = hass.states.get(entity3.entity_id)
|
||||
assert state.attributes["supported_color_modes"] == [light.ColorMode.HS]
|
||||
if light_state == STATE_OFF:
|
||||
assert state.attributes["color_mode"] is None
|
||||
else:
|
||||
assert state.attributes["color_mode"] == light.ColorMode.UNKNOWN
|
||||
|
||||
state = hass.states.get(entity4.entity_id)
|
||||
assert state.attributes["supported_color_modes"] == [
|
||||
light.ColorMode.COLOR_TEMP,
|
||||
light.ColorMode.HS,
|
||||
]
|
||||
if light_state == STATE_OFF:
|
||||
assert state.attributes["color_mode"] is None
|
||||
else:
|
||||
assert state.attributes["color_mode"] == light.ColorMode.UNKNOWN
|
||||
|
||||
|
||||
async def test_light_backwards_compatibility_color_mode(hass: HomeAssistant) -> None:
|
||||
"""Test color_mode if not implemented by the entity."""
|
||||
entities = [
|
||||
MockLight("Test_0", STATE_ON),
|
||||
MockLight("Test_1", STATE_ON),
|
||||
MockLight("Test_2", STATE_ON),
|
||||
MockLight("Test_3", STATE_ON),
|
||||
MockLight("Test_4", STATE_ON),
|
||||
]
|
||||
|
||||
entity0 = entities[0]
|
||||
|
||||
entity1 = entities[1]
|
||||
entity1.supported_features = light.SUPPORT_BRIGHTNESS
|
||||
# Set color modes to none to trigger backwards compatibility in LightEntity
|
||||
entity1.supported_color_modes = None
|
||||
entity1.color_mode = None
|
||||
entity1.brightness = 100
|
||||
|
||||
entity2 = entities[2]
|
||||
entity2.supported_features = light.SUPPORT_BRIGHTNESS | light.SUPPORT_COLOR_TEMP
|
||||
# Set color modes to none to trigger backwards compatibility in LightEntity
|
||||
entity2.supported_color_modes = None
|
||||
entity2.color_mode = None
|
||||
entity2.color_temp_kelvin = 10000
|
||||
|
||||
entity3 = entities[3]
|
||||
entity3.supported_features = light.SUPPORT_BRIGHTNESS | light.SUPPORT_COLOR
|
||||
# Set color modes to none to trigger backwards compatibility in LightEntity
|
||||
entity3.supported_color_modes = None
|
||||
entity3.color_mode = None
|
||||
entity3.hs_color = (240, 100)
|
||||
|
||||
entity4 = entities[4]
|
||||
entity4.supported_features = (
|
||||
light.SUPPORT_BRIGHTNESS | light.SUPPORT_COLOR | light.SUPPORT_COLOR_TEMP
|
||||
)
|
||||
# Set color modes to none to trigger backwards compatibility in LightEntity
|
||||
entity4.supported_color_modes = None
|
||||
entity4.color_mode = None
|
||||
entity4.hs_color = (240, 100)
|
||||
entity4.color_temp_kelvin = 10000
|
||||
|
||||
setup_test_component_platform(hass, light.DOMAIN, entities)
|
||||
|
||||
assert await async_setup_component(hass, "light", {"light": {"platform": "test"}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(entity0.entity_id)
|
||||
assert state.attributes["supported_color_modes"] == [light.ColorMode.ONOFF]
|
||||
assert state.attributes["color_mode"] == light.ColorMode.ONOFF
|
||||
|
||||
state = hass.states.get(entity1.entity_id)
|
||||
assert state.attributes["supported_color_modes"] == [light.ColorMode.BRIGHTNESS]
|
||||
assert state.attributes["color_mode"] == light.ColorMode.BRIGHTNESS
|
||||
|
||||
state = hass.states.get(entity2.entity_id)
|
||||
assert state.attributes["supported_color_modes"] == [light.ColorMode.COLOR_TEMP]
|
||||
assert state.attributes["color_mode"] == light.ColorMode.COLOR_TEMP
|
||||
assert state.attributes["rgb_color"] == (202, 218, 255)
|
||||
assert state.attributes["hs_color"] == (221.575, 20.9)
|
||||
assert state.attributes["xy_color"] == (0.278, 0.287)
|
||||
|
||||
state = hass.states.get(entity3.entity_id)
|
||||
assert state.attributes["supported_color_modes"] == [light.ColorMode.HS]
|
||||
assert state.attributes["color_mode"] == light.ColorMode.HS
|
||||
|
||||
state = hass.states.get(entity4.entity_id)
|
||||
assert state.attributes["supported_color_modes"] == [
|
||||
light.ColorMode.COLOR_TEMP,
|
||||
light.ColorMode.HS,
|
||||
]
|
||||
# hs color prioritized over color_temp, light should report mode ColorMode.HS
|
||||
assert state.attributes["color_mode"] == light.ColorMode.HS
|
||||
|
||||
|
||||
async def test_light_service_call_rgbw(hass: HomeAssistant) -> None:
|
||||
"""Test rgbw functionality in service calls."""
|
||||
entity0 = MockLight("Test_rgbw", STATE_ON)
|
||||
@@ -1478,7 +1301,6 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
|
||||
MockLight("Test_rgb", STATE_ON),
|
||||
MockLight("Test_xy", STATE_ON),
|
||||
MockLight("Test_all", STATE_ON),
|
||||
MockLight("Test_legacy", STATE_ON),
|
||||
MockLight("Test_rgbw", STATE_ON),
|
||||
MockLight("Test_rgbww", STATE_ON),
|
||||
MockLight("Test_temperature", STATE_ON),
|
||||
@@ -1502,19 +1324,13 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
|
||||
}
|
||||
|
||||
entity4 = entities[4]
|
||||
entity4.supported_features = light.SUPPORT_COLOR
|
||||
# Set color modes to none to trigger backwards compatibility in LightEntity
|
||||
entity4.supported_color_modes = None
|
||||
entity4.color_mode = None
|
||||
entity4.supported_color_modes = {light.ColorMode.RGBW}
|
||||
|
||||
entity5 = entities[5]
|
||||
entity5.supported_color_modes = {light.ColorMode.RGBW}
|
||||
entity5.supported_color_modes = {light.ColorMode.RGBWW}
|
||||
|
||||
entity6 = entities[6]
|
||||
entity6.supported_color_modes = {light.ColorMode.RGBWW}
|
||||
|
||||
entity7 = entities[7]
|
||||
entity7.supported_color_modes = {light.ColorMode.COLOR_TEMP}
|
||||
entity6.supported_color_modes = {light.ColorMode.COLOR_TEMP}
|
||||
|
||||
assert await async_setup_component(hass, "light", {"light": {"platform": "test"}})
|
||||
await hass.async_block_till_done()
|
||||
@@ -1536,15 +1352,12 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
|
||||
]
|
||||
|
||||
state = hass.states.get(entity4.entity_id)
|
||||
assert state.attributes["supported_color_modes"] == [light.ColorMode.HS]
|
||||
|
||||
state = hass.states.get(entity5.entity_id)
|
||||
assert state.attributes["supported_color_modes"] == [light.ColorMode.RGBW]
|
||||
|
||||
state = hass.states.get(entity6.entity_id)
|
||||
state = hass.states.get(entity5.entity_id)
|
||||
assert state.attributes["supported_color_modes"] == [light.ColorMode.RGBWW]
|
||||
|
||||
state = hass.states.get(entity7.entity_id)
|
||||
state = hass.states.get(entity6.entity_id)
|
||||
assert state.attributes["supported_color_modes"] == [light.ColorMode.COLOR_TEMP]
|
||||
|
||||
await hass.services.async_call(
|
||||
@@ -1559,7 +1372,6 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
|
||||
entity4.entity_id,
|
||||
entity5.entity_id,
|
||||
entity6.entity_id,
|
||||
entity7.entity_id,
|
||||
],
|
||||
"brightness_pct": 100,
|
||||
"hs_color": (240, 100),
|
||||
@@ -1575,12 +1387,10 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
|
||||
_, data = entity3.last_call("turn_on")
|
||||
assert data == {"brightness": 255, "hs_color": (240.0, 100.0)}
|
||||
_, data = entity4.last_call("turn_on")
|
||||
assert data == {"brightness": 255, "hs_color": (240.0, 100.0)}
|
||||
_, data = entity5.last_call("turn_on")
|
||||
assert data == {"brightness": 255, "rgbw_color": (0, 0, 255, 0)}
|
||||
_, data = entity6.last_call("turn_on")
|
||||
_, data = entity5.last_call("turn_on")
|
||||
assert data == {"brightness": 255, "rgbww_color": (0, 0, 255, 0, 0)}
|
||||
_, data = entity7.last_call("turn_on")
|
||||
_, data = entity6.last_call("turn_on")
|
||||
assert data == {"brightness": 255, "color_temp_kelvin": 1739}
|
||||
|
||||
await hass.services.async_call(
|
||||
@@ -1595,7 +1405,6 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
|
||||
entity4.entity_id,
|
||||
entity5.entity_id,
|
||||
entity6.entity_id,
|
||||
entity7.entity_id,
|
||||
],
|
||||
"brightness_pct": 100,
|
||||
"hs_color": (240, 0),
|
||||
@@ -1611,13 +1420,11 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
|
||||
_, data = entity3.last_call("turn_on")
|
||||
assert data == {"brightness": 255, "hs_color": (240.0, 0.0)}
|
||||
_, data = entity4.last_call("turn_on")
|
||||
assert data == {"brightness": 255, "hs_color": (240.0, 0.0)}
|
||||
_, data = entity5.last_call("turn_on")
|
||||
assert data == {"brightness": 255, "rgbw_color": (0, 0, 0, 255)}
|
||||
_, data = entity6.last_call("turn_on")
|
||||
_, data = entity5.last_call("turn_on")
|
||||
# The midpoint of the white channels is warm, compensated by adding green + blue
|
||||
assert data == {"brightness": 255, "rgbww_color": (0, 76, 141, 255, 255)}
|
||||
_, data = entity7.last_call("turn_on")
|
||||
_, data = entity6.last_call("turn_on")
|
||||
assert data == {"brightness": 255, "color_temp_kelvin": 5962}
|
||||
|
||||
await hass.services.async_call(
|
||||
@@ -1632,7 +1439,6 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
|
||||
entity4.entity_id,
|
||||
entity5.entity_id,
|
||||
entity6.entity_id,
|
||||
entity7.entity_id,
|
||||
],
|
||||
"brightness_pct": 50,
|
||||
"rgb_color": (128, 0, 0),
|
||||
@@ -1648,12 +1454,10 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
|
||||
_, data = entity3.last_call("turn_on")
|
||||
assert data == {"brightness": 128, "rgb_color": (128, 0, 0)}
|
||||
_, data = entity4.last_call("turn_on")
|
||||
assert data == {"brightness": 128, "hs_color": (0.0, 100.0)}
|
||||
_, data = entity5.last_call("turn_on")
|
||||
assert data == {"brightness": 128, "rgbw_color": (128, 0, 0, 0)}
|
||||
_, data = entity6.last_call("turn_on")
|
||||
_, data = entity5.last_call("turn_on")
|
||||
assert data == {"brightness": 128, "rgbww_color": (128, 0, 0, 0, 0)}
|
||||
_, data = entity7.last_call("turn_on")
|
||||
_, data = entity6.last_call("turn_on")
|
||||
assert data == {"brightness": 128, "color_temp_kelvin": 6279}
|
||||
|
||||
await hass.services.async_call(
|
||||
@@ -1668,7 +1472,6 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
|
||||
entity4.entity_id,
|
||||
entity5.entity_id,
|
||||
entity6.entity_id,
|
||||
entity7.entity_id,
|
||||
],
|
||||
"brightness_pct": 50,
|
||||
"rgb_color": (255, 255, 255),
|
||||
@@ -1684,13 +1487,11 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
|
||||
_, data = entity3.last_call("turn_on")
|
||||
assert data == {"brightness": 128, "rgb_color": (255, 255, 255)}
|
||||
_, data = entity4.last_call("turn_on")
|
||||
assert data == {"brightness": 128, "hs_color": (0.0, 0.0)}
|
||||
_, data = entity5.last_call("turn_on")
|
||||
assert data == {"brightness": 128, "rgbw_color": (0, 0, 0, 255)}
|
||||
_, data = entity6.last_call("turn_on")
|
||||
_, data = entity5.last_call("turn_on")
|
||||
# The midpoint the white channels is warm, compensated by adding green + blue
|
||||
assert data == {"brightness": 128, "rgbww_color": (0, 76, 141, 255, 255)}
|
||||
_, data = entity7.last_call("turn_on")
|
||||
_, data = entity6.last_call("turn_on")
|
||||
assert data == {"brightness": 128, "color_temp_kelvin": 5962}
|
||||
|
||||
await hass.services.async_call(
|
||||
@@ -1705,7 +1506,6 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
|
||||
entity4.entity_id,
|
||||
entity5.entity_id,
|
||||
entity6.entity_id,
|
||||
entity7.entity_id,
|
||||
],
|
||||
"brightness_pct": 50,
|
||||
"xy_color": (0.1, 0.8),
|
||||
@@ -1721,12 +1521,10 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
|
||||
_, data = entity3.last_call("turn_on")
|
||||
assert data == {"brightness": 128, "xy_color": (0.1, 0.8)}
|
||||
_, data = entity4.last_call("turn_on")
|
||||
assert data == {"brightness": 128, "hs_color": (125.176, 100.0)}
|
||||
_, data = entity5.last_call("turn_on")
|
||||
assert data == {"brightness": 128, "rgbw_color": (0, 255, 22, 0)}
|
||||
_, data = entity6.last_call("turn_on")
|
||||
_, data = entity5.last_call("turn_on")
|
||||
assert data == {"brightness": 128, "rgbww_color": (0, 255, 22, 0, 0)}
|
||||
_, data = entity7.last_call("turn_on")
|
||||
_, data = entity6.last_call("turn_on")
|
||||
assert data == {"brightness": 128, "color_temp_kelvin": 8645}
|
||||
|
||||
await hass.services.async_call(
|
||||
@@ -1741,7 +1539,6 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
|
||||
entity4.entity_id,
|
||||
entity5.entity_id,
|
||||
entity6.entity_id,
|
||||
entity7.entity_id,
|
||||
],
|
||||
"brightness_pct": 50,
|
||||
"xy_color": (0.323, 0.329),
|
||||
@@ -1757,13 +1554,11 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
|
||||
_, data = entity3.last_call("turn_on")
|
||||
assert data == {"brightness": 128, "xy_color": (0.323, 0.329)}
|
||||
_, data = entity4.last_call("turn_on")
|
||||
assert data == {"brightness": 128, "hs_color": (0.0, 0.392)}
|
||||
_, data = entity5.last_call("turn_on")
|
||||
assert data == {"brightness": 128, "rgbw_color": (1, 0, 0, 255)}
|
||||
_, data = entity6.last_call("turn_on")
|
||||
_, data = entity5.last_call("turn_on")
|
||||
# The midpoint the white channels is warm, compensated by adding green + blue
|
||||
assert data == {"brightness": 128, "rgbww_color": (0, 75, 140, 255, 255)}
|
||||
_, data = entity7.last_call("turn_on")
|
||||
_, data = entity6.last_call("turn_on")
|
||||
assert data == {"brightness": 128, "color_temp_kelvin": 5962}
|
||||
|
||||
await hass.services.async_call(
|
||||
@@ -1778,7 +1573,6 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
|
||||
entity4.entity_id,
|
||||
entity5.entity_id,
|
||||
entity6.entity_id,
|
||||
entity7.entity_id,
|
||||
],
|
||||
"brightness_pct": 50,
|
||||
"rgbw_color": (128, 0, 0, 64),
|
||||
@@ -1794,13 +1588,11 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
|
||||
_, data = entity3.last_call("turn_on")
|
||||
assert data == {"brightness": 128, "rgb_color": (128, 43, 43)}
|
||||
_, data = entity4.last_call("turn_on")
|
||||
assert data == {"brightness": 128, "hs_color": (0.0, 66.406)}
|
||||
_, data = entity5.last_call("turn_on")
|
||||
assert data == {"brightness": 128, "rgbw_color": (128, 0, 0, 64)}
|
||||
_, data = entity6.last_call("turn_on")
|
||||
_, data = entity5.last_call("turn_on")
|
||||
# The midpoint the white channels is warm, compensated by adding green + blue
|
||||
assert data == {"brightness": 128, "rgbww_color": (128, 0, 30, 117, 117)}
|
||||
_, data = entity7.last_call("turn_on")
|
||||
_, data = entity6.last_call("turn_on")
|
||||
assert data == {"brightness": 128, "color_temp_kelvin": 3011}
|
||||
|
||||
await hass.services.async_call(
|
||||
@@ -1815,7 +1607,6 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
|
||||
entity4.entity_id,
|
||||
entity5.entity_id,
|
||||
entity6.entity_id,
|
||||
entity7.entity_id,
|
||||
],
|
||||
"brightness_pct": 50,
|
||||
"rgbw_color": (255, 255, 255, 255),
|
||||
@@ -1831,13 +1622,11 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
|
||||
_, data = entity3.last_call("turn_on")
|
||||
assert data == {"brightness": 128, "rgb_color": (255, 255, 255)}
|
||||
_, data = entity4.last_call("turn_on")
|
||||
assert data == {"brightness": 128, "hs_color": (0.0, 0.0)}
|
||||
_, data = entity5.last_call("turn_on")
|
||||
assert data == {"brightness": 128, "rgbw_color": (255, 255, 255, 255)}
|
||||
_, data = entity6.last_call("turn_on")
|
||||
_, data = entity5.last_call("turn_on")
|
||||
# The midpoint the white channels is warm, compensated by adding green + blue
|
||||
assert data == {"brightness": 128, "rgbww_color": (0, 76, 141, 255, 255)}
|
||||
_, data = entity7.last_call("turn_on")
|
||||
_, data = entity6.last_call("turn_on")
|
||||
assert data == {"brightness": 128, "color_temp_kelvin": 5962}
|
||||
|
||||
await hass.services.async_call(
|
||||
@@ -1852,7 +1641,6 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
|
||||
entity4.entity_id,
|
||||
entity5.entity_id,
|
||||
entity6.entity_id,
|
||||
entity7.entity_id,
|
||||
],
|
||||
"brightness_pct": 50,
|
||||
"rgbww_color": (128, 0, 0, 64, 32),
|
||||
@@ -1868,12 +1656,10 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
|
||||
_, data = entity3.last_call("turn_on")
|
||||
assert data == {"brightness": 128, "rgb_color": (128, 33, 26)}
|
||||
_, data = entity4.last_call("turn_on")
|
||||
assert data == {"brightness": 128, "hs_color": (4.118, 79.688)}
|
||||
_, data = entity5.last_call("turn_on")
|
||||
assert data == {"brightness": 128, "rgbw_color": (128, 9, 0, 33)}
|
||||
_, data = entity6.last_call("turn_on")
|
||||
_, data = entity5.last_call("turn_on")
|
||||
assert data == {"brightness": 128, "rgbww_color": (128, 0, 0, 64, 32)}
|
||||
_, data = entity7.last_call("turn_on")
|
||||
_, data = entity6.last_call("turn_on")
|
||||
assert data == {"brightness": 128, "color_temp_kelvin": 3845}
|
||||
|
||||
await hass.services.async_call(
|
||||
@@ -1888,7 +1674,6 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
|
||||
entity4.entity_id,
|
||||
entity5.entity_id,
|
||||
entity6.entity_id,
|
||||
entity7.entity_id,
|
||||
],
|
||||
"brightness_pct": 50,
|
||||
"rgbww_color": (255, 255, 255, 255, 255),
|
||||
@@ -1904,13 +1689,11 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
|
||||
_, data = entity3.last_call("turn_on")
|
||||
assert data == {"brightness": 128, "rgb_color": (255, 217, 185)}
|
||||
_, data = entity4.last_call("turn_on")
|
||||
assert data == {"brightness": 128, "hs_color": (27.429, 27.451)}
|
||||
_, data = entity5.last_call("turn_on")
|
||||
# The midpoint the white channels is warm, compensated by decreasing green + blue
|
||||
assert data == {"brightness": 128, "rgbw_color": (96, 44, 0, 255)}
|
||||
_, data = entity6.last_call("turn_on")
|
||||
_, data = entity5.last_call("turn_on")
|
||||
assert data == {"brightness": 128, "rgbww_color": (255, 255, 255, 255, 255)}
|
||||
_, data = entity7.last_call("turn_on")
|
||||
_, data = entity6.last_call("turn_on")
|
||||
assert data == {"brightness": 128, "color_temp_kelvin": 3451}
|
||||
|
||||
|
||||
@@ -1923,7 +1706,6 @@ async def test_light_service_call_color_conversion_named_tuple(
|
||||
MockLight("Test_rgb", STATE_ON),
|
||||
MockLight("Test_xy", STATE_ON),
|
||||
MockLight("Test_all", STATE_ON),
|
||||
MockLight("Test_legacy", STATE_ON),
|
||||
MockLight("Test_rgbw", STATE_ON),
|
||||
MockLight("Test_rgbww", STATE_ON),
|
||||
]
|
||||
@@ -1946,16 +1728,10 @@ async def test_light_service_call_color_conversion_named_tuple(
|
||||
}
|
||||
|
||||
entity4 = entities[4]
|
||||
entity4.supported_features = light.SUPPORT_COLOR
|
||||
# Set color modes to none to trigger backwards compatibility in LightEntity
|
||||
entity4.supported_color_modes = None
|
||||
entity4.color_mode = None
|
||||
entity4.supported_color_modes = {light.ColorMode.RGBW}
|
||||
|
||||
entity5 = entities[5]
|
||||
entity5.supported_color_modes = {light.ColorMode.RGBW}
|
||||
|
||||
entity6 = entities[6]
|
||||
entity6.supported_color_modes = {light.ColorMode.RGBWW}
|
||||
entity5.supported_color_modes = {light.ColorMode.RGBWW}
|
||||
|
||||
assert await async_setup_component(hass, "light", {"light": {"platform": "test"}})
|
||||
await hass.async_block_till_done()
|
||||
@@ -1971,7 +1747,6 @@ async def test_light_service_call_color_conversion_named_tuple(
|
||||
entity3.entity_id,
|
||||
entity4.entity_id,
|
||||
entity5.entity_id,
|
||||
entity6.entity_id,
|
||||
],
|
||||
"brightness_pct": 25,
|
||||
"rgb_color": color_util.RGBColor(128, 0, 0),
|
||||
@@ -1987,10 +1762,8 @@ async def test_light_service_call_color_conversion_named_tuple(
|
||||
_, data = entity3.last_call("turn_on")
|
||||
assert data == {"brightness": 64, "rgb_color": (128, 0, 0)}
|
||||
_, data = entity4.last_call("turn_on")
|
||||
assert data == {"brightness": 64, "hs_color": (0.0, 100.0)}
|
||||
_, data = entity5.last_call("turn_on")
|
||||
assert data == {"brightness": 64, "rgbw_color": (128, 0, 0, 0)}
|
||||
_, data = entity6.last_call("turn_on")
|
||||
_, data = entity5.last_call("turn_on")
|
||||
assert data == {"brightness": 64, "rgbww_color": (128, 0, 0, 0, 0)}
|
||||
|
||||
|
||||
@@ -2327,7 +2100,6 @@ async def test_light_state_color_conversion(hass: HomeAssistant) -> None:
|
||||
MockLight("Test_hs", STATE_ON),
|
||||
MockLight("Test_rgb", STATE_ON),
|
||||
MockLight("Test_xy", STATE_ON),
|
||||
MockLight("Test_legacy", STATE_ON),
|
||||
]
|
||||
setup_test_component_platform(hass, light.DOMAIN, entities)
|
||||
|
||||
@@ -2352,13 +2124,6 @@ async def test_light_state_color_conversion(hass: HomeAssistant) -> None:
|
||||
entity2.rgb_color = "Invalid" # Should be ignored
|
||||
entity2.xy_color = (0.1, 0.8)
|
||||
|
||||
entity3 = entities[3]
|
||||
entity3.hs_color = (240, 100)
|
||||
entity3.supported_features = light.SUPPORT_COLOR
|
||||
# Set color modes to none to trigger backwards compatibility in LightEntity
|
||||
entity3.supported_color_modes = None
|
||||
entity3.color_mode = None
|
||||
|
||||
assert await async_setup_component(hass, "light", {"light": {"platform": "test"}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@@ -2380,12 +2145,6 @@ async def test_light_state_color_conversion(hass: HomeAssistant) -> None:
|
||||
assert state.attributes["rgb_color"] == (0, 255, 22)
|
||||
assert state.attributes["xy_color"] == (0.1, 0.8)
|
||||
|
||||
state = hass.states.get(entity3.entity_id)
|
||||
assert state.attributes["color_mode"] == light.ColorMode.HS
|
||||
assert state.attributes["hs_color"] == (240, 100)
|
||||
assert state.attributes["rgb_color"] == (0, 0, 255)
|
||||
assert state.attributes["xy_color"] == (0.136, 0.04)
|
||||
|
||||
|
||||
async def test_services_filter_parameters(
|
||||
hass: HomeAssistant,
|
||||
@@ -2620,31 +2379,6 @@ def test_filter_supported_color_modes() -> None:
|
||||
assert light.filter_supported_color_modes(supported) == {light.ColorMode.BRIGHTNESS}
|
||||
|
||||
|
||||
def test_deprecated_supported_features_ints(
|
||||
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Test deprecated supported features ints."""
|
||||
|
||||
class MockLightEntityEntity(light.LightEntity):
|
||||
@property
|
||||
def supported_features(self) -> int:
|
||||
"""Return supported features."""
|
||||
return 1
|
||||
|
||||
entity = MockLightEntityEntity()
|
||||
entity.hass = hass
|
||||
entity.platform = MockEntityPlatform(hass, domain="test", platform_name="test")
|
||||
assert entity.supported_features_compat is light.LightEntityFeature(1)
|
||||
assert "MockLightEntityEntity" in caplog.text
|
||||
assert "is using deprecated supported features values" in caplog.text
|
||||
assert "Instead it should use" in caplog.text
|
||||
assert "LightEntityFeature" in caplog.text
|
||||
assert "and color modes" in caplog.text
|
||||
caplog.clear()
|
||||
assert entity.supported_features_compat is light.LightEntityFeature(1)
|
||||
assert "is using deprecated supported features values" not in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("color_mode", "supported_color_modes", "warning_expected"),
|
||||
[
|
||||
@@ -2871,46 +2605,3 @@ def test_missing_kelvin_property_warnings(
|
||||
|
||||
assert state.attributes[light.ATTR_MIN_COLOR_TEMP_KELVIN] == expected_values[0]
|
||||
assert state.attributes[light.ATTR_MAX_COLOR_TEMP_KELVIN] == expected_values[1]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"module",
|
||||
[light],
|
||||
)
|
||||
def test_all(module: ModuleType) -> None:
|
||||
"""Test module.__all__ is correctly set."""
|
||||
help_test_all(module)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("constant_name", "constant_value", "constant_replacement"),
|
||||
[
|
||||
("SUPPORT_BRIGHTNESS", 1, "supported_color_modes"),
|
||||
("SUPPORT_COLOR_TEMP", 2, "supported_color_modes"),
|
||||
("SUPPORT_COLOR", 16, "supported_color_modes"),
|
||||
],
|
||||
)
|
||||
def test_deprecated_light_constants(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
constant_name: str,
|
||||
constant_value: int | str,
|
||||
constant_replacement: str,
|
||||
) -> None:
|
||||
"""Test deprecated light constants."""
|
||||
import_and_test_deprecated_constant(
|
||||
caplog, light, constant_name, constant_replacement, constant_value, "2026.1"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"entity_feature",
|
||||
list(light.LightEntityFeature),
|
||||
)
|
||||
def test_deprecated_support_light_constants_enums(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
entity_feature: light.LightEntityFeature,
|
||||
) -> None:
|
||||
"""Test deprecated support light constants."""
|
||||
import_and_test_deprecated_constant_enum(
|
||||
caplog, light, entity_feature, "SUPPORT_", "2026.1"
|
||||
)
|
||||
|
||||
@@ -174,7 +174,9 @@ async def test_rgb_light(
|
||||
assert state.state == STATE_UNKNOWN
|
||||
color_modes = [light.ColorMode.HS]
|
||||
assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes
|
||||
expected_features = light.SUPPORT_FLASH | light.SUPPORT_TRANSITION
|
||||
expected_features = (
|
||||
light.LightEntityFeature.FLASH | light.LightEntityFeature.TRANSITION
|
||||
)
|
||||
assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features
|
||||
|
||||
|
||||
|
||||
177
tests/components/overkiz/test_entity.py
Normal file
177
tests/components/overkiz/test_entity.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""Tests for Overkiz entity."""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.overkiz.entity import OverkizEntity
|
||||
|
||||
|
||||
def _create_mock_device(
|
||||
device_url: str, place_oid: str | None, label: str = "Device"
|
||||
) -> Mock:
|
||||
"""Create a mock device with the given properties."""
|
||||
device = Mock()
|
||||
device.device_url = device_url
|
||||
device.place_oid = place_oid
|
||||
device.label = label
|
||||
device.available = True
|
||||
device.states = []
|
||||
device.widget = Mock(value="TestWidget")
|
||||
device.controllable_name = "test:Component"
|
||||
return device
|
||||
|
||||
|
||||
def _create_mock_entity(device: Mock, all_devices: list[Mock]) -> Mock:
|
||||
"""Create a mock entity with the given device and coordinator data."""
|
||||
entity = Mock(spec=OverkizEntity)
|
||||
entity.device = device
|
||||
entity.device_url = device.device_url
|
||||
entity.base_device_url = device.device_url.split("#")[0]
|
||||
entity.coordinator = Mock()
|
||||
entity.coordinator.data = {d.device_url: d for d in all_devices}
|
||||
|
||||
prefix = f"{entity.base_device_url}#"
|
||||
entity._get_sibling_devices = lambda: [
|
||||
d
|
||||
for d in all_devices
|
||||
if d.device_url != device.device_url and d.device_url.startswith(prefix)
|
||||
]
|
||||
entity._get_device_index = lambda url: (
|
||||
int(url.split("#")[-1]) if url.split("#")[-1].isdigit() else None
|
||||
)
|
||||
return entity
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("place_oids", "expected"),
|
||||
[
|
||||
(["place-a", "place-b"], True),
|
||||
(["place-a", "place-a"], False),
|
||||
],
|
||||
ids=["different_place_oids", "same_place_oids"],
|
||||
)
|
||||
def test_has_siblings_with_different_place_oid(
|
||||
place_oids: list[str], expected: bool
|
||||
) -> None:
|
||||
"""Test detection of siblings with different placeOIDs."""
|
||||
devices = [
|
||||
_create_mock_device("io://gateway/123#1", place_oids[0], "Device 1"),
|
||||
_create_mock_device("io://gateway/123#2", place_oids[1], "Device 2"),
|
||||
]
|
||||
entity = _create_mock_entity(devices[0], devices)
|
||||
|
||||
result = OverkizEntity._has_siblings_with_different_place_oid(entity)
|
||||
|
||||
assert result is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("device_index", "expected"),
|
||||
[
|
||||
(0, True),
|
||||
(1, False),
|
||||
],
|
||||
ids=["lowest_index_is_main", "higher_index_not_main"],
|
||||
)
|
||||
def test_is_main_device_for_place_oid(device_index: int, expected: bool) -> None:
|
||||
"""Test main device detection for placeOID group."""
|
||||
devices = [
|
||||
_create_mock_device("io://gateway/123#1", "place-a", "Device 1"),
|
||||
_create_mock_device("io://gateway/123#4", "place-a", "Device 4"),
|
||||
]
|
||||
entity = _create_mock_entity(devices[device_index], devices)
|
||||
|
||||
result = OverkizEntity._is_main_device_for_place_oid(entity)
|
||||
|
||||
assert result is expected
|
||||
|
||||
|
||||
def test_get_via_device_id_sub_device_links_to_main() -> None:
|
||||
"""Test sub-device links to main actuator with placeOID grouping."""
|
||||
devices = [
|
||||
_create_mock_device("io://gateway/123#1", "place-a", "Actuator"),
|
||||
_create_mock_device("io://gateway/123#2", "place-b", "Zone"),
|
||||
]
|
||||
entity = _create_mock_entity(devices[1], devices)
|
||||
entity.executor = Mock()
|
||||
entity.executor.get_gateway_id = Mock(return_value="gateway-id")
|
||||
|
||||
result = OverkizEntity._get_via_device_id(entity, use_place_oid_grouping=True)
|
||||
|
||||
assert result == "io://gateway/123#place-a"
|
||||
|
||||
|
||||
def test_get_via_device_id_main_device_links_to_gateway() -> None:
|
||||
"""Test main device (#1) links to gateway."""
|
||||
devices = [
|
||||
_create_mock_device("io://gateway/123#1", "place-a", "Actuator"),
|
||||
]
|
||||
entity = _create_mock_entity(devices[0], devices)
|
||||
entity.executor = Mock()
|
||||
entity.executor.get_gateway_id = Mock(return_value="gateway-id")
|
||||
|
||||
result = OverkizEntity._get_via_device_id(entity, use_place_oid_grouping=True)
|
||||
|
||||
assert result == "gateway-id"
|
||||
|
||||
|
||||
def test_has_siblings_with_no_place_oid() -> None:
|
||||
"""Test device with no placeOID returns False."""
|
||||
devices = [
|
||||
_create_mock_device("io://gateway/123#1", None, "Device 1"),
|
||||
_create_mock_device("io://gateway/123#2", "place-b", "Device 2"),
|
||||
]
|
||||
entity = _create_mock_entity(devices[0], devices)
|
||||
|
||||
result = OverkizEntity._has_siblings_with_different_place_oid(entity)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_is_main_device_with_no_place_oid() -> None:
|
||||
"""Test device with no placeOID is always considered main."""
|
||||
devices = [
|
||||
_create_mock_device("io://gateway/123#2", None, "Device 2"),
|
||||
_create_mock_device("io://gateway/123#1", "place-a", "Device 1"),
|
||||
]
|
||||
entity = _create_mock_entity(devices[0], devices)
|
||||
|
||||
result = OverkizEntity._is_main_device_for_place_oid(entity)
|
||||
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_get_via_device_id_main_device_without_place_oid() -> None:
|
||||
"""Test fallback to gateway when #1 device has no placeOID."""
|
||||
devices = [
|
||||
_create_mock_device("io://gateway/123#1", None, "Actuator"),
|
||||
_create_mock_device("io://gateway/123#2", "place-b", "Zone"),
|
||||
]
|
||||
entity = _create_mock_entity(devices[1], devices)
|
||||
entity.executor = Mock()
|
||||
entity.executor.get_gateway_id = Mock(return_value="gateway-id")
|
||||
|
||||
result = OverkizEntity._get_via_device_id(entity, use_place_oid_grouping=True)
|
||||
|
||||
assert result == "gateway-id"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("device_url", "expected"),
|
||||
[
|
||||
("io://gateway/123#4", 4),
|
||||
("io://gateway/123#10", 10),
|
||||
("io://gateway/123#abc", None),
|
||||
("io://gateway/123#", None),
|
||||
],
|
||||
ids=["single_digit", "multi_digit", "non_numeric", "empty_suffix"],
|
||||
)
|
||||
def test_get_device_index(device_url: str, expected: int | None) -> None:
|
||||
"""Test extracting numeric index from device URL."""
|
||||
device = _create_mock_device(device_url, "place-a")
|
||||
entity = _create_mock_entity(device, [device])
|
||||
|
||||
result = OverkizEntity._get_device_index(entity, device_url)
|
||||
|
||||
assert result == expected
|
||||
1
tests/components/redgtech/__init__.py
Normal file
1
tests/components/redgtech/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for the Redgtech component."""
|
||||
70
tests/components/redgtech/conftest.py
Normal file
70
tests/components/redgtech/conftest.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Test fixtures for Redgtech integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.redgtech.const import DOMAIN
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
TEST_EMAIL = "test@example.com"
|
||||
TEST_PASSWORD = "test_password"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redgtech_api() -> Generator[MagicMock]:
|
||||
"""Return a mocked Redgtech API client."""
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.redgtech.coordinator.RedgtechAPI", autospec=True
|
||||
) as api_mock,
|
||||
patch(
|
||||
"homeassistant.components.redgtech.config_flow.RedgtechAPI",
|
||||
new=api_mock,
|
||||
),
|
||||
):
|
||||
api = api_mock.return_value
|
||||
|
||||
api.login = AsyncMock(return_value="mock_access_token")
|
||||
api.get_data = AsyncMock(
|
||||
return_value={
|
||||
"boards": [
|
||||
{
|
||||
"endpointId": "switch_001",
|
||||
"friendlyName": "Living Room Switch",
|
||||
"value": False,
|
||||
"displayCategories": ["SWITCH"],
|
||||
},
|
||||
{
|
||||
"endpointId": "switch_002",
|
||||
"friendlyName": "Kitchen Switch",
|
||||
"value": True,
|
||||
"displayCategories": ["SWITCH"],
|
||||
},
|
||||
{
|
||||
"endpointId": "light_switch_001",
|
||||
"friendlyName": "Bedroom Light Switch",
|
||||
"value": False,
|
||||
"displayCategories": ["LIGHT", "SWITCH"],
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
api.set_switch_state = AsyncMock()
|
||||
|
||||
yield api
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry() -> MockConfigEntry:
|
||||
"""Mock config entry."""
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={"email": TEST_EMAIL, "password": TEST_PASSWORD},
|
||||
title="Mock Title",
|
||||
entry_id="test_entry",
|
||||
)
|
||||
99
tests/components/redgtech/snapshots/test_switch.ambr
Normal file
99
tests/components/redgtech/snapshots/test_switch.ambr
Normal file
@@ -0,0 +1,99 @@
|
||||
# serializer version: 1
|
||||
# name: test_entities[switch.kitchen_switch-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'switch',
|
||||
'entity_category': None,
|
||||
'entity_id': 'switch.kitchen_switch',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': None,
|
||||
'platform': 'redgtech',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': 'switch_002',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entities[switch.kitchen_switch-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Kitchen Switch',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'switch.kitchen_switch',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities[switch.living_room_switch-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'switch',
|
||||
'entity_category': None,
|
||||
'entity_id': 'switch.living_room_switch',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': None,
|
||||
'platform': 'redgtech',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': 'switch_001',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entities[switch.living_room_switch-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Living Room Switch',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'switch.living_room_switch',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
138
tests/components/redgtech/test_config_flow.py
Normal file
138
tests/components/redgtech/test_config_flow.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""Tests Config flow for the Redgtech integration."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from redgtech_api.api import RedgtechAuthError, RedgtechConnectionError
|
||||
|
||||
from homeassistant.components.redgtech.const import DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_USER
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
TEST_EMAIL = "test@example.com"
|
||||
TEST_PASSWORD = "123456"
|
||||
FAKE_TOKEN = "fake_token"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("side_effect", "expected_error"),
|
||||
[
|
||||
(RedgtechAuthError, "invalid_auth"),
|
||||
(RedgtechConnectionError, "cannot_connect"),
|
||||
(Exception("Generic error"), "unknown"),
|
||||
],
|
||||
)
|
||||
async def test_user_step_errors(
|
||||
hass: HomeAssistant,
|
||||
mock_redgtech_api: MagicMock,
|
||||
side_effect: type[Exception],
|
||||
expected_error: str,
|
||||
) -> None:
|
||||
"""Test user step with various errors."""
|
||||
user_input = {CONF_EMAIL: TEST_EMAIL, CONF_PASSWORD: TEST_PASSWORD}
|
||||
mock_redgtech_api.login.side_effect = side_effect
|
||||
mock_redgtech_api.login.return_value = None
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}, data=user_input
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"]["base"] == expected_error
|
||||
mock_redgtech_api.login.assert_called_once_with(TEST_EMAIL, TEST_PASSWORD)
|
||||
|
||||
|
||||
async def test_user_step_creates_entry(
|
||||
hass: HomeAssistant,
|
||||
mock_redgtech_api: MagicMock,
|
||||
) -> None:
|
||||
"""Tests the correct creation of the entry in the configuration."""
|
||||
user_input = {CONF_EMAIL: TEST_EMAIL, CONF_PASSWORD: TEST_PASSWORD}
|
||||
mock_redgtech_api.login.reset_mock()
|
||||
mock_redgtech_api.login.return_value = FAKE_TOKEN
|
||||
mock_redgtech_api.login.side_effect = None
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}, data=user_input
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == TEST_EMAIL
|
||||
assert result["data"] == user_input
|
||||
# Verify login was called at least once with correct parameters
|
||||
mock_redgtech_api.login.assert_any_call(TEST_EMAIL, TEST_PASSWORD)
|
||||
|
||||
|
||||
async def test_user_step_duplicate_entry(
|
||||
hass: HomeAssistant,
|
||||
mock_redgtech_api: MagicMock,
|
||||
) -> None:
|
||||
"""Test attempt to add duplicate entry."""
|
||||
existing_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id=TEST_EMAIL,
|
||||
data={CONF_EMAIL: TEST_EMAIL},
|
||||
)
|
||||
existing_entry.add_to_hass(hass)
|
||||
|
||||
user_input = {CONF_EMAIL: TEST_EMAIL, CONF_PASSWORD: TEST_PASSWORD}
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}, data=user_input
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
mock_redgtech_api.login.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("side_effect", "expected_error"),
|
||||
[
|
||||
(RedgtechAuthError, "invalid_auth"),
|
||||
(RedgtechConnectionError, "cannot_connect"),
|
||||
(Exception("Generic error"), "unknown"),
|
||||
],
|
||||
)
|
||||
async def test_user_step_error_recovery(
|
||||
hass: HomeAssistant,
|
||||
mock_redgtech_api: MagicMock,
|
||||
side_effect: Exception,
|
||||
expected_error: str,
|
||||
) -> None:
|
||||
"""Test that the flow can recover from errors and complete successfully."""
|
||||
user_input = {CONF_EMAIL: TEST_EMAIL, CONF_PASSWORD: TEST_PASSWORD}
|
||||
|
||||
# Reset mock to start fresh
|
||||
mock_redgtech_api.login.reset_mock()
|
||||
mock_redgtech_api.login.return_value = None
|
||||
mock_redgtech_api.login.side_effect = None
|
||||
|
||||
# First attempt fails with error
|
||||
mock_redgtech_api.login.side_effect = side_effect
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}, data=user_input
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"]["base"] == expected_error
|
||||
# Verify login was called at least once for the first attempt
|
||||
assert mock_redgtech_api.login.call_count >= 1
|
||||
first_call_count = mock_redgtech_api.login.call_count
|
||||
|
||||
# Second attempt succeeds - flow recovers
|
||||
mock_redgtech_api.login.side_effect = None
|
||||
mock_redgtech_api.login.return_value = FAKE_TOKEN
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input=user_input
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == TEST_EMAIL
|
||||
assert result["data"] == user_input
|
||||
# Verify login was called again for the second attempt (recovery)
|
||||
assert mock_redgtech_api.login.call_count > first_call_count
|
||||
255
tests/components/redgtech/test_switch.py
Normal file
255
tests/components/redgtech/test_switch.py
Normal file
@@ -0,0 +1,255 @@
|
||||
"""Tests for the Redgtech switch platform."""
|
||||
|
||||
from datetime import timedelta
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from freezegun import freeze_time
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
from redgtech_api.api import RedgtechAuthError, RedgtechConnectionError
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
SERVICE_TOGGLE,
|
||||
SERVICE_TURN_OFF,
|
||||
SERVICE_TURN_ON,
|
||||
STATE_UNAVAILABLE,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def freezer():
|
||||
"""Provide a freezer fixture that works with freeze_time decorator."""
|
||||
with freeze_time() as frozen_time:
|
||||
yield frozen_time
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def setup_redgtech_integration(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_redgtech_api: MagicMock,
|
||||
) -> MagicMock:
|
||||
"""Set up the Redgtech integration with mocked API."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
return mock_redgtech_api
|
||||
|
||||
|
||||
async def test_entities(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
setup_redgtech_integration,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test entity setup."""
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
async def test_switch_turn_on(
|
||||
hass: HomeAssistant,
|
||||
setup_redgtech_integration: MagicMock,
|
||||
) -> None:
|
||||
"""Test turning a switch on."""
|
||||
mock_api = setup_redgtech_integration
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: "switch.living_room_switch"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_api.set_switch_state.assert_called_once_with(
|
||||
"switch_001", True, "mock_access_token"
|
||||
)
|
||||
|
||||
|
||||
async def test_switch_turn_off(
|
||||
hass: HomeAssistant,
|
||||
setup_redgtech_integration: MagicMock,
|
||||
) -> None:
|
||||
"""Test turning a switch off."""
|
||||
mock_api = setup_redgtech_integration
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
SERVICE_TURN_OFF,
|
||||
{ATTR_ENTITY_ID: "switch.kitchen_switch"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_api.set_switch_state.assert_called_once_with(
|
||||
"switch_002", False, "mock_access_token"
|
||||
)
|
||||
|
||||
|
||||
async def test_switch_toggle(
|
||||
hass: HomeAssistant,
|
||||
setup_redgtech_integration: MagicMock,
|
||||
) -> None:
|
||||
"""Test toggling a switch."""
|
||||
mock_api = setup_redgtech_integration
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
SERVICE_TOGGLE,
|
||||
{ATTR_ENTITY_ID: "switch.living_room_switch"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_api.set_switch_state.assert_called_once_with(
|
||||
"switch_001", True, "mock_access_token"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "error_message"),
|
||||
[
|
||||
(
|
||||
RedgtechConnectionError("Connection failed"),
|
||||
"Connection error with Redgtech API",
|
||||
),
|
||||
(
|
||||
RedgtechAuthError("Auth failed"),
|
||||
"Authentication failed when controlling Redgtech switch",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_exception_handling(
|
||||
hass: HomeAssistant,
|
||||
setup_redgtech_integration: MagicMock,
|
||||
exception: Exception,
|
||||
error_message: str,
|
||||
) -> None:
|
||||
"""Test exception handling when controlling switches."""
|
||||
mock_api = setup_redgtech_integration
|
||||
mock_api.set_switch_state.side_effect = exception
|
||||
|
||||
with pytest.raises(HomeAssistantError, match=error_message):
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: "switch.living_room_switch"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_switch_auth_error_with_retry(
|
||||
hass: HomeAssistant,
|
||||
setup_redgtech_integration: MagicMock,
|
||||
) -> None:
|
||||
"""Test handling auth errors with token renewal."""
|
||||
mock_api = setup_redgtech_integration
|
||||
# Mock fails with auth error
|
||||
mock_api.set_switch_state.side_effect = RedgtechAuthError("Auth failed")
|
||||
|
||||
# Expect HomeAssistantError to be raised
|
||||
with pytest.raises(
|
||||
HomeAssistantError,
|
||||
match="Authentication failed when controlling Redgtech switch",
|
||||
):
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: "switch.living_room_switch"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
@freeze_time("2023-01-01 12:00:00")
|
||||
async def test_coordinator_data_update_success(
|
||||
hass: HomeAssistant,
|
||||
setup_redgtech_integration: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test successful data update through coordinator."""
|
||||
mock_api = setup_redgtech_integration
|
||||
|
||||
# Update mock data
|
||||
mock_api.get_data.return_value = {
|
||||
"boards": [
|
||||
{
|
||||
"endpointId": "switch_001",
|
||||
"friendlyName": "Living Room Switch",
|
||||
"value": True, # Changed to True
|
||||
"displayCategories": ["SWITCH"],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# Use freezer to advance time and trigger update
|
||||
freezer.tick(delta=timedelta(minutes=2))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Verify the entity state was updated successfully
|
||||
living_room_state = hass.states.get("switch.living_room_switch")
|
||||
assert living_room_state is not None
|
||||
assert living_room_state.state == "on"
|
||||
|
||||
|
||||
@freeze_time("2023-01-01 12:00:00")
|
||||
async def test_coordinator_connection_error_during_update(
|
||||
hass: HomeAssistant,
|
||||
setup_redgtech_integration: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test coordinator handling connection errors during data updates."""
|
||||
mock_api = setup_redgtech_integration
|
||||
mock_api.get_data.side_effect = RedgtechConnectionError("Connection failed")
|
||||
|
||||
# Use freezer to advance time and trigger update
|
||||
freezer.tick(delta=timedelta(minutes=2))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Verify entities become unavailable due to coordinator error
|
||||
living_room_state = hass.states.get("switch.living_room_switch")
|
||||
kitchen_state = hass.states.get("switch.kitchen_switch")
|
||||
|
||||
assert living_room_state.state == STATE_UNAVAILABLE
|
||||
assert kitchen_state.state == STATE_UNAVAILABLE
|
||||
|
||||
|
||||
@freeze_time("2023-01-01 12:00:00")
|
||||
async def test_coordinator_auth_error_with_token_renewal(
|
||||
hass: HomeAssistant,
|
||||
setup_redgtech_integration: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test coordinator handling auth errors with token renewal."""
|
||||
mock_api = setup_redgtech_integration
|
||||
|
||||
# First call fails with auth error, second succeeds after token renewal
|
||||
mock_api.get_data.side_effect = [
|
||||
RedgtechAuthError("Auth failed"),
|
||||
{
|
||||
"boards": [
|
||||
{
|
||||
"endpointId": "switch_001",
|
||||
"friendlyName": "Living Room Switch",
|
||||
"value": True,
|
||||
"displayCategories": ["SWITCH"],
|
||||
}
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
# Use freezer to advance time and trigger update
|
||||
freezer.tick(delta=timedelta(minutes=2))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Verify token renewal was attempted
|
||||
assert mock_api.login.call_count >= 2
|
||||
# Verify entity is available after successful token renewal
|
||||
living_room_state = hass.states.get("switch.living_room_switch")
|
||||
assert living_room_state is not None
|
||||
assert living_room_state.state != STATE_UNAVAILABLE
|
||||
Reference in New Issue
Block a user