Compare commits

..

4 Commits

Author SHA1 Message Date
Jan Čermák
7a1e378300 Sleep 1e-99s in async_block_till_done to workaround unfinished tasks 2026-02-05 18:08:07 +01:00
Jan Čermák
f1e1792e64 Revert "Try reverting changes from Python's gh-105836 patch"
This reverts commit 5de171f714.
2026-02-05 16:56:18 +01:00
Jan Čermák
5de171f714 Try reverting changes from Python's gh-105836 patch 2026-02-05 14:09:09 +01:00
Jan Čermák
6b07b2b8bc Bump base image to 2026.02.0 with Python 3.14.3, use 3.14.3 in CI
This also bumps libcec used in the base image to 7.1.1, full changelog:
* https://github.com/home-assistant/docker/releases/tag/2026.02.0

Python changelog:
* https://docs.python.org/release/3.14.3/whatsnew/changelog.html
2026-02-05 08:59:14 +01:00
69 changed files with 752 additions and 1451 deletions

View File

@@ -10,12 +10,12 @@ on:
env:
BUILD_TYPE: core
DEFAULT_PYTHON: "3.14.2"
DEFAULT_PYTHON: "3.14.3"
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.01.0"
BASE_IMAGE_VERSION: "2026.02.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.2"
ALL_PYTHON_VERSIONS: "['3.14.2']"
DEFAULT_PYTHON: "3.14.3"
ALL_PYTHON_VERSIONS: "['3.14.3']"
# 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.2"
DEFAULT_PYTHON: "3.14.3"
jobs:
upload:

View File

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

View File

@@ -435,7 +435,6 @@ 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,8 +1355,6 @@ 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

@@ -99,7 +99,7 @@ class AbodeLight(AbodeDevice, LightEntity):
return _hs
@property
def color_mode(self) -> ColorMode:
def color_mode(self) -> ColorMode | None:
"""Return the color mode of the light."""
if self._device.is_dimmable and self._device.is_color_capable:
if self.hs_color is not None:
@@ -110,7 +110,7 @@ class AbodeLight(AbodeDevice, LightEntity):
return ColorMode.ONOFF
@property
def supported_color_modes(self) -> set[ColorMode]:
def supported_color_modes(self) -> set[ColorMode] | None:
"""Flag supported color modes."""
if self._device.is_dimmable and self._device.is_color_capable:
return {ColorMode.COLOR_TEMP, ColorMode.HS}

View File

@@ -90,9 +90,6 @@
"cannot_retrieve_data_with_error": {
"message": "Error retrieving data: {error}"
},
"config_entry_not_found": {
"message": "Config entry not found: {device_id}"
},
"device_serial_number_missing": {
"message": "Device serial number missing: {device_id}"
},

View File

@@ -58,14 +58,14 @@
"name": "Enable motion detection"
},
"play_stream": {
"description": "Plays a camera stream on a supported media player.",
"description": "Plays the camera stream on a supported media player.",
"fields": {
"format": {
"description": "Stream format supported by the media player.",
"name": "Format"
},
"media_player": {
"description": "Media player to stream to.",
"description": "Media players 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.13.0", "openai==2.15.0"],
"requirements": ["hass-nabucasa==1.12.0", "openai==2.15.0"],
"single_config_entry": true
}

View File

@@ -196,46 +196,44 @@ class R2BackupAgent(BackupAgent):
)
upload_id = multipart_upload["UploadId"]
try:
parts: list[dict[str, Any]] = []
parts = []
part_number = 1
buffer = bytearray() # bytes buffer to store the data
buffer_size = 0 # bytes
buffer: list[bytes] = []
stream = await open_stream()
async for chunk in stream:
buffer.extend(chunk)
# upload parts of exactly MULTIPART_MIN_PART_SIZE_BYTES to ensure
# all non-trailing parts have the same size (required by S3/R2)
while len(buffer) >= MULTIPART_MIN_PART_SIZE_BYTES:
part_data = bytes(buffer[:MULTIPART_MIN_PART_SIZE_BYTES])
del buffer[:MULTIPART_MIN_PART_SIZE_BYTES]
buffer_size += len(chunk)
buffer.append(chunk)
# If buffer size meets minimum part size, upload it as a part
if buffer_size >= MULTIPART_MIN_PART_SIZE_BYTES:
_LOGGER.debug(
"Uploading part number %d, size %d",
part_number,
len(part_data),
"Uploading part number %d, size %d", part_number, buffer_size
)
part = await self._client.upload_part(
Bucket=self._bucket,
Key=self._with_prefix(tar_filename),
PartNumber=part_number,
UploadId=upload_id,
Body=part_data,
Body=b"".join(buffer),
)
parts.append({"PartNumber": part_number, "ETag": part["ETag"]})
part_number += 1
buffer_size = 0
buffer = []
# Upload the final buffer as the last part (no minimum size requirement)
if buffer:
_LOGGER.debug(
"Uploading final part number %d, size %d", part_number, len(buffer)
"Uploading final part number %d, size %d", part_number, buffer_size
)
part = await self._client.upload_part(
Bucket=self._bucket,
Key=self._with_prefix(tar_filename),
PartNumber=part_number,
UploadId=upload_id,
Body=bytes(buffer),
Body=b"".join(buffer),
)
parts.append({"PartNumber": part_number, "ETag": part["ETag"]})

View File

