Compare commits

..

98 Commits

Author SHA1 Message Date
Paul Bottein
85336eadd6 Address copilot feedbacks 2026-02-05 14:08:09 +01:00
Paul Bottein
d037b2073d Add tests 2026-02-05 13:26:10 +01:00
Paul Bottein
ff3abb5b0b Refactor code 2026-02-05 12:28:40 +01:00
Paul Bottein
c014d32cac Group Overkiz sub-devices by placeOID 2026-02-05 12:23:09 +01:00
Krisjanis Lejejs
fa58fe5f4e Bump hass-nabucasa from 1.12.0 to 1.13.0 (#162274) 2026-02-05 11:03:44 +01:00
Petro31
46f230c487 Clean up unused cover constants (#162225) 2026-02-05 10:46:36 +01:00
epenet
13a987aba3 Cleanup deprecated SUPPORT_ light constants (#162210) 2026-02-05 10:32:32 +01:00
cdnninja
9cef323581 Update Vesync quality-scale to Bronze (#162260) 2026-02-05 09:44:47 +01:00
epenet
7ea7576188 Cleanup legacy support for extracting color modes from light supported features (#162265) 2026-02-05 09:33:22 +01:00
Franck Nijhof
f8abbfd42b Merge branch 'master' into dev 2026-02-05 08:17:24 +00:00
Erik Montnemery
5cd1821bc9 Update redgtech snapshots (#162267) 2026-02-05 09:13:13 +01:00
Norbert Rittel
2ef7f26ffb Improve description of camera.play_stream action (#162264) 2026-02-05 09:07:10 +01:00
Jonathan Sady do Nascimento
184bea49e2 Add redgtech integration (#136947)
Co-authored-by: luan-nvg <luannnvg@gmail.com>
2026-02-05 09:04:14 +01:00
David Bonnes
c853fb2068 Bump evohome-async to 1.1.3 (#162232) 2026-02-05 08:25:30 +01:00
Franck Nijhof
3e8923f105 2026.2.0 (#162224) 2026-02-04 20:35:11 +01:00
Franck Nijhof
17cca3e69d Bump version to 2026.2.0 2026-02-04 18:53:49 +00:00
Franck Nijhof
12714c489f Bump version to 2026.2.0b5 2026-02-04 18:45:36 +00:00
Robert Resch
f788d61b4a Revert "Bump intents (#162205)" (#162226) 2026-02-04 18:36:12 +00:00
Simone Chemelli
5c726af00b Fix logic and tests for Alexa Devices utils module (#162223) 2026-02-04 18:36:10 +00:00
Joost Lekkerkerker
d1d207fbb2 Add guard for Apple TV text focus state (#162207) 2026-02-04 18:36:09 +00:00
David Bonnes
6c7f8df7f7 Fix evohome not updating scheduled setpoints in state attrs (#162043) 2026-02-04 18:36:07 +00:00
Kevin Stillhammer
6f8c9b1504 Bump fressnapftracker to 0.2.2 (#161913) 2026-02-04 18:36:06 +00:00
Kevin Stillhammer
4f9aedbc84 Filter out invalid trackers in fressnapf_tracker (#161670) 2026-02-04 18:36:04 +00:00
Franck Nijhof
52fb0343e4 Bump version to 2026.2.0b4 2026-02-04 16:14:23 +00:00
Bram Kragten
1050b4580a Update frontend to 20260128.6 (#162214) 2026-02-04 16:10:08 +00:00
Åke Strandberg
344c42172e Add missing codes for Miele coffe systems (#162206) 2026-02-04 16:10:06 +00:00
Michael Hansen
93cc0fd7f1 Bump intents (#162205) 2026-02-04 16:10:05 +00:00
andreimoraru
05fe636b55 Bump yt-dlp to 2026.02.04 (#162204)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-04 16:10:03 +00:00
Marc Mueller
f22467d099 Pin auth0-python to <5.0 (#162203) 2026-02-04 16:10:01 +00:00
TheJulianJES
4bc3899b32 Bump ZHA to 0.0.89 (#162195) 2026-02-04 16:10:00 +00:00
Oliver
fc4d6bf5f1 Bump denonavr to 1.3.1 (#162183) 2026-02-04 16:09:58 +00:00
johanzander
8ed0672a8f Bump growattServer to 1.9.0 (#162179)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 16:09:57 +00:00
Norbert Rittel
282e347a1b Clarify action descriptions in media_player (#162172) 2026-02-04 16:09:55 +00:00
Erik Montnemery
1bfb02b440 Bump python-otbr-api to 2.8.0 (#162167) 2026-02-04 16:09:54 +00:00
Przemko92
71b03bd9ae Bump compit-inext-api to 0.8.0 (#162166) 2026-02-04 16:09:52 +00:00
Przemko92
cbd69822eb Update compit-inext-api to 0.7.0 (#162020) 2026-02-04 16:09:51 +00:00
Denis Shulyaka
db900f4dd2 Anthropic repair deprecated models (#162162)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-04 16:00:14 +00:00
Jonathan Bangert
a707e695bc Bump bleak-esphome to 3.6.0 (#162028) 2026-02-04 16:00:12 +00:00
Liquidmasl
4feceac205 Jellyfin native client controls (#161982) 2026-02-04 16:00:11 +00:00
Petro31
10c20faaca Fix template weather humidity (#161945) 2026-02-04 16:00:09 +00:00
Robert Svensson
abcd512401 Add missing OUI to Axis integration, discovery would abort with unsup… (#161943) 2026-02-04 16:00:07 +00:00
Bram Kragten
fdf8edf474 Bump version to 2026.2.0b3 2026-02-03 18:03:54 +01:00
Bram Kragten
47e1a98bee Update frontend to 20260128.5 (#162156) 2026-02-03 18:03:04 +01:00
Joost Lekkerkerker
2d8572b943 Add Heatit virtual brand (#162155) 2026-02-03 18:03:02 +01:00
Joost Lekkerkerker
660cfdbd50 Add Heiman virtual brand (#162152) 2026-02-03 18:03:00 +01:00
Steven Travers
4208595da6 Modify Analytics text on feature labs (#162151) 2026-02-03 18:02:59 +01:00
Paul Bottein
b6b2d2fc6f Update title and description of YAML dashboard repair (#162138) 2026-02-03 18:02:58 +01:00
victorigualada
6c4c632848 Handle chat log attachments in Cloud integration (#162121) 2026-02-03 18:02:57 +01:00
Shay Levy
78cf62176f Fix Shelly xpercent sensor state_class (#162107) 2026-02-03 18:02:56 +01:00
Denis Shulyaka
df971c7a42 Anthropic: Switch default model to Haiku 4.5 (#162093) 2026-02-03 18:02:55 +01:00
mezz64
1fcabb7f2d Bump pyhik to 0.4.2 (#162092)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-03 18:02:53 +01:00
Åke Strandberg
9fb60c9ea2 Update Senz temperature sensor (#162016) 2026-02-03 18:02:52 +01:00
J. Diego Rodríguez Royo
9c11a4646f Remove coffee machine's hot water sensor's state class at Home Connect (#161246) 2026-02-03 17:58:47 +01:00
jameson_uk
b036a78776 Remove invalid notification sensors for Alexa devices (#160422)
Co-authored-by: Simone Chemelli <simone.chemelli@gmail.com>
2026-02-03 17:58:45 +01:00
Kamil Breguła
60bb3cb704 Handle missing battery stats in systemmonitor (#158287)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-03 17:58:43 +01:00
Bram Kragten
0e770958ac Bump version to 2026.2.0b2 2026-02-02 19:12:33 +01:00
Bram Kragten
2a54c71b6c Update frontend to 20260128.4 (#162096) 2026-02-02 19:11:59 +01:00
Steven Travers
50463291ab Add learn more data for Analytics in labs (#162094) 2026-02-02 19:11:59 +01:00
Andrea Turri
43cc34042a Fix Miele dishwasher PowerDisk filling level sensor not showing up (#162048) 2026-02-02 19:11:58 +01:00
Jan Bouwhuis
a02244ccda Bump incomfort-client to 0.6.12 (#162037) 2026-02-02 19:11:57 +01:00
Adrián Moreno
a739619121 Bump pymeteoclimatic to 0.1.1 (#162029) 2026-02-02 19:11:56 +01:00
Åke Strandberg
5db97a5f1c Improved error checking during startup of SENZ (#162026) 2026-02-02 19:11:54 +01:00
Josef Zweck
804ba9c9cc Remove file description dependency in onedrive (#162012) 2026-02-02 19:11:53 +01:00
Filip Bårdsnes Tomren
5ecbcea946 Update ical requirement version to 12.1.3 (#162010) 2026-02-02 19:11:52 +01:00
hanwg
11be2b6289 Fix parse_mode for Telegram bot actions (#162006) 2026-02-02 19:11:51 +01:00
cdnninja
eefae0307b Add integration type of hub to vesync (#162004) 2026-02-02 19:11:50 +01:00
Matthias Alphart
d397ee28ea Fix KNX fan unique_id for switch-only fans (#162002) 2026-02-02 19:11:49 +01:00
starkillerOG
02c821128e Bump reolink-aio to 0.18.2 (#161998) 2026-02-02 19:11:48 +01:00
Shay Levy
71dc15d45f Fix Shelly CoIoT repair issue (#161973)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-02 19:11:47 +01:00
Raphael Hehl
1078387b22 Bump uiprotect to version 10.1.0 (#161967)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-02-02 19:11:46 +01:00
tronikos
35fab27d15 Bump opower to 0.17.0 (#161962) 2026-02-02 19:11:45 +01:00
Yuxin Wang
915dc7a908 Mark datetime sensors as unknown when parsing fails (#161952) 2026-02-02 19:11:44 +01:00
mvn23
e5a9738983 Fix OpenTherm Gateway button availability (#161933) 2026-02-02 19:11:43 +01:00
mvn23
2ff73219a2 Bump pyotgw to 2.2.3 (#161928) 2026-02-02 19:11:42 +01:00
epenet
5dc1270ed1 Fix mired warning in template light (#161923) 2026-02-02 19:11:41 +01:00
J. Diego Rodríguez Royo
9e95ad5a85 Restore the Home Connect program option entities (#156401)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-02-02 19:11:40 +01:00
Franck Nijhof
9a5d4610f7 Bump version to 2026.2.0b1 2026-01-30 11:45:08 +00:00
Paul Bottein
41c524fce4 Update frontend to 20260128.3 (#161918) 2026-01-30 11:44:54 +00:00
David Recordon
5f9fa95554 Fix Control4 HVAC state-to-action mapping (#161916) 2026-01-30 11:44:51 +00:00
Simone Chemelli
6950be8ea9 Handle hostname resolution for Shelly repair issue (#161914) 2026-01-30 11:44:47 +00:00
puddly
c5a8bf64d0 Bump ZHA to 0.0.88 (#161904) 2026-01-30 11:44:44 +00:00
hanwg
a2b9a6e9df Update translations for Telegram bot (#161903) 2026-01-30 11:44:43 +00:00
Marc Mueller
a0c567f0da Update fritzconnection to 1.15.1 (#161887) 2026-01-30 11:44:40 +00:00
Bram Kragten
c7feafdde6 Update frontend to 20260128.2 (#161881) 2026-01-30 11:44:38 +00:00
Björn Dalfors
e1e74b0aeb Bump nibe to 2.22.0 (#161873) 2026-01-30 11:44:36 +00:00
Sebastiaan Speck
673411ef97 Bump renault-api to 0.5.3 (#161857) 2026-01-30 11:44:34 +00:00
epenet
f7e5af7cb1 Fix incorrect entity_description class in radarr (#161856) 2026-01-30 11:44:32 +00:00
Norbert Rittel
0ee56ce708 Fix action descriptions of alarm_control_panel (#161852) 2026-01-30 11:44:30 +00:00
Manu
f93a176398 Fix string in Namecheap DynamicDNS integration (#161821) 2026-01-30 11:44:28 +00:00
Paul Bottein
cd2394bc12 Allow lovelace path for dashboard in yaml and fix yaml dashboard migration (#161816) 2026-01-30 11:44:26 +00:00
Michael Hansen
5c20b8eaff Bump intents to 2026.1.28 (#161813) 2026-01-30 11:44:25 +00:00
Aaron Godfrey
4bd499d3a6 Update todoist-api-python to 3.1.0 (#161811)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-30 11:44:23 +00:00
Jan Bouwhuis
8a53b94c5a Fix use of ambiguous units for reactive power and energy (#161810) 2026-01-30 11:44:20 +00:00
victorigualada
d5aff326e3 Use OpenAI schema dataclasses for cloud stream responses (#161663) 2026-01-30 11:44:18 +00:00
Gage Benne
22f66abbe7 Bump pydexcom to 0.5.1 (#161549) 2026-01-30 11:44:16 +00:00
Mattia Monga
f635228b1f Make viaggiatreno work by fixing some critical bugs (#160093)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-01-30 11:44:14 +00:00
Artur Pragacz
4c708c143d Fix validation of actions config in intent_script (#158266) 2026-01-30 11:44:12 +00:00
Franck Nijhof
3369459d41 Bump version to 2026.2.0b0 2026-01-28 20:00:19 +00:00
43 changed files with 1389 additions and 560 deletions

View File

@@ -10,12 +10,12 @@ on:
env:
BUILD_TYPE: core
DEFAULT_PYTHON: "3.14.3"
DEFAULT_PYTHON: "3.14.2"
PIP_TIMEOUT: 60
UV_HTTP_TIMEOUT: 60
UV_SYSTEM_PYTHON: "true"
# Base image version from https://github.com/home-assistant/docker
BASE_IMAGE_VERSION: "2026.02.0"
BASE_IMAGE_VERSION: "2026.01.0"
ARCHITECTURES: '["amd64", "aarch64"]'
jobs:

View File

@@ -41,8 +41,8 @@ env:
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2026.3"
DEFAULT_PYTHON: "3.14.3"
ALL_PYTHON_VERSIONS: "['3.14.3']"
DEFAULT_PYTHON: "3.14.2"
ALL_PYTHON_VERSIONS: "['3.14.2']"
# 10.3 is the oldest supported version
# - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022)
# 10.6 is the current long-term-support

View File

@@ -10,7 +10,7 @@ on:
- "**strings.json"
env:
DEFAULT_PYTHON: "3.14.3"
DEFAULT_PYTHON: "3.14.2"
jobs:
upload:

View File

@@ -17,7 +17,7 @@ on:
- "script/gen_requirements_all.py"
env:
DEFAULT_PYTHON: "3.14.3"
DEFAULT_PYTHON: "3.14.2"
concurrency:
group: ${{ github.workflow }}-${{ github.ref_name}}

View File

@@ -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
View File

@@ -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

View File

@@ -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"
}
},

View File

@@ -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
}

View File

@@ -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"]
}

View File

@@ -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())

View File

@@ -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,
)

View 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)

View 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},
)

View File

@@ -0,0 +1,4 @@
"""Constants for the Redgtech integration."""
DOMAIN = "redgtech"
INTEGRATION_NAME = "Redgtech"

View 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

View 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"]
}

View 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

View 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"
}
}
}

View 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)

View File

@@ -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"

View File

@@ -14,5 +14,6 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pyvesync"],
"quality_scale": "bronze",
"requirements": ["pyvesync==3.4.1"]
}

View File

@@ -2865,53 +2865,3 @@ class ServiceRegistry:
if TYPE_CHECKING:
target = cast(Callable[..., ServiceResponse], target)
return await self._hass.async_add_executor_job(target, service_call)
# mypy: disable-error-code="attr-defined,no-untyped-def,unused-ignore,no-untyped-call"
# fmt: off
def _old_chain_future(source, destination):
"""Revert of https://github.com/python/cpython/pull/142358."""
from asyncio import futures # noqa: PLC0415
if not futures.isfuture(source) and not isinstance(
source, concurrent.futures.Future
):
raise TypeError("A future is required for source argument")
if not futures.isfuture(destination) and not isinstance(
destination, concurrent.futures.Future
):
raise TypeError("A future is required for destination argument")
source_loop = futures._get_loop(source) if futures.isfuture(source) else None # noqa: SLF001
dest_loop = (
futures._get_loop(destination) if futures.isfuture(destination) else None # noqa: SLF001
)
def _set_state(future, other):
if futures.isfuture(future):
futures._copy_future_state(other, future) # noqa: SLF001
else:
futures._set_concurrent_future_state(future, other) # noqa: SLF001
def _call_check_cancel(destination):
if destination.cancelled():
if source_loop is None or source_loop is dest_loop:
source.cancel()
else:
source_loop.call_soon_threadsafe(source.cancel)
def _call_set_state(source):
if destination.cancelled() and dest_loop is not None and dest_loop.is_closed():
return
if dest_loop is None or dest_loop is source_loop:
_set_state(destination, source)
else:
if dest_loop.is_closed():
return
dest_loop.call_soon_threadsafe(_set_state, destination, source)
destination.add_done_callback(_call_check_cancel)
source.add_done_callback(_call_set_state)
# monkey-patch asyncio to revert to 3.14.2 behavior
asyncio.futures._chain_future = _old_chain_future # noqa: SLF001

View File

@@ -570,6 +570,7 @@ FLOWS = {
"rapt_ble",
"rdw",
"recollect_waste",
"redgtech",
"refoss",
"rehlko",
"remote_calendar",

View File

@@ -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",

View File

@@ -37,7 +37,7 @@ 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

10
mypy.ini generated
View File

@@ -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

View File

@@ -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",

2
requirements.txt generated
View File

@@ -25,7 +25,7 @@ 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.1.28

7
requirements_all.txt generated
View File

@@ -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
@@ -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
@@ -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

View File

@@ -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
@@ -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
@@ -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

View File

@@ -2039,7 +2039,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"versasense",
"version",
"vicare",
"vesync",
"viaggiatreno",
"vilfo",
"vivotek",

View File

@@ -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")

View File

@@ -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

View File

@@ -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})

View File

@@ -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

View File

@@ -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"
)

View File

@@ -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

View 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

View File

@@ -0,0 +1 @@
"""Tests for the Redgtech component."""

View 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",
)

View 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',
})
# ---

View 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

View 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