@@ -46,6 +46,7 @@ async def async_setup_entry(
async_add_entities(
[
DemoLight(
available=True,
effect_list=LIGHT_EFFECT_LIST,
effect=LIGHT_EFFECT_LIST[0],
translation_key="bed_light",
@@ -54,18 +55,21 @@ async def async_setup_entry(
unique_id="light_1",
),
DemoLight(
available=True,
ct=LIGHT_TEMPS[1],
device_name="Ceiling Lights",
state=True,
unique_id="light_2",
),
DemoLight(
available=True,
hs_color=LIGHT_COLORS[1],
device_name="Kitchen Lights",
state=True,
unique_id="light_3",
),
DemoLight(
available=True,
ct=LIGHT_TEMPS[1],
device_name="Office RGBW Lights",
rgbw_color=(255, 0, 0, 255),
@@ -74,6 +78,7 @@ async def async_setup_entry(
unique_id="light_4",
),
DemoLight(
available=True,
device_name="Living Room RGBWW Lights",
rgbww_color=(255, 0, 0, 255, 0),
state=True,
@@ -81,6 +86,7 @@ async def async_setup_entry(
unique_id="light_5",
),
DemoLight(
available=True,
device_name="Entrance Color + White Lights",
hs_color=LIGHT_COLORS[1],
state=True,
@@ -106,6 +112,7 @@ class DemoLight(LightEntity):
unique_id: str,
device_name: str,
state: bool,
available: bool = False,
brightness: int = 180,
ct: int | None = None,
effect_list: list[str] | None = None,
@@ -118,72 +125,128 @@ class DemoLight(LightEntity):
) -> None:
"""Initialize the light."""
self._attr_translation_key = translation_key
self._attr_brightness = brightness
self._attr_color_temp_kelvin = ct or random.choice(LIGHT_TEMPS)
self._attr_effect = effect
self._attr_effect_list = effect_list
self._attr_hs_color = hs_color
self._attr_rgbw_color = rgbw_color
self._attr_rgbww_color = rgbww_color
self._attr_is_on = state
self._attr_unique_id = unique_id
self._available = True
self._brightness = brightness
self._ct = ct or random.choice(LIGHT_TEMPS)
self._effect = effect
self._effect_list = effect_list
self._hs_color = hs_color
self._rgbw_color = rgbw_color
self._rgbww_color = rgbww_color
self._state = state
self._unique_id = unique_id
if hs_color:
self._attr_color_mode = ColorMode.HS
self._color_mode = ColorMode.HS
elif rgbw_color:
self._attr_color_mode = ColorMode.RGBW
self._color_mode = ColorMode.RGBW
elif rgbww_color:
self._attr_color_mode = ColorMode.RGBWW
self._color_mode = ColorMode.RGBWW
else:
self._attr_color_mode = ColorMode.COLOR_TEMP
self._color_mode = ColorMode.COLOR_TEMP
if not supported_color_modes:
supported_color_modes = SUPPORT_DEMO
self._attr_supported_color_modes = supported_color_modes
if self._attr_effect_list is not None:
self._color_modes = supported_color_modes
if self._effect_list is not None:
self._attr_supported_features |= LightEntityFeature.EFFECT
self._attr_device_info = DeviceInfo(
identifiers={
# Serial numbers are unique identifiers within a specific domain
(DOMAIN, unique_id)
(DOMAIN, self.unique_id)
},
name=device_name,
)
@property
def unique_id(self) -> str:
"""Return unique ID for light."""
return self._unique_id
@property
def available(self) -> bool:
"""Return availability."""
# This demo light is always available, but well-behaving components
# should implement this to inform Home Assistant accordingly.
return True
return self._available
@property
def brightness(self) -> int:
"""Return the brightness of this light between 0..255."""
return self._brightness
@property
def color_mode(self) -> ColorMode | None:
"""Return the color mode of the light."""
return self._color_mode
@property
def hs_color(self) -> tuple[int, int] | None:
"""Return the hs color value."""
return self._hs_color
@property
def rgbw_color(self) -> tuple[int, int, int, int] | None:
"""Return the rgbw color value."""
return self._rgbw_color
@property
def rgbww_color(self) -> tuple[int, int, int, int, int] | None:
"""Return the rgbww color value."""
return self._rgbww_color
@property
def color_temp_kelvin(self) -> int | None:
"""Return the color temperature value in Kelvin."""
return self._ct
@property
def effect_list(self) -> list[str] | None:
"""Return the list of supported effects."""
return self._effect_list
@property
def effect(self) -> str | None:
"""Return the current effect."""
return self._effect
@property
def is_on(self) -> bool:
"""Return true if light is on."""
return self._state
@property
def supported_color_modes(self) -> set[ColorMode]:
"""Flag supported color modes."""
return self._color_modes
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
self._attr_is_on = True
self._state = True
if ATTR_BRIGHTNESS in kwargs:
self._attr_brightness = kwargs[ATTR_BRIGHTNESS]
self._brightness = kwargs[ATTR_BRIGHTNESS]
if ATTR_COLOR_TEMP_KELVIN in kwargs:
self._attr_color_mode = ColorMode.COLOR_TEMP
self._attr_color_temp_kelvin = kwargs[ATTR_COLOR_TEMP_KELVIN]
self._color_mode = ColorMode.COLOR_TEMP
self._ct = kwargs[ATTR_COLOR_TEMP_KELVIN]
if ATTR_EFFECT in kwargs:
self._attr_effect = kwargs[ATTR_EFFECT]
self._effect = kwargs[ATTR_EFFECT]
if ATTR_HS_COLOR in kwargs:
self._attr_color_mode = ColorMode.HS
self._attr_hs_color = kwargs[ATTR_HS_COLOR]
self._color_mode = ColorMode.HS
self._hs_color = kwargs[ATTR_HS_COLOR]
if ATTR_RGBW_COLOR in kwargs:
self._attr_color_mode = ColorMode.RGBW
self._attr_rgbw_color = kwargs[ATTR_RGBW_COLOR]
self._color_mode = ColorMode.RGBW
self._rgbw_color = kwargs[ATTR_RGBW_COLOR]
if ATTR_RGBWW_COLOR in kwargs:
self._attr_color_mode = ColorMode.RGBWW
self._attr_rgbww_color = kwargs[ATTR_RGBWW_COLOR]
self._color_mode = ColorMode.RGBWW
self._rgbww_color = kwargs[ATTR_RGBWW_COLOR]
if ATTR_WHITE in kwargs:
self._attr_color_mode = ColorMode.WHITE
self._attr_brightness = kwargs[ATTR_WHITE]
self._color_mode = ColorMode.WHITE
self._brightness = kwargs[ATTR_WHITE]
# As we have disabled polling, we need to inform
# Home Assistant about updates in our state ourselves.
@@ -191,7 +254,7 @@ class DemoLight(LightEntity):
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
self._attr_is_on = False
self._state = False
# As we have disabled polling, we need to inform
# Home Assistant about updates in our state ourselves.

View File

@@ -7,7 +7,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["denonavr"],
"requirements": ["denonavr==1.3.2"],
"requirements": ["denonavr==1.3.1"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",

View File

@@ -17,7 +17,6 @@ from denonavr.const import (
STATE_ON,
STATE_PAUSED,
STATE_PLAYING,
STATE_STOPPED,
)
from denonavr.exceptions import (
AvrCommandError,
@@ -70,7 +69,6 @@ SUPPORT_MEDIA_MODES = (
| MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.STOP
)
SCAN_INTERVAL = timedelta(seconds=10)
@@ -98,7 +96,6 @@ DENON_STATE_MAPPING = {
STATE_OFF: MediaPlayerState.OFF,
STATE_PLAYING: MediaPlayerState.PLAYING,
STATE_PAUSED: MediaPlayerState.PAUSED,
STATE_STOPPED: MediaPlayerState.IDLE,
}
@@ -407,11 +404,6 @@ class DenonDevice(MediaPlayerEntity):
"""Send pause command."""
await self._receiver.async_pause()
@async_log_errors
async def async_media_stop(self) -> None:
"""Send stop command."""
await self._receiver.async_stop()
@async_log_errors
async def async_media_previous_track(self) -> None:
"""Send previous track command."""

View File

@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["pyenphase"],
"quality_scale": "platinum",
"requirements": ["pyenphase==2.4.5"],
"requirements": ["pyenphase==2.4.3"],
"zeroconf": [
{
"type": "_enphase-envoy._tcp.local."

View File

@@ -299,6 +299,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
return round(self._state.brightness * 255)
@property
@esphome_state_property
def color_mode(self) -> ColorMode:
"""Return the color mode of the light."""
if not self._supports_color_mode:

View File

@@ -4,7 +4,7 @@
"codeowners": ["@zxdavb"],
"documentation": "https://www.home-assistant.io/integrations/evohome",
"iot_class": "cloud_polling",
"loggers": ["evohomeasync", "evohomeasync2"],
"loggers": ["evohome", "evohomeasync", "evohomeasync2"],
"quality_scale": "legacy",
"requirements": ["evohome-async==1.1.3"]
"requirements": ["evohome-async==1.0.6"]
}

View File

@@ -39,7 +39,6 @@ from .valve import async_create_preview_valve
_STATISTIC_MEASURES = [
"last",
"first_available",
"max",
"mean",
"median",

View File

@@ -68,8 +68,6 @@ ATTR_MEAN = "mean"
ATTR_MEDIAN = "median"
ATTR_LAST = "last"
ATTR_LAST_ENTITY_ID = "last_entity_id"
ATTR_FIRST_AVAILABLE = "first_available"
ATTR_FIRST_AVAILABLE_ENTITY_ID = "first_available_entity_id"
ATTR_RANGE = "range"
ATTR_STDEV = "stdev"
ATTR_SUM = "sum"
@@ -80,7 +78,6 @@ SENSOR_TYPES = {
ATTR_MEAN: "mean",
ATTR_MEDIAN: "median",
ATTR_LAST: "last",
ATTR_FIRST_AVAILABLE: "first_available",
ATTR_RANGE: "range",
ATTR_STDEV: "stdev",
ATTR_SUM: "sum",
@@ -258,19 +255,6 @@ def calc_last(
return attributes, last
def calc_first_available(
sensor_values: list[tuple[str, float, State]],
) -> tuple[dict[str, str | None], float | None]:
"""Calculate first available value."""
first_available_entity_id: str | None = None
first_available: float | None = None
if sensor_values:
first_available_entity_id, first_available, _ = sensor_values[0]
attributes = {ATTR_FIRST_AVAILABLE_ENTITY_ID: first_available_entity_id}
return attributes, first_available
def calc_range(
sensor_values: list[tuple[str, float, State]],
) -> tuple[dict[str, str | None], float]:
@@ -325,7 +309,6 @@ CALC_TYPES: dict[
"mean": calc_mean,
"median": calc_median,
"last": calc_last,
"first_available": calc_first_available,
"range": calc_range,
"stdev": calc_stdev,
"sum": calc_sum,

View File

@@ -280,7 +280,6 @@
"selector": {
"type": {
"options": {
"first_available": "First available",
"last": "Most recently updated",
"max": "Maximum",
"mean": "Arithmetic mean",

View File

@@ -10,5 +10,5 @@
"iot_class": "local_polling",
"loggers": ["pypck"],
"quality_scale": "silver",
"requirements": ["pypck==0.9.10", "lcn-frontend==0.2.7"]
"requirements": ["pypck==0.9.9", "lcn-frontend==0.2.7"]
}

View File

@@ -5,9 +5,10 @@ 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, Self, cast, final
from typing import TYPE_CHECKING, Any, Final, Self, cast, final
from propcache.api import cached_property
import voluptuous as vol
@@ -22,6 +23,13 @@ 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
@@ -48,6 +56,27 @@ 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
@@ -262,7 +291,7 @@ def filter_turn_off_params(
if not params:
return params
supported_features = light.supported_features
supported_features = light.supported_features_compat
if LightEntityFeature.FLASH not in supported_features:
params.pop(ATTR_FLASH, None)
@@ -274,7 +303,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
supported_features = light.supported_features_compat
if LightEntityFeature.EFFECT not in supported_features:
params.pop(ATTR_EFFECT, None)
@@ -779,7 +808,7 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
entity_description: LightEntityDescription
_attr_brightness: int | None = None
_attr_color_mode: ColorMode = ColorMode.UNKNOWN
_attr_color_mode: ColorMode | None = None
_attr_color_temp_kelvin: int | None = None
_attr_effect_list: list[str] | None = None
_attr_effect: str | None = None
@@ -801,10 +830,43 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
return self._attr_brightness
@cached_property
def color_mode(self) -> ColorMode:
def color_mode(self) -> ColorMode | None:
"""Return the color mode of the light."""
return self._attr_color_mode
@property
def _light_internal_color_mode(self) -> str:
"""Return the color mode of the light with backwards compatibility."""
if (color_mode := self.color_mode) is None:
# Backwards compatibility for color_mode added in 2021.4
# Warning added in 2024.3, break in 2025.3
if not self.__color_mode_reported and self.__should_report_light_issue():
self.__color_mode_reported = True
report_issue = self._suggest_report_issue()
_LOGGER.warning(
(
"%s (%s) does not report a color mode, this will stop working "
"in Home Assistant Core 2025.3, please %s"
),
self.entity_id,
type(self),
report_issue,
)
supported = self._light_internal_supported_color_modes
if ColorMode.HS in supported and self.hs_color is not None:
return ColorMode.HS
if ColorMode.COLOR_TEMP in supported and self.color_temp_kelvin is not None:
return ColorMode.COLOR_TEMP
if ColorMode.BRIGHTNESS in supported and self.brightness is not None:
return ColorMode.BRIGHTNESS
if ColorMode.ONOFF in supported:
return ColorMode.ONOFF
return ColorMode.UNKNOWN
return color_mode
@cached_property
def hs_color(self) -> tuple[float, float] | None:
"""Return the hue and saturation color value [float, float]."""
@@ -894,7 +956,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
supported_features = self.supported_features_compat
supported_color_modes = self._light_internal_supported_color_modes
if ColorMode.COLOR_TEMP in supported_color_modes:
@@ -1044,14 +1106,14 @@ 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
supported_features = self.supported_features_compat
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.color_mode if _is_on else None
color_mode = self._light_internal_color_mode if _is_on else None
effect: str | None
if LightEntityFeature.EFFECT in supported_features:
@@ -1068,12 +1130,26 @@ 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
@@ -1111,8 +1187,24 @@ 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()
return {ColorMode.ONOFF}
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
@cached_property
def supported_color_modes(self) -> set[ColorMode] | None:
@@ -1124,9 +1216,48 @@ 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

@@ -221,10 +221,8 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
# Brightness is supported and no supported_color_modes are set,
# so set brightness as the supported color mode.
self._attr_supported_color_modes = {ColorMode.BRIGHTNESS}
self._attr_color_mode = ColorMode.BRIGHTNESS
else:
self._attr_supported_color_modes = {ColorMode.ONOFF}
self._attr_color_mode = ColorMode.ONOFF
def _update_color(self, values: dict[str, Any]) -> None:
color_mode: str = values["color_mode"]

View File

@@ -274,7 +274,6 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity):
if color_temp_value != "None"
else None
)
self._update_color_mode()
except ValueError:
_LOGGER.warning(
"Invalid color temperature value '%s' received from %s",

View File

@@ -85,7 +85,7 @@ class NanoleafLight(NanoleafEntity, LightEntity):
return self._nanoleaf.hue, self._nanoleaf.saturation
@property
def color_mode(self) -> ColorMode:
def color_mode(self) -> ColorMode | None:
"""Return the color mode of the light."""
# According to API docs, color mode is "ct", "effect" or "hs"
# https://forum.nanoleaf.me/docs/openapi#_4qgqrz96f44d

View File

@@ -1,35 +0,0 @@
"""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

@@ -1,67 +0,0 @@
"""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

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

View File

@@ -1,130 +0,0 @@
"""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

@@ -1,11 +0,0 @@
{
"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

@@ -1,72 +0,0 @@
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

@@ -1,40 +0,0 @@
{
"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

@@ -1,95 +0,0 @@
"""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

@@ -104,6 +104,7 @@ class RpcLinkedgoThermostatClimate(ShellyRpcAttributeEntity, ClimateEntity):
)
config = coordinator.device.config
self._status = coordinator.device.status
self._attr_min_temp = config[key]["min"]
self._attr_max_temp = config[key]["max"]
@@ -141,11 +142,6 @@ class RpcLinkedgoThermostatClimate(ShellyRpcAttributeEntity, ClimateEntity):
THERMOSTAT_TO_HA_MODE[mode] for mode in modes
]
@property
def _status(self) -> dict[str, Any]:
"""Return the full device status."""
return self.coordinator.device.status
@property
def current_humidity(self) -> float | None:
"""Return the current humidity."""

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_push",
"loggers": ["pysmarlaapi", "pysignalr"],
"quality_scale": "bronze",
"requirements": ["pysmarlaapi==0.13.0"]
"requirements": ["pysmarlaapi==0.9.3"]
}

View File

@@ -74,7 +74,7 @@ class SwitchbotLightEntity(SwitchbotEntity, LightEntity):
return max(0, min(255, round(self._device.brightness * 2.55)))
@property
def color_mode(self) -> ColorMode:
def color_mode(self) -> ColorMode | None:
"""Return the color mode of the light."""
return SWITCHBOT_COLOR_MODE_TO_HASS.get(
self._device.color_mode, ColorMode.UNKNOWN

View File

@@ -195,9 +195,9 @@ class TasmotaLight(
return self._brightness
@property
def color_mode(self) -> ColorMode:
def color_mode(self) -> ColorMode | None:
"""Return the color mode of the light."""
return self._color_mode # type: ignore[return-value]
return self._color_mode
@property
def color_temp_kelvin(self) -> int | None:

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
import voluptuous as vol
@@ -53,11 +54,23 @@ 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

@@ -293,6 +293,7 @@ class AbstractTemplateLight(AbstractTemplateEntity, LightEntity):
# Stored values for template attributes
self._attr_is_on = initial_state
self._supports_transition = False
self._attr_color_mode: ColorMode | None = None
def _setup_light_features(self, config: ConfigType, name: str) -> None:
"""Setup light scripts, supported color modes, and supported features."""

View File

@@ -110,7 +110,7 @@ class TradfriLight(TradfriBaseEntity, LightEntity):
return cast(bool, self._device_data.state)
@property
def color_mode(self) -> ColorMode:
def color_mode(self) -> ColorMode | None:
"""Return the color mode of the light."""
if self._fixed_color_mode:
return self._fixed_color_mode

View File

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

View File

@@ -157,10 +157,10 @@ class Light(LightEntity, ZHAEntity):
)
@property
def color_mode(self) -> ColorMode:
def color_mode(self) -> ColorMode | None:
"""Return the color mode."""
if self.entity_data.entity.color_mode is None:
return ColorMode.UNKNOWN
return None
return ZHA_TO_HA_COLOR_MODE[self.entity_data.entity.color_mode]
@property

View File

@@ -120,6 +120,10 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
self._supports_rgbw = False
self._supports_color_temp = False
self._supports_dimming = False
self._color_mode: ColorMode | None = None
self._hs_color: tuple[float, float] | None = None
self._rgbw_color: tuple[int, int, int, int] | None = None
self._color_temp: int | None = None
self._warm_white = self.get_zwave_value(
TARGET_COLOR_PROPERTY,
CommandClass.SWITCH_COLOR,
@@ -130,6 +134,7 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
CommandClass.SWITCH_COLOR,
value_property_key=ColorComponent.COLD_WHITE,
)
self._supported_color_modes: set[ColorMode] = set()
self._target_brightness: Value | None = None
@@ -175,18 +180,17 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
)
self._calculate_color_support()
self._attr_supported_color_modes = set()
if self._supports_rgbw:
self._attr_supported_color_modes.add(ColorMode.RGBW)
self._supported_color_modes.add(ColorMode.RGBW)
elif self._supports_color:
self._attr_supported_color_modes.add(ColorMode.HS)
self._supported_color_modes.add(ColorMode.HS)
if self._supports_color_temp:
self._attr_supported_color_modes.add(ColorMode.COLOR_TEMP)
if not self._attr_supported_color_modes:
self._supported_color_modes.add(ColorMode.COLOR_TEMP)
if not self._supported_color_modes:
if self.info.primary_value.command_class == CommandClass.SWITCH_BINARY:
self._attr_supported_color_modes.add(ColorMode.ONOFF)
self._supported_color_modes.add(ColorMode.ONOFF)
else:
self._attr_supported_color_modes.add(ColorMode.BRIGHTNESS)
self._supported_color_modes.add(ColorMode.BRIGHTNESS)
self._calculate_color_values()
# Entity class attributes
@@ -221,6 +225,11 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
return None
return round((cast(int, self.info.primary_value.value) / 99) * 255)
@property
def color_mode(self) -> ColorMode | None:
"""Return the color mode of the light."""
return self._color_mode
@property
def is_on(self) -> bool | None:
"""Return true if device is on (brightness above 0)."""
@@ -230,6 +239,26 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
brightness = self.brightness
return brightness > 0 if brightness is not None else None
@property
def hs_color(self) -> tuple[float, float] | None:
"""Return the hs color."""
return self._hs_color
@property
def rgbw_color(self) -> tuple[int, int, int, int] | None:
"""Return the RGBW color."""
return self._rgbw_color
@property
def color_temp_kelvin(self) -> int | None:
"""Return the color temperature value in Kelvin."""
return self._color_temp
@property
def supported_color_modes(self) -> set[ColorMode] | None:
"""Flag supported features."""
return self._supported_color_modes
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the device on."""
@@ -450,9 +479,9 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
# Default: Brightness (no color) or Unknown
if self.supported_color_modes == {ColorMode.BRIGHTNESS}:
self._attr_color_mode = ColorMode.BRIGHTNESS
self._color_mode = ColorMode.BRIGHTNESS
else:
self._attr_color_mode = ColorMode.UNKNOWN
self._color_mode = ColorMode.UNKNOWN
# RGB support
if red_val and green_val and blue_val:
@@ -462,9 +491,9 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
blue = multi_color.get(COLOR_SWITCH_COMBINED_BLUE, blue_val.value)
if red is not None and green is not None and blue is not None:
# convert to HS
self._attr_hs_color = color_util.color_RGB_to_hs(red, green, blue)
self._hs_color = color_util.color_RGB_to_hs(red, green, blue)
# Light supports color, set color mode to hs
self._attr_color_mode = ColorMode.HS
self._color_mode = ColorMode.HS
# color temperature support
if ww_val and cw_val:
@@ -472,16 +501,14 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
cold_white = multi_color.get(COLOR_SWITCH_COMBINED_COLD_WHITE, cw_val.value)
# Calculate color temps based on whites
if cold_white or warm_white:
self._attr_color_temp_kelvin = (
color_util.color_temperature_mired_to_kelvin(
MAX_MIREDS
- ((cast(int, cold_white) / 255) * (MAX_MIREDS - MIN_MIREDS))
)
self._color_temp = color_util.color_temperature_mired_to_kelvin(
MAX_MIREDS
- ((cast(int, cold_white) / 255) * (MAX_MIREDS - MIN_MIREDS))
)
# White channels turned on, set color mode to color_temp
self._attr_color_mode = ColorMode.COLOR_TEMP
self._color_mode = ColorMode.COLOR_TEMP
else:
self._attr_color_temp_kelvin = None
self._color_temp = None
# only one white channel (warm white) = rgbw support
elif red_val and green_val and blue_val and ww_val:
white = multi_color.get(COLOR_SWITCH_COMBINED_WARM_WHITE, ww_val.value)
@@ -492,9 +519,9 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
and blue is not None
and white is not None
)
self._attr_rgbw_color = (red, green, blue, white)
self._rgbw_color = (red, green, blue, white)
# Light supports rgbw, set color mode to rgbw
self._attr_color_mode = ColorMode.RGBW
self._color_mode = ColorMode.RGBW
# only one white channel (cool white) = rgbw support
elif cw_val:
self._supports_rgbw = True
@@ -506,9 +533,9 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
and blue is not None
and white is not None
)
self._attr_rgbw_color = (red, green, blue, white)
self._rgbw_color = (red, green, blue, white)
# Light supports rgbw, set color mode to rgbw
self._attr_color_mode = ColorMode.RGBW
self._color_mode = ColorMode.RGBW
class ZwaveColorOnOffLight(ZwaveLight):
@@ -593,8 +620,8 @@ class ZwaveColorOnOffLight(ZwaveLight):
new_colors = {}
for color, value in self._last_on_color.items():
new_colors[color] = round(value * new_scale)
elif hs_color is None and self._attr_color_mode == ColorMode.HS:
hs_color = self._attr_hs_color
elif hs_color is None and self._color_mode == ColorMode.HS:
hs_color = self._hs_color
elif hs_color is not None and brightness is None:
# Turned on by using the color controls
current_brightness = self.brightness

View File

@@ -962,7 +962,7 @@ class HomeAssistant:
async def async_block_till_done(self, wait_background_tasks: bool = False) -> None:
"""Block until all pending work is done."""
# To flush out any call_soon_threadsafe
await asyncio.sleep(0)
await asyncio.sleep(1e-99)
start_time: float | None = None
current_task = asyncio.current_task()
while tasks := [

View File

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

View File

@@ -5583,12 +5583,6 @@
"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.13.0
hass-nabucasa==1.12.0
hassil==3.5.0
home-assistant-bluetooth==1.13.1
home-assistant-frontend==20260128.6

10
mypy.ini generated
View File

@@ -4106,16 +4106,6 @@ 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

@@ -1837,7 +1837,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = {
),
TypeHintMatch(
function_name="color_mode",
return_type=["ColorMode"],
return_type=["ColorMode", None],
mandatory=True,
),
TypeHintMatch(

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.13.0",
"hass-nabucasa==1.12.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.13.0
hass-nabucasa==1.12.0
hassil==3.5.0
home-assistant-bluetooth==1.13.1
home-assistant-intents==2026.1.28

15
requirements_all.txt generated
View File

@@ -797,7 +797,7 @@ deluge-client==1.10.2
demetriek==1.3.0
# homeassistant.components.denonavr
denonavr==1.3.2
denonavr==1.3.1
# homeassistant.components.devialet
devialet==1.5.7
@@ -938,7 +938,7 @@ eufylife-ble-client==0.1.8
# evdev==1.6.1
# homeassistant.components.evohome
evohome-async==1.1.3
evohome-async==1.0.6
# 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.13.0
hass-nabucasa==1.12.0
# homeassistant.components.splunk
hass-splunk==0.1.1
@@ -2032,7 +2032,7 @@ pyegps==0.2.5
pyemoncms==0.1.3
# homeassistant.components.enphase_envoy
pyenphase==2.4.5
pyenphase==2.4.3
# homeassistant.components.envisalink
pyenvisalink==4.7
@@ -2319,7 +2319,7 @@ pypaperless==4.1.1
pypca==0.0.7
# homeassistant.components.lcn
pypck==0.9.10
pypck==0.9.9
# homeassistant.components.pglab
pypglab==0.0.5
@@ -2434,7 +2434,7 @@ pysma==1.1.0
pysmappee==0.2.29
# homeassistant.components.smarla
pysmarlaapi==0.13.0
pysmarlaapi==0.9.3
# homeassistant.components.smartthings
pysmartthings==3.5.1
@@ -2744,9 +2744,6 @@ 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

@@ -706,7 +706,7 @@ deluge-client==1.10.2
demetriek==1.3.0
# homeassistant.components.denonavr
denonavr==1.3.2
denonavr==1.3.1
# homeassistant.components.devialet
devialet==1.5.7
@@ -826,7 +826,7 @@ eternalegypt==0.0.18
eufylife-ble-client==0.1.8
# homeassistant.components.evohome
evohome-async==1.1.3
evohome-async==1.0.6
# 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.13.0
hass-nabucasa==1.12.0
# homeassistant.components.assist_satellite
# homeassistant.components.conversation
@@ -1730,7 +1730,7 @@ pyegps==0.2.5
pyemoncms==0.1.3
# homeassistant.components.enphase_envoy
pyenphase==2.4.5
pyenphase==2.4.3
# homeassistant.components.everlights
pyeverlights==0.1.0
@@ -1966,7 +1966,7 @@ pypalazzetti==0.1.20
pypaperless==4.1.1
# homeassistant.components.lcn
pypck==0.9.10
pypck==0.9.9
# homeassistant.components.pglab
pypglab==0.0.5
@@ -2060,7 +2060,7 @@ pysma==1.1.0
pysmappee==0.2.29
# homeassistant.components.smarla
pysmarlaapi==0.13.0
pysmarlaapi==0.9.3
# homeassistant.components.smartthings
pysmartthings==3.5.1
@@ -2310,9 +2310,6 @@ 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,6 +2039,7 @@ INTEGRATIONS_WITHOUT_SCALE = [
"versasense",
"version",
"vicare",
"vesync",
"viaggiatreno",
"vilfo",
"vivotek",

View File

@@ -188,69 +188,3 @@ async def test_config_entry_not_loaded(
assert exc_info.value.translation_domain == DOMAIN
assert exc_info.value.translation_key == "entry_not_loaded"
assert exc_info.value.translation_placeholders == {"entry": mock_config_entry.title}
async def test_invalid_config_entry(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_amazon_devices_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test invalid config entry."""
await setup_integration(hass, mock_config_entry)
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, TEST_DEVICE_1_SN)}
)
assert device_entry
device_entry.config_entries.add("non_existing_entry_id")
await hass.async_block_till_done()
# Call Service
await hass.services.async_call(
DOMAIN,
SERVICE_SOUND_NOTIFICATION,
{
ATTR_SOUND: "bell_02",
ATTR_DEVICE_ID: device_entry.id,
},
blocking=True,
)
# No exception should be raised
assert mock_amazon_devices_client.call_alexa_sound.call_count == 1
async def test_missing_config_entry(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_amazon_devices_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test missing config entry."""
await setup_integration(hass, mock_config_entry)
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, TEST_DEVICE_1_SN)}
)
assert device_entry
device_entry.config_entries.clear()
# Call Service
with pytest.raises(ServiceValidationError) as exc_info:
await hass.services.async_call(
DOMAIN,
SERVICE_SOUND_NOTIFICATION,
{
ATTR_SOUND: "bell_02",
ATTR_DEVICE_ID: device_entry.id,
},
blocking=True,
)
assert exc_info.value.translation_domain == DOMAIN
assert exc_info.value.translation_key == "config_entry_not_found"
assert exc_info.value.translation_placeholders == {"device_id": device_entry.id}

View File

@@ -367,57 +367,6 @@ async def test_agents_upload_network_failure(
assert "Upload failed for cloudflare_r2" in caplog.text
async def test_multipart_upload_consistent_part_sizes(
hass: HomeAssistant,
mock_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that multipart upload uses consistent part sizes.
S3/R2 requires all non-trailing parts to have the same size. This test
verifies that varying chunk sizes still result in consistent part sizes.
"""
agent = R2BackupAgent(hass, mock_config_entry)
# simulate varying chunk data sizes
# total data: 12 + 12 + 10 + 12 + 5 = 51 MiB
chunk_sizes = [12, 12, 10, 12, 5] # in units of 1 MiB
mib = 2**20
async def mock_stream():
for size in chunk_sizes:
yield b"x" * (size * mib)
async def open_stream():
return mock_stream()
# Record the sizes of each uploaded part
uploaded_part_sizes: list[int] = []
async def record_upload_part(**kwargs):
body = kwargs.get("Body", b"")
uploaded_part_sizes.append(len(body))
return {"ETag": f"etag-{len(uploaded_part_sizes)}"}
mock_client.upload_part.side_effect = record_upload_part
await agent._upload_multipart("test.tar", open_stream)
# Verify that all non-trailing parts have the same size
assert len(uploaded_part_sizes) >= 2, "Expected at least 2 parts"
non_trailing_parts = uploaded_part_sizes[:-1]
assert all(size == MULTIPART_MIN_PART_SIZE_BYTES for size in non_trailing_parts), (
f"All non-trailing parts should be {MULTIPART_MIN_PART_SIZE_BYTES} bytes, got {non_trailing_parts}"
)
# Verify the trailing part contains the remainder
total_data = sum(chunk_sizes) * mib
expected_trailing = total_data % MULTIPART_MIN_PART_SIZE_BYTES
if expected_trailing == 0:
expected_trailing = MULTIPART_MIN_PART_SIZE_BYTES
assert uploaded_part_sizes[-1] == expected_trailing
async def test_agents_download(
hass_client: ClientSessionGenerator,
mock_client: MagicMock,

View File

@@ -814,9 +814,7 @@ 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.LightEntityFeature.TRANSITION
)
supported_features = attributes[ATTR_SUPPORTED_FEATURES] | light.SUPPORT_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,9 +31,13 @@ _MSG_USR = (
"special characters accepted via the vendor's website are not valid here."
)
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_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_FAIL_CONNECTION = (
"homeassistant.components.evohome",
@@ -106,10 +110,10 @@ EXC_BAD_GATEWAY = aiohttp.ClientResponseError(
)
AUTHENTICATION_TESTS: dict[Exception, list] = {
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],
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],
}
CLIENT_REQUEST_TESTS: dict[Exception, list] = {
@@ -133,8 +137,7 @@ 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),
):
@@ -162,7 +165,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

@@ -12,7 +12,6 @@ import pytest
from homeassistant import config as hass_config
from homeassistant.components.group import DOMAIN
from homeassistant.components.group.sensor import (
ATTR_FIRST_AVAILABLE_ENTITY_ID,
ATTR_LAST_ENTITY_ID,
ATTR_MAX_ENTITY_ID,
ATTR_MIN_ENTITY_ID,
@@ -87,11 +86,6 @@ def set_or_remove_state(
("mean", MEAN, {}),
("median", MEDIAN, {}),
("last", VALUES[2], {ATTR_LAST_ENTITY_ID: "sensor.test_3"}),
(
"first_available",
VALUES[0],
{ATTR_FIRST_AVAILABLE_ENTITY_ID: "sensor.test_1"},
),
("range", RANGE, {}),
("stdev", STDEV, {}),
("sum", SUM_VALUE, {}),
@@ -867,62 +861,6 @@ async def test_last_sensor(hass: HomeAssistant) -> None:
assert state.attributes.get("last_entity_id") == entity_id
async def test_first_available_sensor(hass: HomeAssistant) -> None:
"""Test the first available sensor."""
config = {
SENSOR_DOMAIN: {
"platform": DOMAIN,
"name": "test_first_available",
"type": "first_available",
"entities": ["sensor.test_1", "sensor.test_2", "sensor.test_3"],
"unique_id": "very_unique_id_first_available_sensor",
"ignore_non_numeric": True,
}
}
assert await async_setup_component(hass, "sensor", config)
await hass.async_block_till_done()
entity_ids = config["sensor"]["entities"]
# Ensure that while sensor states are being set
# the group will always point to the first available sensor.
for entity_id, value in dict(zip(entity_ids, VALUES, strict=False)).items():
hass.states.async_set(entity_id, value)
await hass.async_block_till_done()
state = hass.states.get("sensor.test_first_available")
assert str(float(VALUES[0])) == state.state
assert entity_ids[0] == state.attributes.get("first_available_entity_id")
# If the second sensor of the group becomes unavailable
# then the first one should still be taken.
hass.states.async_set(entity_ids[1], STATE_UNAVAILABLE)
await hass.async_block_till_done()
state = hass.states.get("sensor.test_first_available")
assert str(float(VALUES[0])) == state.state
assert entity_ids[0] == state.attributes.get("first_available_entity_id")
# If the first sensor of the group becomes now unavailable
# then the third one should be taken.
hass.states.async_set(entity_ids[0], STATE_UNAVAILABLE)
await hass.async_block_till_done()
state = hass.states.get("sensor.test_first_available")
assert str(float(VALUES[2])) == state.state
assert entity_ids[2] == state.attributes.get("first_available_entity_id")
# If all sensors of the group become unavailable
# then the group should also be unavailable.
hass.states.async_set(entity_ids[2], STATE_UNAVAILABLE)
await hass.async_block_till_done()
state = hass.states.get("sensor.test_first_available")
assert state.state == STATE_UNAVAILABLE
assert state.attributes.get("first_available_entity_id") is None
async def test_sensors_attributes_added_when_entity_info_available(
hass: HomeAssistant,
) -> None:

View File

@@ -26,7 +26,6 @@ from homeassistant.components.light import (
DOMAIN,
ColorMode,
LightEntity,
LightEntityFeature,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
@@ -157,7 +156,7 @@ class MockLight(MockToggleEntity, LightEntity):
_attr_max_color_temp_kelvin = DEFAULT_MAX_KELVIN
_attr_min_color_temp_kelvin = DEFAULT_MIN_KELVIN
supported_features = LightEntityFeature(0)
supported_features = 0
brightness = None
color_temp_kelvin = None

View File

@@ -1,5 +1,7 @@
"""The tests for the Light component."""
from types import ModuleType
from typing import Literal
from unittest.mock import MagicMock, mock_open, patch
import pytest
@@ -28,6 +30,9 @@ 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,
)
@@ -132,10 +137,13 @@ async def test_services(
ent3.supported_color_modes = [light.ColorMode.HS]
ent1.supported_features = light.LightEntityFeature.TRANSITION
ent2.supported_features = (
light.LightEntityFeature.EFFECT | light.LightEntityFeature.TRANSITION
light.SUPPORT_COLOR
| light.LightEntityFeature.EFFECT
| light.LightEntityFeature.TRANSITION
)
ent2.supported_color_modes = [light.ColorMode.HS]
ent2.color_mode = light.ColorMode.HS
# Set color modes to none to trigger backwards compatibility in LightEntity
ent2.supported_color_modes = None
ent2.color_mode = None
ent3.supported_features = (
light.LightEntityFeature.FLASH | light.LightEntityFeature.TRANSITION
)
@@ -895,12 +903,16 @@ async def test_light_brightness_step(hass: HomeAssistant) -> None:
setup_test_component_platform(hass, light.DOMAIN, entities)
entity0 = entities[0]
entity0.supported_color_modes = {light.ColorMode.BRIGHTNESS}
entity0.color_mode = light.ColorMode.BRIGHTNESS
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.brightness = 100
entity1 = entities[1]
entity1.supported_color_modes = {light.ColorMode.BRIGHTNESS}
entity1.color_mode = light.ColorMode.BRIGHTNESS
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 = 50
assert await async_setup_component(hass, "light", {"light": {"platform": "test"}})
await hass.async_block_till_done()
@@ -943,8 +955,10 @@ async def test_light_brightness_step_pct(hass: HomeAssistant) -> None:
setup_test_component_platform(hass, light.DOMAIN, [entity])
entity.supported_color_modes = {light.ColorMode.BRIGHTNESS}
entity.color_mode = light.ColorMode.BRIGHTNESS
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.brightness = 255
assert await async_setup_component(hass, "light", {"light": {"platform": "test"}})
await hass.async_block_till_done()
@@ -986,8 +1000,10 @@ async def test_light_brightness_pct_conversion(
setup_test_component_platform(hass, light.DOMAIN, mock_light_entities)
entity = mock_light_entities[0]
entity.supported_color_modes = {light.ColorMode.BRIGHTNESS}
entity.color_mode = light.ColorMode.BRIGHTNESS
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.brightness = 100
assert await async_setup_component(hass, "light", {"light": {"platform": "test"}})
await hass.async_block_till_done()
@@ -1136,6 +1152,167 @@ 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)
@@ -1301,6 +1478,7 @@ 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),
@@ -1324,13 +1502,19 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
}
entity4 = entities[4]
entity4.supported_color_modes = {light.ColorMode.RGBW}
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
entity5 = entities[5]
entity5.supported_color_modes = {light.ColorMode.RGBWW}
entity5.supported_color_modes = {light.ColorMode.RGBW}
entity6 = entities[6]
entity6.supported_color_modes = {light.ColorMode.COLOR_TEMP}
entity6.supported_color_modes = {light.ColorMode.RGBWW}
entity7 = entities[7]
entity7.supported_color_modes = {light.ColorMode.COLOR_TEMP}
assert await async_setup_component(hass, "light", {"light": {"platform": "test"}})
await hass.async_block_till_done()
@@ -1352,12 +1536,15 @@ 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.RGBW]
assert state.attributes["supported_color_modes"] == [light.ColorMode.HS]
state = hass.states.get(entity5.entity_id)
assert state.attributes["supported_color_modes"] == [light.ColorMode.RGBWW]
assert state.attributes["supported_color_modes"] == [light.ColorMode.RGBW]
state = hass.states.get(entity6.entity_id)
assert state.attributes["supported_color_modes"] == [light.ColorMode.RGBWW]
state = hass.states.get(entity7.entity_id)
assert state.attributes["supported_color_modes"] == [light.ColorMode.COLOR_TEMP]
await hass.services.async_call(
@@ -1372,6 +1559,7 @@ 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),
@@ -1387,10 +1575,12 @@ 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, "rgbw_color": (0, 0, 255, 0)}
assert data == {"brightness": 255, "hs_color": (240.0, 100.0)}
_, data = entity5.last_call("turn_on")
assert data == {"brightness": 255, "rgbww_color": (0, 0, 255, 0, 0)}
assert data == {"brightness": 255, "rgbw_color": (0, 0, 255, 0)}
_, data = entity6.last_call("turn_on")
assert data == {"brightness": 255, "rgbww_color": (0, 0, 255, 0, 0)}
_, data = entity7.last_call("turn_on")
assert data == {"brightness": 255, "color_temp_kelvin": 1739}
await hass.services.async_call(
@@ -1405,6 +1595,7 @@ 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),
@@ -1420,11 +1611,13 @@ 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, "rgbw_color": (0, 0, 0, 255)}
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")
# 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 = entity6.last_call("turn_on")
_, data = entity7.last_call("turn_on")
assert data == {"brightness": 255, "color_temp_kelvin": 5962}
await hass.services.async_call(
@@ -1439,6 +1632,7 @@ 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),
@@ -1454,10 +1648,12 @@ 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, "rgbw_color": (128, 0, 0, 0)}
assert data == {"brightness": 128, "hs_color": (0.0, 100.0)}
_, data = entity5.last_call("turn_on")
assert data == {"brightness": 128, "rgbww_color": (128, 0, 0, 0, 0)}
assert data == {"brightness": 128, "rgbw_color": (128, 0, 0, 0)}
_, data = entity6.last_call("turn_on")
assert data == {"brightness": 128, "rgbww_color": (128, 0, 0, 0, 0)}
_, data = entity7.last_call("turn_on")
assert data == {"brightness": 128, "color_temp_kelvin": 6279}
await hass.services.async_call(
@@ -1472,6 +1668,7 @@ 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),
@@ -1487,11 +1684,13 @@ 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, "rgbw_color": (0, 0, 0, 255)}
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")
# The midpoint the white channels is warm, compensated by adding green + blue
assert data == {"brightness": 128, "rgbww_color": (0, 76, 141, 255, 255)}
_, data = entity6.last_call("turn_on")
_, data = entity7.last_call("turn_on")
assert data == {"brightness": 128, "color_temp_kelvin": 5962}
await hass.services.async_call(
@@ -1506,6 +1705,7 @@ 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),
@@ -1521,10 +1721,12 @@ 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, "rgbw_color": (0, 255, 22, 0)}
assert data == {"brightness": 128, "hs_color": (125.176, 100.0)}
_, data = entity5.last_call("turn_on")
assert data == {"brightness": 128, "rgbww_color": (0, 255, 22, 0, 0)}
assert data == {"brightness": 128, "rgbw_color": (0, 255, 22, 0)}
_, data = entity6.last_call("turn_on")
assert data == {"brightness": 128, "rgbww_color": (0, 255, 22, 0, 0)}
_, data = entity7.last_call("turn_on")
assert data == {"brightness": 128, "color_temp_kelvin": 8645}
await hass.services.async_call(
@@ -1539,6 +1741,7 @@ 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),
@@ -1554,11 +1757,13 @@ 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, "rgbw_color": (1, 0, 0, 255)}
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")
# The midpoint the white channels is warm, compensated by adding green + blue
assert data == {"brightness": 128, "rgbww_color": (0, 75, 140, 255, 255)}
_, data = entity6.last_call("turn_on")
_, data = entity7.last_call("turn_on")
assert data == {"brightness": 128, "color_temp_kelvin": 5962}
await hass.services.async_call(
@@ -1573,6 +1778,7 @@ 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),
@@ -1588,11 +1794,13 @@ 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, "rgbw_color": (128, 0, 0, 64)}
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")
# The midpoint the white channels is warm, compensated by adding green + blue
assert data == {"brightness": 128, "rgbww_color": (128, 0, 30, 117, 117)}
_, data = entity6.last_call("turn_on")
_, data = entity7.last_call("turn_on")
assert data == {"brightness": 128, "color_temp_kelvin": 3011}
await hass.services.async_call(
@@ -1607,6 +1815,7 @@ 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),
@@ -1622,11 +1831,13 @@ 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, "rgbw_color": (255, 255, 255, 255)}
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")
# The midpoint the white channels is warm, compensated by adding green + blue
assert data == {"brightness": 128, "rgbww_color": (0, 76, 141, 255, 255)}
_, data = entity6.last_call("turn_on")
_, data = entity7.last_call("turn_on")
assert data == {"brightness": 128, "color_temp_kelvin": 5962}
await hass.services.async_call(
@@ -1641,6 +1852,7 @@ 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),
@@ -1656,10 +1868,12 @@ 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, "rgbw_color": (128, 9, 0, 33)}
assert data == {"brightness": 128, "hs_color": (4.118, 79.688)}
_, data = entity5.last_call("turn_on")
assert data == {"brightness": 128, "rgbww_color": (128, 0, 0, 64, 32)}
assert data == {"brightness": 128, "rgbw_color": (128, 9, 0, 33)}
_, data = entity6.last_call("turn_on")
assert data == {"brightness": 128, "rgbww_color": (128, 0, 0, 64, 32)}
_, data = entity7.last_call("turn_on")
assert data == {"brightness": 128, "color_temp_kelvin": 3845}
await hass.services.async_call(
@@ -1674,6 +1888,7 @@ 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),
@@ -1689,11 +1904,13 @@ 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 = entity5.last_call("turn_on")
assert data == {"brightness": 128, "rgbww_color": (255, 255, 255, 255, 255)}
_, data = entity6.last_call("turn_on")
assert data == {"brightness": 128, "rgbww_color": (255, 255, 255, 255, 255)}
_, data = entity7.last_call("turn_on")
assert data == {"brightness": 128, "color_temp_kelvin": 3451}
@@ -1706,6 +1923,7 @@ 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),
]
@@ -1728,10 +1946,16 @@ async def test_light_service_call_color_conversion_named_tuple(
}
entity4 = entities[4]
entity4.supported_color_modes = {light.ColorMode.RGBW}
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
entity5 = entities[5]
entity5.supported_color_modes = {light.ColorMode.RGBWW}
entity5.supported_color_modes = {light.ColorMode.RGBW}
entity6 = entities[6]
entity6.supported_color_modes = {light.ColorMode.RGBWW}
assert await async_setup_component(hass, "light", {"light": {"platform": "test"}})
await hass.async_block_till_done()
@@ -1747,6 +1971,7 @@ 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),
@@ -1762,8 +1987,10 @@ 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, "rgbw_color": (128, 0, 0, 0)}
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")
assert data == {"brightness": 64, "rgbww_color": (128, 0, 0, 0, 0)}
@@ -2100,6 +2327,7 @@ 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)
@@ -2124,6 +2352,13 @@ 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()
@@ -2145,6 +2380,12 @@ 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,
@@ -2379,6 +2620,61 @@ 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"),
[
(None, {light.ColorMode.ONOFF}, True),
(light.ColorMode.ONOFF, {light.ColorMode.ONOFF}, False),
],
)
async def test_report_no_color_mode(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
color_mode: str,
supported_color_modes: set[str],
warning_expected: bool,
) -> None:
"""Test a light setting no color mode."""
class MockLightEntityEntity(light.LightEntity):
_attr_color_mode = color_mode
_attr_is_on = True
_attr_supported_features = light.LightEntityFeature.EFFECT
_attr_supported_color_modes = supported_color_modes
entity = MockLightEntityEntity()
platform = MockEntityPlatform(hass, domain="test", platform_name="test")
await platform.async_add_entities([entity])
entity._async_calculate_state()
expected_warning = "does not report a color mode"
assert (expected_warning in caplog.text) is warning_expected
@pytest.mark.parametrize(
("color_mode", "supported_color_modes", "warning_expected"),
[
@@ -2575,3 +2871,46 @@ 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,9 +174,7 @@ 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.LightEntityFeature.FLASH | light.LightEntityFeature.TRANSITION
)
expected_features = light.SUPPORT_FLASH | light.SUPPORT_TRANSITION
assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features

View File

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

View File

@@ -1,70 +0,0 @@
"""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

@@ -1,99 +0,0 @@
# 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

@@ -1,138 +0,0 @@
"""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

@@ -1,255 +0,0 @@
"""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

View File

@@ -57,7 +57,6 @@ from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM
from . import (
MOCK_MAC,
init_integration,
mutate_rpc_device_status,
patch_platforms,
register_device,
register_entity,
@@ -1048,16 +1047,6 @@ async def test_rpc_linkedgo_st802_thermostat(
assert (state := hass.states.get(entity_id))
assert state.state == HVACMode.OFF
# Test current temperature update
assert (state := hass.states.get(entity_id))
assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 25.1
mutate_rpc_device_status(monkeypatch, mock_rpc_device, "number:201", "value", 22.4)
mock_rpc_device.mock_update()
assert (state := hass.states.get(entity_id))
assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 22.4
async def test_rpc_linkedgo_st1820_thermostat(
hass: HomeAssistant,