Compare commits

...

113 Commits

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

View File

@@ -435,6 +435,7 @@ homeassistant.components.raspberry_pi.*
homeassistant.components.rdw.*
homeassistant.components.recollect_waste.*
homeassistant.components.recorder.*
homeassistant.components.redgtech.*
homeassistant.components.remember_the_milk.*
homeassistant.components.remote.*
homeassistant.components.remote_calendar.*

2
CODEOWNERS generated
View File

@@ -1355,6 +1355,8 @@ build.json @home-assistant/supervisor
/tests/components/recorder/ @home-assistant/core
/homeassistant/components/recovery_mode/ @home-assistant/core
/tests/components/recovery_mode/ @home-assistant/core
/homeassistant/components/redgtech/ @jonhsady @luan-nvg
/tests/components/redgtech/ @jonhsady @luan-nvg
/homeassistant/components/refoss/ @ashionky
/tests/components/refoss/ @ashionky
/homeassistant/components/rehlko/ @bdraco @peterager

View File

@@ -99,7 +99,7 @@ class AbodeLight(AbodeDevice, LightEntity):
return _hs
@property
def color_mode(self) -> ColorMode | None:
def color_mode(self) -> ColorMode:
"""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] | None:
def supported_color_modes(self) -> set[ColorMode]:
"""Flag supported color modes."""
if self._device.is_dimmable and self._device.is_color_capable:
return {ColorMode.COLOR_TEMP, ColorMode.HS}

View File

@@ -90,6 +90,9 @@
"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 the camera stream on a supported media player.",
"description": "Plays a camera stream on a supported media player.",
"fields": {
"format": {
"description": "Stream format supported by the media player.",
"name": "Format"
},
"media_player": {
"description": "Media players to stream to.",
"description": "Media player to stream to.",
"name": "Media player"
}
},

View File

@@ -13,6 +13,6 @@
"integration_type": "system",
"iot_class": "cloud_push",
"loggers": ["acme", "hass_nabucasa", "snitun"],
"requirements": ["hass-nabucasa==1.12.0", "openai==2.15.0"],
"requirements": ["hass-nabucasa==1.13.0", "openai==2.15.0"],
"single_config_entry": true
}

View File

@@ -196,44 +196,46 @@ class R2BackupAgent(BackupAgent):
)
upload_id = multipart_upload["UploadId"]
try:
parts = []
parts: list[dict[str, Any]] = []
part_number = 1
buffer_size = 0 # bytes
buffer: list[bytes] = []
buffer = bytearray() # bytes buffer to store the data
stream = await open_stream()
async for chunk in stream:
buffer_size += len(chunk)
buffer.append(chunk)
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]
# 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, buffer_size
"Uploading part number %d, size %d",
part_number,
len(part_data),
)
part = await self._client.upload_part(
Bucket=self._bucket,
Key=self._with_prefix(tar_filename),
PartNumber=part_number,
UploadId=upload_id,
Body=b"".join(buffer),
Body=part_data,
)
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, buffer_size
"Uploading final part number %d, size %d", part_number, len(buffer)
)
part = await self._client.upload_part(
Bucket=self._bucket,
Key=self._with_prefix(tar_filename),
PartNumber=part_number,
UploadId=upload_id,
Body=b"".join(buffer),
Body=bytes(buffer),
)
parts.append({"PartNumber": part_number, "ETag": part["ETag"]})

View File

@@ -46,7 +46,6 @@ 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",
@@ -55,21 +54,18 @@ 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),
@@ -78,7 +74,6 @@ 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,
@@ -86,7 +81,6 @@ 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,
@@ -112,7 +106,6 @@ 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,
@@ -125,128 +118,72 @@ class DemoLight(LightEntity):
) -> None:
"""Initialize the light."""
self._attr_translation_key = translation_key
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
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
if hs_color:
self._color_mode = ColorMode.HS
self._attr_color_mode = ColorMode.HS
elif rgbw_color:
self._color_mode = ColorMode.RGBW
self._attr_color_mode = ColorMode.RGBW
elif rgbww_color:
self._color_mode = ColorMode.RGBWW
self._attr_color_mode = ColorMode.RGBWW
else:
self._color_mode = ColorMode.COLOR_TEMP
self._attr_color_mode = ColorMode.COLOR_TEMP
if not supported_color_modes:
supported_color_modes = SUPPORT_DEMO
self._color_modes = supported_color_modes
if self._effect_list is not None:
self._attr_supported_color_modes = supported_color_modes
if self._attr_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, self.unique_id)
(DOMAIN, 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 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
return True
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
self._state = True
self._attr_is_on = True
if ATTR_BRIGHTNESS in kwargs:
self._brightness = kwargs[ATTR_BRIGHTNESS]
self._attr_brightness = kwargs[ATTR_BRIGHTNESS]
if ATTR_COLOR_TEMP_KELVIN in kwargs:
self._color_mode = ColorMode.COLOR_TEMP
self._ct = kwargs[ATTR_COLOR_TEMP_KELVIN]
self._attr_color_mode = ColorMode.COLOR_TEMP
self._attr_color_temp_kelvin = kwargs[ATTR_COLOR_TEMP_KELVIN]
if ATTR_EFFECT in kwargs:
self._effect = kwargs[ATTR_EFFECT]
self._attr_effect = kwargs[ATTR_EFFECT]
if ATTR_HS_COLOR in kwargs:
self._color_mode = ColorMode.HS
self._hs_color = kwargs[ATTR_HS_COLOR]
self._attr_color_mode = ColorMode.HS
self._attr_hs_color = kwargs[ATTR_HS_COLOR]
if ATTR_RGBW_COLOR in kwargs:
self._color_mode = ColorMode.RGBW
self._rgbw_color = kwargs[ATTR_RGBW_COLOR]
self._attr_color_mode = ColorMode.RGBW
self._attr_rgbw_color = kwargs[ATTR_RGBW_COLOR]
if ATTR_RGBWW_COLOR in kwargs:
self._color_mode = ColorMode.RGBWW
self._rgbww_color = kwargs[ATTR_RGBWW_COLOR]
self._attr_color_mode = ColorMode.RGBWW
self._attr_rgbww_color = kwargs[ATTR_RGBWW_COLOR]
if ATTR_WHITE in kwargs:
self._color_mode = ColorMode.WHITE
self._brightness = kwargs[ATTR_WHITE]
self._attr_color_mode = ColorMode.WHITE
self._attr_brightness = kwargs[ATTR_WHITE]
# As we have disabled polling, we need to inform
# Home Assistant about updates in our state ourselves.
@@ -254,7 +191,7 @@ class DemoLight(LightEntity):
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
self._state = False
self._attr_is_on = 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.1"],
"requirements": ["denonavr==1.3.2"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",

View File

@@ -17,6 +17,7 @@ from denonavr.const import (
STATE_ON,
STATE_PAUSED,
STATE_PLAYING,
STATE_STOPPED,
)
from denonavr.exceptions import (
AvrCommandError,
@@ -69,6 +70,7 @@ SUPPORT_MEDIA_MODES = (
| MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.STOP
)
SCAN_INTERVAL = timedelta(seconds=10)
@@ -96,6 +98,7 @@ DENON_STATE_MAPPING = {
STATE_OFF: MediaPlayerState.OFF,
STATE_PLAYING: MediaPlayerState.PLAYING,
STATE_PAUSED: MediaPlayerState.PAUSED,
STATE_STOPPED: MediaPlayerState.IDLE,
}
@@ -404,6 +407,11 @@ 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

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

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["google_air_quality_api"],
"quality_scale": "bronze",
"requirements": ["google_air_quality_api==3.0.0"]
"requirements": ["google_air_quality_api==3.0.1"]
}

View File

@@ -7,7 +7,7 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pyliebherrhomeapi"],
"quality_scale": "bronze",
"quality_scale": "silver",
"requirements": ["pyliebherrhomeapi==0.2.1"],
"zeroconf": [
{

View File

@@ -34,7 +34,7 @@ rules:
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: todo
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage: done

View File

@@ -5,10 +5,9 @@ from __future__ import annotations
from collections.abc import Iterable
import csv
import dataclasses
from functools import partial
import logging
import os
from typing import TYPE_CHECKING, Any, Final, Self, cast, final
from typing import TYPE_CHECKING, Any, Self, cast, final
from propcache.api import cached_property
import voluptuous as vol
@@ -23,13 +22,6 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.deprecation import (
DeprecatedConstant,
DeprecatedConstantEnum,
all_with_deprecated_constants,
check_if_deprecated_constant,
dir_with_deprecated_constants,
)
from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.frame import ReportBehavior, report_usage
@@ -56,27 +48,6 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
# These SUPPORT_* constants are deprecated as of Home Assistant 2022.5.
# Please use the LightEntityFeature enum instead.
_DEPRECATED_SUPPORT_BRIGHTNESS: Final = DeprecatedConstant(
1, "supported_color_modes", "2026.1"
) # Deprecated, replaced by color modes
_DEPRECATED_SUPPORT_COLOR_TEMP: Final = DeprecatedConstant(
2, "supported_color_modes", "2026.1"
) # Deprecated, replaced by color modes
_DEPRECATED_SUPPORT_EFFECT: Final = DeprecatedConstantEnum(
LightEntityFeature.EFFECT, "2026.1"
)
_DEPRECATED_SUPPORT_FLASH: Final = DeprecatedConstantEnum(
LightEntityFeature.FLASH, "2026.1"
)
_DEPRECATED_SUPPORT_COLOR: Final = DeprecatedConstant(
16, "supported_color_modes", "2026.1"
) # Deprecated, replaced by color modes
_DEPRECATED_SUPPORT_TRANSITION: Final = DeprecatedConstantEnum(
LightEntityFeature.TRANSITION, "2026.1"
)
# Color mode of the light
ATTR_COLOR_MODE = "color_mode"
# List of color modes supported by the light
@@ -291,7 +262,7 @@ def filter_turn_off_params(
if not params:
return params
supported_features = light.supported_features_compat
supported_features = light.supported_features
if LightEntityFeature.FLASH not in supported_features:
params.pop(ATTR_FLASH, None)
@@ -303,7 +274,7 @@ def filter_turn_off_params(
def filter_turn_on_params(light: LightEntity, params: dict[str, Any]) -> dict[str, Any]:
"""Filter out params not supported by the light."""
supported_features = light.supported_features_compat
supported_features = light.supported_features
if LightEntityFeature.EFFECT not in supported_features:
params.pop(ATTR_EFFECT, None)
@@ -956,7 +927,7 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
def capability_attributes(self) -> dict[str, Any]:
"""Return capability attributes."""
data: dict[str, Any] = {}
supported_features = self.supported_features_compat
supported_features = self.supported_features
supported_color_modes = self._light_internal_supported_color_modes
if ColorMode.COLOR_TEMP in supported_color_modes:
@@ -1106,12 +1077,12 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
def state_attributes(self) -> dict[str, Any] | None:
"""Return state attributes."""
data: dict[str, Any] = {}
supported_features = self.supported_features_compat
supported_features = self.supported_features
supported_color_modes = self.supported_color_modes
legacy_supported_color_modes = (
supported_color_modes or self._light_internal_supported_color_modes
)
supported_features_value = supported_features.value
_is_on = self.is_on
color_mode = self._light_internal_color_mode if _is_on else None
@@ -1130,26 +1101,12 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
data[ATTR_BRIGHTNESS] = self.brightness
else:
data[ATTR_BRIGHTNESS] = None
elif supported_features_value & _DEPRECATED_SUPPORT_BRIGHTNESS.value:
# Backwards compatibility for ambiguous / incomplete states
# Warning is printed by supported_features_compat, remove in 2025.1
if _is_on:
data[ATTR_BRIGHTNESS] = self.brightness
else:
data[ATTR_BRIGHTNESS] = None
if color_temp_supported(supported_color_modes):
if color_mode == ColorMode.COLOR_TEMP:
data[ATTR_COLOR_TEMP_KELVIN] = self.color_temp_kelvin
else:
data[ATTR_COLOR_TEMP_KELVIN] = None
elif supported_features_value & _DEPRECATED_SUPPORT_COLOR_TEMP.value:
# Backwards compatibility
# Warning is printed by supported_features_compat, remove in 2025.1
if _is_on:
data[ATTR_COLOR_TEMP_KELVIN] = self.color_temp_kelvin
else:
data[ATTR_COLOR_TEMP_KELVIN] = None
if color_supported(legacy_supported_color_modes) or color_temp_supported(
legacy_supported_color_modes
@@ -1187,24 +1144,8 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
type(self),
report_issue,
)
supported_features = self.supported_features_compat
supported_features_value = supported_features.value
supported_color_modes: set[ColorMode] = set()
if supported_features_value & _DEPRECATED_SUPPORT_COLOR_TEMP.value:
supported_color_modes.add(ColorMode.COLOR_TEMP)
if supported_features_value & _DEPRECATED_SUPPORT_COLOR.value:
supported_color_modes.add(ColorMode.HS)
if (
not supported_color_modes
and supported_features_value & _DEPRECATED_SUPPORT_BRIGHTNESS.value
):
supported_color_modes = {ColorMode.BRIGHTNESS}
if not supported_color_modes:
supported_color_modes = {ColorMode.ONOFF}
return supported_color_modes
return {ColorMode.ONOFF}
@cached_property
def supported_color_modes(self) -> set[ColorMode] | None:
@@ -1216,48 +1157,9 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Flag supported features."""
return self._attr_supported_features
@property
def supported_features_compat(self) -> LightEntityFeature:
"""Return the supported features as LightEntityFeature.
Remove this compatibility shim in 2025.1 or later.
"""
features = self.supported_features
if type(features) is not int:
return features
new_features = LightEntityFeature(features)
if self._deprecated_supported_features_reported is True:
return new_features
self._deprecated_supported_features_reported = True
report_issue = self._suggest_report_issue()
report_issue += (
" and reference "
"https://developers.home-assistant.io/blog/2023/12/28/support-feature-magic-numbers-deprecation"
)
_LOGGER.warning(
(
"Entity %s (%s) is using deprecated supported features"
" values which will be removed in HA Core 2025.1. Instead it should use"
" %s and color modes, please %s"
),
self.entity_id,
type(self),
repr(new_features),
report_issue,
)
return new_features
def __should_report_light_issue(self) -> bool:
"""Return if light color mode issues should be reported."""
if not self.platform:
return True
# philips_js has known issues, we don't need users to open issues
return self.platform.platform_name not in {"philips_js"}
# These can be removed if no deprecated constant are in this module anymore
__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
__dir__ = partial(
dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
)
__all__ = all_with_deprecated_constants(globals())

View File

@@ -85,7 +85,7 @@ class NanoleafLight(NanoleafEntity, LightEntity):
return self._nanoleaf.hue, self._nanoleaf.saturation
@property
def color_mode(self) -> ColorMode | None:
def color_mode(self) -> ColorMode:
"""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

@@ -0,0 +1,35 @@
"""Initialize the Redgtech integration for Home Assistant."""
from __future__ import annotations
import logging
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import RedgtechConfigEntry, RedgtechDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.SWITCH]
async def async_setup_entry(hass: HomeAssistant, entry: RedgtechConfigEntry) -> bool:
"""Set up Redgtech from a config entry."""
_LOGGER.debug("Setting up Redgtech entry: %s", entry.entry_id)
coordinator = RedgtechDataUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
_LOGGER.debug("Successfully set up Redgtech entry: %s", entry.entry_id)
return True
async def async_unload_entry(hass: HomeAssistant, entry: RedgtechConfigEntry) -> bool:
"""Unload a config entry."""
_LOGGER.debug("Unloading Redgtech entry: %s", entry.entry_id)
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -0,0 +1,67 @@
"""Config flow for the Redgtech integration."""
from __future__ import annotations
import logging
from typing import Any
from redgtech_api.api import RedgtechAPI, RedgtechAuthError, RedgtechConnectionError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from .const import DOMAIN, INTEGRATION_NAME
_LOGGER = logging.getLogger(__name__)
class RedgtechConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config Flow for Redgtech integration."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial user step for login."""
errors: dict[str, str] = {}
if user_input is not None:
email = user_input[CONF_EMAIL]
password = user_input[CONF_PASSWORD]
self._async_abort_entries_match({CONF_EMAIL: user_input[CONF_EMAIL]})
api = RedgtechAPI()
try:
await api.login(email, password)
except RedgtechAuthError:
errors["base"] = "invalid_auth"
except RedgtechConnectionError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected error during login")
errors["base"] = "unknown"
else:
_LOGGER.debug("Login successful, token received")
return self.async_create_entry(
title=email,
data={
CONF_EMAIL: email,
CONF_PASSWORD: password,
},
)
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
vol.Schema(
{
vol.Required(CONF_EMAIL): str,
vol.Required(CONF_PASSWORD): str,
}
),
user_input,
),
errors=errors,
description_placeholders={"integration_name": INTEGRATION_NAME},
)

View File

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

View File

@@ -0,0 +1,130 @@
"""Coordinator for Redgtech integration."""
from __future__ import annotations
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import Any
from redgtech_api.api import RedgtechAPI, RedgtechAuthError, RedgtechConnectionError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
UPDATE_INTERVAL = timedelta(seconds=15)
_LOGGER = logging.getLogger(__name__)
@dataclass
class RedgtechDevice:
"""Representation of a Redgtech device."""
unique_id: str
name: str
state: bool
type RedgtechConfigEntry = ConfigEntry[RedgtechDataUpdateCoordinator]
class RedgtechDataUpdateCoordinator(DataUpdateCoordinator[dict[str, RedgtechDevice]]):
"""Coordinator to manage fetching data from the Redgtech API.
Uses a dictionary keyed by unique_id for O(1) device lookup instead of O(n) list iteration.
"""
config_entry: RedgtechConfigEntry
def __init__(self, hass: HomeAssistant, config_entry: RedgtechConfigEntry) -> None:
"""Initialize the coordinator."""
self.api = RedgtechAPI()
self.access_token: str | None = None
self.email = config_entry.data[CONF_EMAIL]
self.password = config_entry.data[CONF_PASSWORD]
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=UPDATE_INTERVAL,
config_entry=config_entry,
)
async def login(self, email: str, password: str) -> str | None:
"""Login to the Redgtech API and return the access token."""
try:
self.access_token = await self.api.login(email, password)
except RedgtechAuthError as e:
raise ConfigEntryError("Authentication error during login") from e
except RedgtechConnectionError as e:
raise UpdateFailed("Connection error during login") from e
else:
_LOGGER.debug("Access token obtained successfully")
return self.access_token
async def renew_token(self, email: str, password: str) -> None:
"""Renew the access token."""
self.access_token = await self.api.login(email, password)
_LOGGER.debug("Access token renewed successfully")
async def call_api_with_valid_token[_R, *_Ts](
self, api_call: Callable[[*_Ts], Coroutine[Any, Any, _R]], *args: *_Ts
) -> _R:
"""Make an API call with a valid token.
Ensure we have a valid access token, renewing it if necessary.
"""
if not self.access_token:
_LOGGER.debug("No access token, logging in")
self.access_token = await self.login(self.email, self.password)
else:
_LOGGER.debug("Using existing access token")
try:
return await api_call(*args)
except RedgtechAuthError:
_LOGGER.debug("Auth failed, trying to renew token")
await self.renew_token(
self.config_entry.data[CONF_EMAIL],
self.config_entry.data[CONF_PASSWORD],
)
return await api_call(*args)
async def _async_update_data(self) -> dict[str, RedgtechDevice]:
"""Fetch data from the API on demand.
Returns a dictionary keyed by unique_id for efficient device lookup.
"""
_LOGGER.debug("Fetching data from Redgtech API on demand")
try:
data = await self.call_api_with_valid_token(
self.api.get_data, self.access_token
)
except RedgtechAuthError as e:
raise ConfigEntryError("Authentication failed") from e
except RedgtechConnectionError as e:
raise UpdateFailed("Failed to connect to Redgtech API") from e
devices: dict[str, RedgtechDevice] = {}
for item in data["boards"]:
display_categories = {cat.lower() for cat in item["displayCategories"]}
if "light" in display_categories or "switch" not in display_categories:
continue
device = RedgtechDevice(
unique_id=item["endpointId"],
name=item["friendlyName"],
state=item["value"],
)
_LOGGER.debug("Processing device: %s", device)
devices[device.unique_id] = device
return devices

View File

@@ -0,0 +1,11 @@
{
"domain": "redgtech",
"name": "Redgtech",
"codeowners": ["@jonhsady", "@luan-nvg"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/redgtech",
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["redgtech-api==0.1.38"]
}

View File

@@ -0,0 +1,72 @@
rules:
# Bronze
action-setup:
status: exempt
comment: No custom actions
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: No custom actions
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: No explicit signature for events
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: No options flow
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default:
status: exempt
comment: Only essential entities
entity-translations: done
exception-translations: todo
icon-translations: done
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: No repair issues
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: todo
strict-typing: done

View File

@@ -0,0 +1,40 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"email": "Enter the email address associated with your {integration_name} account.",
"password": "Enter the password for your {integration_name} account."
},
"description": "Please enter your credentials to connect to the {integration_name} API.",
"title": "Set up {integration_name}"
}
}
},
"exceptions": {
"api_error": {
"message": "Error while communicating with the {integration_name} API"
},
"authentication_failed": {
"message": "Authentication failed. Please check your credentials."
},
"connection_error": {
"message": "Connection error with {integration_name} API"
},
"switch_auth_error": {
"message": "Authentication failed when controlling {integration_name} switch"
}
}
}

View File

@@ -0,0 +1,95 @@
"""Integration for Redgtech switches."""
from __future__ import annotations
from typing import Any
from redgtech_api.api import RedgtechAuthError, RedgtechConnectionError
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, INTEGRATION_NAME
from .coordinator import (
RedgtechConfigEntry,
RedgtechDataUpdateCoordinator,
RedgtechDevice,
)
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: RedgtechConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the switch platform."""
coordinator = config_entry.runtime_data
async_add_entities(
RedgtechSwitch(coordinator, device) for device in coordinator.data.values()
)
class RedgtechSwitch(CoordinatorEntity[RedgtechDataUpdateCoordinator], SwitchEntity):
"""Representation of a Redgtech switch."""
_attr_has_entity_name = True
_attr_name = None
def __init__(
self, coordinator: RedgtechDataUpdateCoordinator, device: RedgtechDevice
) -> None:
"""Initialize the switch."""
super().__init__(coordinator)
self.coordinator = coordinator
self.device = device
self._attr_unique_id = device.unique_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.unique_id)},
name=device.name,
manufacturer=INTEGRATION_NAME,
)
@property
def is_on(self) -> bool:
"""Return true if the switch is on."""
if device := self.coordinator.data.get(self.device.unique_id):
return bool(device.state)
return False
async def _set_state(self, new_state: bool) -> None:
"""Set state of the switch."""
try:
await self.coordinator.call_api_with_valid_token(
self.coordinator.api.set_switch_state,
self.device.unique_id,
new_state,
self.coordinator.access_token,
)
except RedgtechAuthError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="switch_auth_error",
translation_placeholders={"integration_name": INTEGRATION_NAME},
) from err
except RedgtechConnectionError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="connection_error",
translation_placeholders={"integration_name": INTEGRATION_NAME},
) from err
await self.coordinator.async_refresh()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self._set_state(True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self._set_state(False)

View File

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

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 | None:
def color_mode(self) -> ColorMode:
"""Return the color mode of the light."""
return SWITCHBOT_COLOR_MODE_TO_HASS.get(
self._device.color_mode, ColorMode.UNKNOWN

View File

@@ -1,6 +1,7 @@
"""The syncthing integration."""
import asyncio
from asyncio import Task
import logging
import aiosyncthing
@@ -13,7 +14,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.dispatcher import async_dispatcher_send
@@ -57,7 +58,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
async def cancel_listen_task(_):
async def cancel_listen_task(event: Event) -> None:
"""Cancel the listen task on Home Assistant stop."""
await syncthing.unsubscribe()
entry.async_on_unload(
@@ -80,44 +82,46 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
class SyncthingClient:
"""A Syncthing client."""
def __init__(self, hass, client, server_id):
def __init__(
self, hass: HomeAssistant, client: aiosyncthing.Syncthing, server_id: str
) -> None:
"""Initialize the client."""
self._hass = hass
self._client = client
self._server_id = server_id
self._listen_task = None
self._listen_task: Task[None] | None = None
@property
def server_id(self):
def server_id(self) -> str:
"""Get server id."""
return self._server_id
@property
def url(self):
def url(self) -> str:
"""Get server URL."""
return self._client.url
@property
def database(self):
def database(self) -> aiosyncthing.Database:
"""Get database namespace client."""
return self._client.database
@property
def system(self):
def system(self) -> aiosyncthing.System:
"""Get system namespace client."""
return self._client.system
def subscribe(self):
def subscribe(self) -> None:
"""Start event listener coroutine."""
self._listen_task = asyncio.create_task(self._listen())
async def unsubscribe(self):
async def unsubscribe(self) -> None:
"""Stop event listener coroutine."""
if self._listen_task:
self._listen_task.cancel()
await self._client.close()
async def _listen(self):
async def _listen(self) -> None:
"""Listen to Syncthing events."""
events = self._client.events
server_was_unavailable = False
@@ -142,11 +146,7 @@ class SyncthingClient:
continue
signal_name = EVENTS[event["type"]]
folder = None
if "folder" in event["data"]:
folder = event["data"]["folder"]
else: # A workaround, some events store folder id under `id` key
folder = event["data"]["id"]
folder = event["data"].get("folder") or event["data"]["id"]
async_dispatcher_send(
self._hass,
f"{signal_name}-{self._server_id}-{folder}",
@@ -168,7 +168,8 @@ class SyncthingClient:
server_was_unavailable = True
continue
async def _server_available(self):
async def _server_available(self) -> bool:
"""Check if the Syncthing server is available."""
try:
await self._client.system.ping()
except aiosyncthing.exceptions.SyncthingError:

View File

@@ -21,7 +21,7 @@ DATA_SCHEMA = vol.Schema(
)
async def validate_input(hass: HomeAssistant, data):
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]:
"""Validate the user input allows us to connect."""
try:

View File

@@ -1,16 +1,20 @@
"""Support for monitoring the Syncthing instance."""
"""Support for Syncthing sensors."""
from collections.abc import Mapping
from typing import Any
import aiosyncthing
from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_time_interval
from . import SyncthingClient
from .const import (
DOMAIN,
FOLDER_PAUSED_RECEIVED,
@@ -86,14 +90,21 @@ class FolderSensor(SensorEntity):
"stateChanged": "state_changed",
}
def __init__(self, syncthing, server_id, folder_id, folder_label, version):
def __init__(
self,
syncthing: SyncthingClient,
server_id: str,
folder_id: str,
folder_label: str,
version: str,
) -> None:
"""Initialize the sensor."""
self._syncthing = syncthing
self._server_id = server_id
self._folder_id = folder_id
self._folder_label = folder_label
self._state = None
self._unsub_timer = None
self._state: dict[str, Any] | None = None
self._unsub_timer: CALLBACK_TYPE | None = None
self._short_server_id = server_id.split("-")[0]
self._attr_name = f"{self._short_server_id} {folder_id} {folder_label}"
@@ -107,9 +118,9 @@ class FolderSensor(SensorEntity):
)
@property
def native_value(self):
def native_value(self) -> str | None:
"""Return the state of the sensor."""
return self._state["state"]
return self._state["state"] if self._state else None
@property
def available(self) -> bool:
@@ -117,11 +128,11 @@ class FolderSensor(SensorEntity):
return self._state is not None
@property
def extra_state_attributes(self):
def extra_state_attributes(self) -> Mapping[str, Any] | None:
"""Return the state attributes."""
return self._state
async def async_update_status(self):
async def async_update_status(self) -> None:
"""Request folder status and update state."""
try:
state = await self._syncthing.database.status(self._folder_id)
@@ -131,11 +142,11 @@ class FolderSensor(SensorEntity):
self._state = self._filter_state(state)
self.async_write_ha_state()
def subscribe(self):
def subscribe(self) -> None:
"""Start polling syncthing folder status."""
if self._unsub_timer is None:
async def refresh(event_time):
async def refresh(event_time) -> None:
"""Get the latest data from Syncthing."""
await self.async_update_status()
@@ -144,7 +155,7 @@ class FolderSensor(SensorEntity):
)
@callback
def unsubscribe(self):
def unsubscribe(self) -> None:
"""Stop polling syncthing folder status."""
if self._unsub_timer is not None:
self._unsub_timer()
@@ -154,8 +165,9 @@ class FolderSensor(SensorEntity):
"""Handle entity which will be added."""
@callback
def handle_folder_summary(event):
if self._state is not None:
def handle_folder_summary(event: dict[str, Any]) -> None:
"""Handle folder summary event."""
if self._state:
self._state = self._filter_state(event["data"]["summary"])
self.async_write_ha_state()
@@ -168,8 +180,9 @@ class FolderSensor(SensorEntity):
)
@callback
def handle_state_changed(event):
if self._state is not None:
def handle_state_changed(event: dict[str, Any]) -> None:
"""Handle folder state changed event."""
if self._state:
self._state["state"] = event["data"]["to"]
self.async_write_ha_state()
@@ -182,8 +195,9 @@ class FolderSensor(SensorEntity):
)
@callback
def handle_folder_paused(event):
if self._state is not None:
def handle_folder_paused(event: dict[str, Any]) -> None:
"""Handle folder paused event."""
if self._state:
self._state["state"] = "paused"
self.async_write_ha_state()
@@ -196,7 +210,8 @@ class FolderSensor(SensorEntity):
)
@callback
def handle_server_unavailable():
def handle_server_unavailable() -> None:
"""Handle server becoming unavailable."""
self._state = None
self.unsubscribe()
self.async_write_ha_state()
@@ -209,7 +224,8 @@ class FolderSensor(SensorEntity):
)
)
async def handle_server_available():
async def handle_server_available() -> None:
"""Handle server becoming available."""
self.subscribe()
await self.async_update_status()
@@ -226,20 +242,20 @@ class FolderSensor(SensorEntity):
await self.async_update_status()
def _filter_state(self, state):
# Select only needed state attributes and map their names
state = {
def _filter_state(self, state: dict[str, Any]) -> dict[str, Any]:
"""Filter and map state attributes."""
filtered_state: dict[str, Any] = {
self.STATE_ATTRIBUTES[key]: value
for key, value in state.items()
if key in self.STATE_ATTRIBUTES
}
# A workaround, for some reason, state of paused folders is an empty string
if state["state"] == "":
state["state"] = "paused"
if filtered_state["state"] == "":
filtered_state["state"] = "paused"
# Add some useful attributes
state["id"] = self._folder_id
state["label"] = self._folder_label
filtered_state["id"] = self._folder_id
filtered_state["label"] = self._folder_label
return state
return filtered_state

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
import voluptuous as vol
@@ -54,23 +53,11 @@ from .schemas import (
from .template_entity import TemplateEntity
from .trigger_entity import TriggerEntity
_LOGGER = logging.getLogger(__name__)
OPEN_STATE = "open"
OPENING_STATE = "opening"
CLOSED_STATE = "closed"
CLOSING_STATE = "closing"
_VALID_STATES = [
OPEN_STATE,
OPENING_STATE,
CLOSED_STATE,
CLOSING_STATE,
"true",
"false",
"none",
]
CONF_POSITION = "position"
CONF_POSITION_TEMPLATE = "position_template"
CONF_TILT = "tilt"

View File

@@ -293,7 +293,6 @@ 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 | None:
def color_mode(self) -> ColorMode:
"""Return the color mode of the light."""
if self._fixed_color_mode:
return self._fixed_color_mode

View File

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

View File

@@ -0,0 +1,15 @@
{
"entity": {
"sensor": {
"actual_compressor_speed": {
"default": "mdi:speedometer"
},
"airflow_current_speed": {
"default": "mdi:fan"
},
"mode": {
"default": "mdi:gauge"
}
}
}
}

View File

@@ -18,89 +18,93 @@ from homeassistant.util import slugify
from . import UPDATE_TOPIC, WaterFurnaceConfigEntry, WaterFurnaceData
SENSORS = [
SensorEntityDescription(name="Furnace Mode", key="mode", icon="mdi:gauge"),
SensorEntityDescription(
name="Total Power",
key="mode",
translation_key="mode",
),
SensorEntityDescription(
key="totalunitpower",
translation_key="total_unit_power",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
name="Active Setpoint",
key="tstatactivesetpoint",
translation_key="tstat_active_setpoint",
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
name="Leaving Air",
key="leavingairtemp",
translation_key="leaving_air_temp",
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
name="Room Temp",
key="tstatroomtemp",
translation_key="room_temp",
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
name="Loop Temp",
key="enteringwatertemp",
translation_key="entering_water_temp",
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
name="Humidity Set Point",
key="tstathumidsetpoint",
icon="mdi:water-percent",
translation_key="tstat_humid_setpoint",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
name="Humidity",
key="tstatrelativehumidity",
icon="mdi:water-percent",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
name="Compressor Power",
key="compressorpower",
translation_key="compressor_power",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
name="Fan Power",
key="fanpower",
translation_key="fan_power",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
name="Aux Power",
key="auxpower",
translation_key="aux_power",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
name="Loop Pump Power",
key="looppumppower",
translation_key="loop_pump_power",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
name="Compressor Speed", key="actualcompressorspeed", icon="mdi:speedometer"
key="actualcompressorspeed",
translation_key="actual_compressor_speed",
),
SensorEntityDescription(
name="Fan Speed", key="airflowcurrentspeed", icon="mdi:fan"
key="airflowcurrentspeed",
translation_key="airflow_current_speed",
),
]
@@ -124,6 +128,7 @@ class WaterFurnaceSensor(SensorEntity):
"""Implementing the Waterfurnace sensor."""
_attr_should_poll = False
_attr_has_entity_name = True
def __init__(
self, client: WaterFurnaceData, description: SensorEntityDescription

View File

@@ -26,6 +26,49 @@
}
}
},
"entity": {
"sensor": {
"actual_compressor_speed": {
"name": "Compressor speed"
},
"airflow_current_speed": {
"name": "Fan speed"
},
"aux_power": {
"name": "Aux power"
},
"compressor_power": {
"name": "Compressor power"
},
"entering_water_temp": {
"name": "Loop temperature"
},
"fan_power": {
"name": "Fan power"
},
"leaving_air_temp": {
"name": "Leaving air temperature"
},
"loop_pump_power": {
"name": "Loop pump power"
},
"mode": {
"name": "Furnace mode"
},
"room_temp": {
"name": "Room temperature"
},
"total_unit_power": {
"name": "Total power"
},
"tstat_active_setpoint": {
"name": "Active setpoint"
},
"tstat_humid_setpoint": {
"name": "Humidity setpoint"
}
}
},
"issues": {
"deprecated_yaml_import_issue_cannot_connect": {
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, we could not connect to {integration_title}. Please check your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",

View File

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

View File

@@ -120,10 +120,6 @@ 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,
@@ -134,7 +130,6 @@ 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
@@ -180,17 +175,18 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
)
self._calculate_color_support()
self._attr_supported_color_modes = set()
if self._supports_rgbw:
self._supported_color_modes.add(ColorMode.RGBW)
self._attr_supported_color_modes.add(ColorMode.RGBW)
elif self._supports_color:
self._supported_color_modes.add(ColorMode.HS)
self._attr_supported_color_modes.add(ColorMode.HS)
if self._supports_color_temp:
self._supported_color_modes.add(ColorMode.COLOR_TEMP)
if not self._supported_color_modes:
self._attr_supported_color_modes.add(ColorMode.COLOR_TEMP)
if not self._attr_supported_color_modes:
if self.info.primary_value.command_class == CommandClass.SWITCH_BINARY:
self._supported_color_modes.add(ColorMode.ONOFF)
self._attr_supported_color_modes.add(ColorMode.ONOFF)
else:
self._supported_color_modes.add(ColorMode.BRIGHTNESS)
self._attr_supported_color_modes.add(ColorMode.BRIGHTNESS)
self._calculate_color_values()
# Entity class attributes
@@ -225,11 +221,6 @@ 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)."""
@@ -239,26 +230,6 @@ 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."""
@@ -479,9 +450,9 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
# Default: Brightness (no color) or Unknown
if self.supported_color_modes == {ColorMode.BRIGHTNESS}:
self._color_mode = ColorMode.BRIGHTNESS
self._attr_color_mode = ColorMode.BRIGHTNESS
else:
self._color_mode = ColorMode.UNKNOWN
self._attr_color_mode = ColorMode.UNKNOWN
# RGB support
if red_val and green_val and blue_val:
@@ -491,9 +462,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._hs_color = color_util.color_RGB_to_hs(red, green, blue)
self._attr_hs_color = color_util.color_RGB_to_hs(red, green, blue)
# Light supports color, set color mode to hs
self._color_mode = ColorMode.HS
self._attr_color_mode = ColorMode.HS
# color temperature support
if ww_val and cw_val:
@@ -501,14 +472,16 @@ 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._color_temp = color_util.color_temperature_mired_to_kelvin(
MAX_MIREDS
- ((cast(int, cold_white) / 255) * (MAX_MIREDS - MIN_MIREDS))
self._attr_color_temp_kelvin = (
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._color_mode = ColorMode.COLOR_TEMP
self._attr_color_mode = ColorMode.COLOR_TEMP
else:
self._color_temp = None
self._attr_color_temp_kelvin = 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)
@@ -519,9 +492,9 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
and blue is not None
and white is not None
)
self._rgbw_color = (red, green, blue, white)
self._attr_rgbw_color = (red, green, blue, white)
# Light supports rgbw, set color mode to rgbw
self._color_mode = ColorMode.RGBW
self._attr_color_mode = ColorMode.RGBW
# only one white channel (cool white) = rgbw support
elif cw_val:
self._supports_rgbw = True
@@ -533,9 +506,9 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
and blue is not None
and white is not None
)
self._rgbw_color = (red, green, blue, white)
self._attr_rgbw_color = (red, green, blue, white)
# Light supports rgbw, set color mode to rgbw
self._color_mode = ColorMode.RGBW
self._attr_color_mode = ColorMode.RGBW
class ZwaveColorOnOffLight(ZwaveLight):
@@ -620,8 +593,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._color_mode == ColorMode.HS:
hs_color = self._hs_color
elif hs_color is None and self._attr_color_mode == ColorMode.HS:
hs_color = self._attr_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

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

View File

@@ -5583,6 +5583,12 @@
"config_flow": false,
"iot_class": "cloud_polling"
},
"redgtech": {
"name": "Redgtech",
"integration_type": "service",
"config_flow": true,
"iot_class": "cloud_polling"
},
"refoss": {
"name": "Refoss",
"integration_type": "hub",

View File

@@ -37,7 +37,7 @@ fnv-hash-fast==1.6.0
go2rtc-client==0.4.0
ha-ffmpeg==3.2.2
habluetooth==5.8.0
hass-nabucasa==1.12.0
hass-nabucasa==1.13.0
hassil==3.5.0
home-assistant-bluetooth==1.13.1
home-assistant-frontend==20260128.6

10
mypy.ini generated
View File

@@ -4106,6 +4106,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.redgtech.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.remember_the_milk.*]
check_untyped_defs = true
disallow_incomplete_defs = true

View File

@@ -51,7 +51,7 @@ dependencies = [
"fnv-hash-fast==1.6.0",
# hass-nabucasa is imported by helpers which don't depend on the cloud
# integration
"hass-nabucasa==1.12.0",
"hass-nabucasa==1.13.0",
# When bumping httpx, please check the version pins of
# httpcore, anyio, and h11 in gen_requirements_all
"httpx==0.28.1",

2
requirements.txt generated
View File

@@ -25,7 +25,7 @@ cronsim==2.7
cryptography==46.0.2
fnv-hash-fast==1.6.0
ha-ffmpeg==3.2.2
hass-nabucasa==1.12.0
hass-nabucasa==1.13.0
hassil==3.5.0
home-assistant-bluetooth==1.13.1
home-assistant-intents==2026.1.28

13
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.1
denonavr==1.3.2
# 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.0.6
evohome-async==1.1.3
# homeassistant.components.bryant_evolution
evolutionhttp==0.0.18
@@ -1105,7 +1105,7 @@ google-nest-sdm==9.1.2
google-photos-library-api==0.12.1
# homeassistant.components.google_air_quality
google_air_quality_api==3.0.0
google_air_quality_api==3.0.1
# homeassistant.components.slide
# homeassistant.components.slide_local
@@ -1175,7 +1175,7 @@ habluetooth==5.8.0
hanna-cloud==0.0.7
# homeassistant.components.cloud
hass-nabucasa==1.12.0
hass-nabucasa==1.13.0
# homeassistant.components.splunk
hass-splunk==0.1.1
@@ -2434,7 +2434,7 @@ pysma==1.1.0
pysmappee==0.2.29
# homeassistant.components.smarla
pysmarlaapi==0.9.3
pysmarlaapi==0.13.0
# homeassistant.components.smartthings
pysmartthings==3.5.1
@@ -2744,6 +2744,9 @@ rapt-ble==0.1.2
# homeassistant.components.raspyrfm
raspyrfm-client==1.2.9
# homeassistant.components.redgtech
redgtech-api==0.1.38
# homeassistant.components.refoss
refoss-ha==1.2.5

View File

@@ -706,7 +706,7 @@ deluge-client==1.10.2
demetriek==1.3.0
# homeassistant.components.denonavr
denonavr==1.3.1
denonavr==1.3.2
# 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.0.6
evohome-async==1.1.3
# homeassistant.components.bryant_evolution
evolutionhttp==0.0.18
@@ -981,7 +981,7 @@ google-nest-sdm==9.1.2
google-photos-library-api==0.12.1
# homeassistant.components.google_air_quality
google_air_quality_api==3.0.0
google_air_quality_api==3.0.1
# homeassistant.components.slide
# homeassistant.components.slide_local
@@ -1045,7 +1045,7 @@ habluetooth==5.8.0
hanna-cloud==0.0.7
# homeassistant.components.cloud
hass-nabucasa==1.12.0
hass-nabucasa==1.13.0
# homeassistant.components.assist_satellite
# homeassistant.components.conversation
@@ -2060,7 +2060,7 @@ pysma==1.1.0
pysmappee==0.2.29
# homeassistant.components.smarla
pysmarlaapi==0.9.3
pysmarlaapi==0.13.0
# homeassistant.components.smartthings
pysmartthings==3.5.1
@@ -2310,6 +2310,9 @@ radiotherm==2.1.0
# homeassistant.components.rapt_ble
rapt-ble==0.1.2
# homeassistant.components.redgtech
redgtech-api==0.1.38
# homeassistant.components.refoss
refoss-ha==1.2.5

View File

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

View File

@@ -188,3 +188,69 @@ 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,6 +367,57 @@ 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,7 +814,9 @@ async def test_put_light_state(
# mock light.turn_on call
attributes = hass.states.get("light.ceiling_lights").attributes
supported_features = attributes[ATTR_SUPPORTED_FEATURES] | light.SUPPORT_TRANSITION
supported_features = (
attributes[ATTR_SUPPORTED_FEATURES] | light.LightEntityFeature.TRANSITION
)
attributes = {**attributes, ATTR_SUPPORTED_FEATURES: supported_features}
hass.states.async_set("light.ceiling_lights", STATE_ON, attributes)
call_turn_on = async_mock_service(hass, "light", "turn_on")

View File

@@ -168,7 +168,7 @@ async def setup_evohome(
"evohomeasync2.auth.CredentialsManagerBase._post_request",
mock_post_request(install),
),
patch("evohome.auth.AbstractAuth._make_request", mock_make_request(install)),
patch("_evohome.auth.AbstractAuth._make_request", mock_make_request(install)),
):
evo: EvohomeClient | None = None

View File

@@ -31,13 +31,9 @@ _MSG_USR = (
"special characters accepted via the vendor's website are not valid here."
)
LOG_HINT_429_CREDS = ("evohome.credentials", logging.ERROR, _MSG_429)
LOG_HINT_OTH_CREDS = ("evohome.credentials", logging.ERROR, _MSG_OTH)
LOG_HINT_USR_CREDS = ("evohome.credentials", logging.ERROR, _MSG_USR)
LOG_HINT_429_AUTH = ("evohome.auth", logging.ERROR, _MSG_429)
LOG_HINT_OTH_AUTH = ("evohome.auth", logging.ERROR, _MSG_OTH)
LOG_HINT_USR_AUTH = ("evohome.auth", logging.ERROR, _MSG_USR)
LOG_HINT_429_AUTH = ("evohomeasync2.auth", logging.ERROR, _MSG_429)
LOG_HINT_OTH_AUTH = ("evohomeasync2.auth", logging.ERROR, _MSG_OTH)
LOG_HINT_USR_AUTH = ("evohomeasync2.auth", logging.ERROR, _MSG_USR)
LOG_FAIL_CONNECTION = (
"homeassistant.components.evohome",
@@ -110,10 +106,10 @@ EXC_BAD_GATEWAY = aiohttp.ClientResponseError(
)
AUTHENTICATION_TESTS: dict[Exception, list] = {
EXC_BAD_CONNECTION: [LOG_HINT_OTH_CREDS, LOG_FAIL_CONNECTION, LOG_SETUP_FAILED],
EXC_BAD_CREDENTIALS: [LOG_HINT_USR_CREDS, LOG_FAIL_CREDENTIALS, LOG_SETUP_FAILED],
EXC_BAD_GATEWAY: [LOG_HINT_OTH_CREDS, LOG_FAIL_GATEWAY, LOG_SETUP_FAILED],
EXC_TOO_MANY_REQUESTS: [LOG_HINT_429_CREDS, LOG_FAIL_TOO_MANY, LOG_SETUP_FAILED],
EXC_BAD_CONNECTION: [LOG_HINT_OTH_AUTH, LOG_FAIL_CONNECTION, LOG_SETUP_FAILED],
EXC_BAD_CREDENTIALS: [LOG_HINT_USR_AUTH, LOG_FAIL_CREDENTIALS, LOG_SETUP_FAILED],
EXC_BAD_GATEWAY: [LOG_HINT_OTH_AUTH, LOG_FAIL_GATEWAY, LOG_SETUP_FAILED],
EXC_TOO_MANY_REQUESTS: [LOG_HINT_429_AUTH, LOG_FAIL_TOO_MANY, LOG_SETUP_FAILED],
}
CLIENT_REQUEST_TESTS: dict[Exception, list] = {
@@ -137,7 +133,8 @@ async def test_authentication_failure_v2(
with (
patch(
"evohome.credentials.CredentialsManagerBase._request", side_effect=exception
"_evohome.credentials.CredentialsManagerBase._request",
side_effect=exception,
),
caplog.at_level(logging.WARNING),
):
@@ -165,7 +162,7 @@ async def test_client_request_failure_v2(
"evohomeasync2.auth.CredentialsManagerBase._post_request",
mock_post_request("default"),
),
patch("evohome.auth.AbstractAuth._request", side_effect=exception),
patch("_evohome.auth.AbstractAuth._request", side_effect=exception),
caplog.at_level(logging.WARNING),
):
result = await async_setup_component(hass, DOMAIN, {DOMAIN: config})

View File

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

View File

@@ -1,7 +1,5 @@
"""The tests for the Light component."""
from types import ModuleType
from typing import Literal
from unittest.mock import MagicMock, mock_open, patch
import pytest
@@ -30,9 +28,6 @@ from tests.common import (
MockEntityPlatform,
MockUser,
async_mock_service,
help_test_all,
import_and_test_deprecated_constant,
import_and_test_deprecated_constant_enum,
setup_test_component_platform,
)
@@ -137,13 +132,10 @@ async def test_services(
ent3.supported_color_modes = [light.ColorMode.HS]
ent1.supported_features = light.LightEntityFeature.TRANSITION
ent2.supported_features = (
light.SUPPORT_COLOR
| light.LightEntityFeature.EFFECT
| light.LightEntityFeature.TRANSITION
light.LightEntityFeature.EFFECT | light.LightEntityFeature.TRANSITION
)
# Set color modes to none to trigger backwards compatibility in LightEntity
ent2.supported_color_modes = None
ent2.color_mode = None
ent2.supported_color_modes = [light.ColorMode.HS]
ent2.color_mode = light.ColorMode.HS
ent3.supported_features = (
light.LightEntityFeature.FLASH | light.LightEntityFeature.TRANSITION
)
@@ -903,16 +895,12 @@ async def test_light_brightness_step(hass: HomeAssistant) -> None:
setup_test_component_platform(hass, light.DOMAIN, entities)
entity0 = entities[0]
entity0.supported_features = light.SUPPORT_BRIGHTNESS
# Set color modes to none to trigger backwards compatibility in LightEntity
entity0.supported_color_modes = None
entity0.color_mode = None
entity0.supported_color_modes = {light.ColorMode.BRIGHTNESS}
entity0.color_mode = light.ColorMode.BRIGHTNESS
entity0.brightness = 100
entity1 = entities[1]
entity1.supported_features = light.SUPPORT_BRIGHTNESS
# Set color modes to none to trigger backwards compatibility in LightEntity
entity1.supported_color_modes = None
entity1.color_mode = None
entity1.supported_color_modes = {light.ColorMode.BRIGHTNESS}
entity1.color_mode = light.ColorMode.BRIGHTNESS
entity1.brightness = 50
assert await async_setup_component(hass, "light", {"light": {"platform": "test"}})
await hass.async_block_till_done()
@@ -955,10 +943,8 @@ async def test_light_brightness_step_pct(hass: HomeAssistant) -> None:
setup_test_component_platform(hass, light.DOMAIN, [entity])
entity.supported_features = light.SUPPORT_BRIGHTNESS
# Set color modes to none to trigger backwards compatibility in LightEntity
entity.supported_color_modes = None
entity.color_mode = None
entity.supported_color_modes = {light.ColorMode.BRIGHTNESS}
entity.color_mode = light.ColorMode.BRIGHTNESS
entity.brightness = 255
assert await async_setup_component(hass, "light", {"light": {"platform": "test"}})
await hass.async_block_till_done()
@@ -1000,10 +986,8 @@ async def test_light_brightness_pct_conversion(
setup_test_component_platform(hass, light.DOMAIN, mock_light_entities)
entity = mock_light_entities[0]
entity.supported_features = light.SUPPORT_BRIGHTNESS
# Set color modes to none to trigger backwards compatibility in LightEntity
entity.supported_color_modes = None
entity.color_mode = None
entity.supported_color_modes = {light.ColorMode.BRIGHTNESS}
entity.color_mode = light.ColorMode.BRIGHTNESS
entity.brightness = 100
assert await async_setup_component(hass, "light", {"light": {"platform": "test"}})
await hass.async_block_till_done()
@@ -1152,167 +1136,6 @@ invalid_no_brightness_no_color_no_transition,,,
assert invalid_profile_name not in profiles.data
@pytest.mark.parametrize("light_state", [STATE_ON, STATE_OFF])
async def test_light_backwards_compatibility_supported_color_modes(
hass: HomeAssistant, light_state: Literal["on", "off"]
) -> None:
"""Test supported_color_modes if not implemented by the entity."""
entities = [
MockLight("Test_0", light_state),
MockLight("Test_1", light_state),
MockLight("Test_2", light_state),
MockLight("Test_3", light_state),
MockLight("Test_4", light_state),
]
entity0 = entities[0]
entity1 = entities[1]
entity1.supported_features = light.SUPPORT_BRIGHTNESS
# Set color modes to none to trigger backwards compatibility in LightEntity
entity1.supported_color_modes = None
entity1.color_mode = None
entity2 = entities[2]
entity2.supported_features = light.SUPPORT_BRIGHTNESS | light.SUPPORT_COLOR_TEMP
# Set color modes to none to trigger backwards compatibility in LightEntity
entity2.supported_color_modes = None
entity2.color_mode = None
entity3 = entities[3]
entity3.supported_features = light.SUPPORT_BRIGHTNESS | light.SUPPORT_COLOR
# Set color modes to none to trigger backwards compatibility in LightEntity
entity3.supported_color_modes = None
entity3.color_mode = None
entity4 = entities[4]
entity4.supported_features = (
light.SUPPORT_BRIGHTNESS | light.SUPPORT_COLOR | light.SUPPORT_COLOR_TEMP
)
# Set color modes to none to trigger backwards compatibility in LightEntity
entity4.supported_color_modes = None
entity4.color_mode = None
setup_test_component_platform(hass, light.DOMAIN, entities)
assert await async_setup_component(hass, "light", {"light": {"platform": "test"}})
await hass.async_block_till_done()
state = hass.states.get(entity0.entity_id)
assert state.attributes["supported_color_modes"] == [light.ColorMode.ONOFF]
if light_state == STATE_OFF:
assert state.attributes["color_mode"] is None
else:
assert state.attributes["color_mode"] == light.ColorMode.ONOFF
state = hass.states.get(entity1.entity_id)
assert state.attributes["supported_color_modes"] == [light.ColorMode.BRIGHTNESS]
if light_state == STATE_OFF:
assert state.attributes["color_mode"] is None
else:
assert state.attributes["color_mode"] == light.ColorMode.UNKNOWN
state = hass.states.get(entity2.entity_id)
assert state.attributes["supported_color_modes"] == [light.ColorMode.COLOR_TEMP]
if light_state == STATE_OFF:
assert state.attributes["color_mode"] is None
else:
assert state.attributes["color_mode"] == light.ColorMode.UNKNOWN
state = hass.states.get(entity3.entity_id)
assert state.attributes["supported_color_modes"] == [light.ColorMode.HS]
if light_state == STATE_OFF:
assert state.attributes["color_mode"] is None
else:
assert state.attributes["color_mode"] == light.ColorMode.UNKNOWN
state = hass.states.get(entity4.entity_id)
assert state.attributes["supported_color_modes"] == [
light.ColorMode.COLOR_TEMP,
light.ColorMode.HS,
]
if light_state == STATE_OFF:
assert state.attributes["color_mode"] is None
else:
assert state.attributes["color_mode"] == light.ColorMode.UNKNOWN
async def test_light_backwards_compatibility_color_mode(hass: HomeAssistant) -> None:
"""Test color_mode if not implemented by the entity."""
entities = [
MockLight("Test_0", STATE_ON),
MockLight("Test_1", STATE_ON),
MockLight("Test_2", STATE_ON),
MockLight("Test_3", STATE_ON),
MockLight("Test_4", STATE_ON),
]
entity0 = entities[0]
entity1 = entities[1]
entity1.supported_features = light.SUPPORT_BRIGHTNESS
# Set color modes to none to trigger backwards compatibility in LightEntity
entity1.supported_color_modes = None
entity1.color_mode = None
entity1.brightness = 100
entity2 = entities[2]
entity2.supported_features = light.SUPPORT_BRIGHTNESS | light.SUPPORT_COLOR_TEMP
# Set color modes to none to trigger backwards compatibility in LightEntity
entity2.supported_color_modes = None
entity2.color_mode = None
entity2.color_temp_kelvin = 10000
entity3 = entities[3]
entity3.supported_features = light.SUPPORT_BRIGHTNESS | light.SUPPORT_COLOR
# Set color modes to none to trigger backwards compatibility in LightEntity
entity3.supported_color_modes = None
entity3.color_mode = None
entity3.hs_color = (240, 100)
entity4 = entities[4]
entity4.supported_features = (
light.SUPPORT_BRIGHTNESS | light.SUPPORT_COLOR | light.SUPPORT_COLOR_TEMP
)
# Set color modes to none to trigger backwards compatibility in LightEntity
entity4.supported_color_modes = None
entity4.color_mode = None
entity4.hs_color = (240, 100)
entity4.color_temp_kelvin = 10000
setup_test_component_platform(hass, light.DOMAIN, entities)
assert await async_setup_component(hass, "light", {"light": {"platform": "test"}})
await hass.async_block_till_done()
state = hass.states.get(entity0.entity_id)
assert state.attributes["supported_color_modes"] == [light.ColorMode.ONOFF]
assert state.attributes["color_mode"] == light.ColorMode.ONOFF
state = hass.states.get(entity1.entity_id)
assert state.attributes["supported_color_modes"] == [light.ColorMode.BRIGHTNESS]
assert state.attributes["color_mode"] == light.ColorMode.BRIGHTNESS
state = hass.states.get(entity2.entity_id)
assert state.attributes["supported_color_modes"] == [light.ColorMode.COLOR_TEMP]
assert state.attributes["color_mode"] == light.ColorMode.COLOR_TEMP
assert state.attributes["rgb_color"] == (202, 218, 255)
assert state.attributes["hs_color"] == (221.575, 20.9)
assert state.attributes["xy_color"] == (0.278, 0.287)
state = hass.states.get(entity3.entity_id)
assert state.attributes["supported_color_modes"] == [light.ColorMode.HS]
assert state.attributes["color_mode"] == light.ColorMode.HS
state = hass.states.get(entity4.entity_id)
assert state.attributes["supported_color_modes"] == [
light.ColorMode.COLOR_TEMP,
light.ColorMode.HS,
]
# hs color prioritized over color_temp, light should report mode ColorMode.HS
assert state.attributes["color_mode"] == light.ColorMode.HS
async def test_light_service_call_rgbw(hass: HomeAssistant) -> None:
"""Test rgbw functionality in service calls."""
entity0 = MockLight("Test_rgbw", STATE_ON)
@@ -1478,7 +1301,6 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
MockLight("Test_rgb", STATE_ON),
MockLight("Test_xy", STATE_ON),
MockLight("Test_all", STATE_ON),
MockLight("Test_legacy", STATE_ON),
MockLight("Test_rgbw", STATE_ON),
MockLight("Test_rgbww", STATE_ON),
MockLight("Test_temperature", STATE_ON),
@@ -1502,19 +1324,13 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
}
entity4 = entities[4]
entity4.supported_features = light.SUPPORT_COLOR
# Set color modes to none to trigger backwards compatibility in LightEntity
entity4.supported_color_modes = None
entity4.color_mode = None
entity4.supported_color_modes = {light.ColorMode.RGBW}
entity5 = entities[5]
entity5.supported_color_modes = {light.ColorMode.RGBW}
entity5.supported_color_modes = {light.ColorMode.RGBWW}
entity6 = entities[6]
entity6.supported_color_modes = {light.ColorMode.RGBWW}
entity7 = entities[7]
entity7.supported_color_modes = {light.ColorMode.COLOR_TEMP}
entity6.supported_color_modes = {light.ColorMode.COLOR_TEMP}
assert await async_setup_component(hass, "light", {"light": {"platform": "test"}})
await hass.async_block_till_done()
@@ -1536,15 +1352,12 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
]
state = hass.states.get(entity4.entity_id)
assert state.attributes["supported_color_modes"] == [light.ColorMode.HS]
state = hass.states.get(entity5.entity_id)
assert state.attributes["supported_color_modes"] == [light.ColorMode.RGBW]
state = hass.states.get(entity6.entity_id)
state = hass.states.get(entity5.entity_id)
assert state.attributes["supported_color_modes"] == [light.ColorMode.RGBWW]
state = hass.states.get(entity7.entity_id)
state = hass.states.get(entity6.entity_id)
assert state.attributes["supported_color_modes"] == [light.ColorMode.COLOR_TEMP]
await hass.services.async_call(
@@ -1559,7 +1372,6 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
entity4.entity_id,
entity5.entity_id,
entity6.entity_id,
entity7.entity_id,
],
"brightness_pct": 100,
"hs_color": (240, 100),
@@ -1575,12 +1387,10 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
_, data = entity3.last_call("turn_on")
assert data == {"brightness": 255, "hs_color": (240.0, 100.0)}
_, data = entity4.last_call("turn_on")
assert data == {"brightness": 255, "hs_color": (240.0, 100.0)}
_, data = entity5.last_call("turn_on")
assert data == {"brightness": 255, "rgbw_color": (0, 0, 255, 0)}
_, data = entity6.last_call("turn_on")
_, data = entity5.last_call("turn_on")
assert data == {"brightness": 255, "rgbww_color": (0, 0, 255, 0, 0)}
_, data = entity7.last_call("turn_on")
_, data = entity6.last_call("turn_on")
assert data == {"brightness": 255, "color_temp_kelvin": 1739}
await hass.services.async_call(
@@ -1595,7 +1405,6 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
entity4.entity_id,
entity5.entity_id,
entity6.entity_id,
entity7.entity_id,
],
"brightness_pct": 100,
"hs_color": (240, 0),
@@ -1611,13 +1420,11 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
_, data = entity3.last_call("turn_on")
assert data == {"brightness": 255, "hs_color": (240.0, 0.0)}
_, data = entity4.last_call("turn_on")
assert data == {"brightness": 255, "hs_color": (240.0, 0.0)}
_, data = entity5.last_call("turn_on")
assert data == {"brightness": 255, "rgbw_color": (0, 0, 0, 255)}
_, data = entity6.last_call("turn_on")
_, data = entity5.last_call("turn_on")
# The midpoint of the white channels is warm, compensated by adding green + blue
assert data == {"brightness": 255, "rgbww_color": (0, 76, 141, 255, 255)}
_, data = entity7.last_call("turn_on")
_, data = entity6.last_call("turn_on")
assert data == {"brightness": 255, "color_temp_kelvin": 5962}
await hass.services.async_call(
@@ -1632,7 +1439,6 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
entity4.entity_id,
entity5.entity_id,
entity6.entity_id,
entity7.entity_id,
],
"brightness_pct": 50,
"rgb_color": (128, 0, 0),
@@ -1648,12 +1454,10 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
_, data = entity3.last_call("turn_on")
assert data == {"brightness": 128, "rgb_color": (128, 0, 0)}
_, data = entity4.last_call("turn_on")
assert data == {"brightness": 128, "hs_color": (0.0, 100.0)}
_, data = entity5.last_call("turn_on")
assert data == {"brightness": 128, "rgbw_color": (128, 0, 0, 0)}
_, data = entity6.last_call("turn_on")
_, data = entity5.last_call("turn_on")
assert data == {"brightness": 128, "rgbww_color": (128, 0, 0, 0, 0)}
_, data = entity7.last_call("turn_on")
_, data = entity6.last_call("turn_on")
assert data == {"brightness": 128, "color_temp_kelvin": 6279}
await hass.services.async_call(
@@ -1668,7 +1472,6 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
entity4.entity_id,
entity5.entity_id,
entity6.entity_id,
entity7.entity_id,
],
"brightness_pct": 50,
"rgb_color": (255, 255, 255),
@@ -1684,13 +1487,11 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
_, data = entity3.last_call("turn_on")
assert data == {"brightness": 128, "rgb_color": (255, 255, 255)}
_, data = entity4.last_call("turn_on")
assert data == {"brightness": 128, "hs_color": (0.0, 0.0)}
_, data = entity5.last_call("turn_on")
assert data == {"brightness": 128, "rgbw_color": (0, 0, 0, 255)}
_, data = entity6.last_call("turn_on")
_, data = entity5.last_call("turn_on")
# The midpoint the white channels is warm, compensated by adding green + blue
assert data == {"brightness": 128, "rgbww_color": (0, 76, 141, 255, 255)}
_, data = entity7.last_call("turn_on")
_, data = entity6.last_call("turn_on")
assert data == {"brightness": 128, "color_temp_kelvin": 5962}
await hass.services.async_call(
@@ -1705,7 +1506,6 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
entity4.entity_id,
entity5.entity_id,
entity6.entity_id,
entity7.entity_id,
],
"brightness_pct": 50,
"xy_color": (0.1, 0.8),
@@ -1721,12 +1521,10 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
_, data = entity3.last_call("turn_on")
assert data == {"brightness": 128, "xy_color": (0.1, 0.8)}
_, data = entity4.last_call("turn_on")
assert data == {"brightness": 128, "hs_color": (125.176, 100.0)}
_, data = entity5.last_call("turn_on")
assert data == {"brightness": 128, "rgbw_color": (0, 255, 22, 0)}
_, data = entity6.last_call("turn_on")
_, data = entity5.last_call("turn_on")
assert data == {"brightness": 128, "rgbww_color": (0, 255, 22, 0, 0)}
_, data = entity7.last_call("turn_on")
_, data = entity6.last_call("turn_on")
assert data == {"brightness": 128, "color_temp_kelvin": 8645}
await hass.services.async_call(
@@ -1741,7 +1539,6 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
entity4.entity_id,
entity5.entity_id,
entity6.entity_id,
entity7.entity_id,
],
"brightness_pct": 50,
"xy_color": (0.323, 0.329),
@@ -1757,13 +1554,11 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
_, data = entity3.last_call("turn_on")
assert data == {"brightness": 128, "xy_color": (0.323, 0.329)}
_, data = entity4.last_call("turn_on")
assert data == {"brightness": 128, "hs_color": (0.0, 0.392)}
_, data = entity5.last_call("turn_on")
assert data == {"brightness": 128, "rgbw_color": (1, 0, 0, 255)}
_, data = entity6.last_call("turn_on")
_, data = entity5.last_call("turn_on")
# The midpoint the white channels is warm, compensated by adding green + blue
assert data == {"brightness": 128, "rgbww_color": (0, 75, 140, 255, 255)}
_, data = entity7.last_call("turn_on")
_, data = entity6.last_call("turn_on")
assert data == {"brightness": 128, "color_temp_kelvin": 5962}
await hass.services.async_call(
@@ -1778,7 +1573,6 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
entity4.entity_id,
entity5.entity_id,
entity6.entity_id,
entity7.entity_id,
],
"brightness_pct": 50,
"rgbw_color": (128, 0, 0, 64),
@@ -1794,13 +1588,11 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
_, data = entity3.last_call("turn_on")
assert data == {"brightness": 128, "rgb_color": (128, 43, 43)}
_, data = entity4.last_call("turn_on")
assert data == {"brightness": 128, "hs_color": (0.0, 66.406)}
_, data = entity5.last_call("turn_on")
assert data == {"brightness": 128, "rgbw_color": (128, 0, 0, 64)}
_, data = entity6.last_call("turn_on")
_, data = entity5.last_call("turn_on")
# The midpoint the white channels is warm, compensated by adding green + blue
assert data == {"brightness": 128, "rgbww_color": (128, 0, 30, 117, 117)}
_, data = entity7.last_call("turn_on")
_, data = entity6.last_call("turn_on")
assert data == {"brightness": 128, "color_temp_kelvin": 3011}
await hass.services.async_call(
@@ -1815,7 +1607,6 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
entity4.entity_id,
entity5.entity_id,
entity6.entity_id,
entity7.entity_id,
],
"brightness_pct": 50,
"rgbw_color": (255, 255, 255, 255),
@@ -1831,13 +1622,11 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
_, data = entity3.last_call("turn_on")
assert data == {"brightness": 128, "rgb_color": (255, 255, 255)}
_, data = entity4.last_call("turn_on")
assert data == {"brightness": 128, "hs_color": (0.0, 0.0)}
_, data = entity5.last_call("turn_on")
assert data == {"brightness": 128, "rgbw_color": (255, 255, 255, 255)}
_, data = entity6.last_call("turn_on")
_, data = entity5.last_call("turn_on")
# The midpoint the white channels is warm, compensated by adding green + blue
assert data == {"brightness": 128, "rgbww_color": (0, 76, 141, 255, 255)}
_, data = entity7.last_call("turn_on")
_, data = entity6.last_call("turn_on")
assert data == {"brightness": 128, "color_temp_kelvin": 5962}
await hass.services.async_call(
@@ -1852,7 +1641,6 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
entity4.entity_id,
entity5.entity_id,
entity6.entity_id,
entity7.entity_id,
],
"brightness_pct": 50,
"rgbww_color": (128, 0, 0, 64, 32),
@@ -1868,12 +1656,10 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
_, data = entity3.last_call("turn_on")
assert data == {"brightness": 128, "rgb_color": (128, 33, 26)}
_, data = entity4.last_call("turn_on")
assert data == {"brightness": 128, "hs_color": (4.118, 79.688)}
_, data = entity5.last_call("turn_on")
assert data == {"brightness": 128, "rgbw_color": (128, 9, 0, 33)}
_, data = entity6.last_call("turn_on")
_, data = entity5.last_call("turn_on")
assert data == {"brightness": 128, "rgbww_color": (128, 0, 0, 64, 32)}
_, data = entity7.last_call("turn_on")
_, data = entity6.last_call("turn_on")
assert data == {"brightness": 128, "color_temp_kelvin": 3845}
await hass.services.async_call(
@@ -1888,7 +1674,6 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
entity4.entity_id,
entity5.entity_id,
entity6.entity_id,
entity7.entity_id,
],
"brightness_pct": 50,
"rgbww_color": (255, 255, 255, 255, 255),
@@ -1904,13 +1689,11 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
_, data = entity3.last_call("turn_on")
assert data == {"brightness": 128, "rgb_color": (255, 217, 185)}
_, data = entity4.last_call("turn_on")
assert data == {"brightness": 128, "hs_color": (27.429, 27.451)}
_, data = entity5.last_call("turn_on")
# The midpoint the white channels is warm, compensated by decreasing green + blue
assert data == {"brightness": 128, "rgbw_color": (96, 44, 0, 255)}
_, data = entity6.last_call("turn_on")
_, data = entity5.last_call("turn_on")
assert data == {"brightness": 128, "rgbww_color": (255, 255, 255, 255, 255)}
_, data = entity7.last_call("turn_on")
_, data = entity6.last_call("turn_on")
assert data == {"brightness": 128, "color_temp_kelvin": 3451}
@@ -1923,7 +1706,6 @@ async def test_light_service_call_color_conversion_named_tuple(
MockLight("Test_rgb", STATE_ON),
MockLight("Test_xy", STATE_ON),
MockLight("Test_all", STATE_ON),
MockLight("Test_legacy", STATE_ON),
MockLight("Test_rgbw", STATE_ON),
MockLight("Test_rgbww", STATE_ON),
]
@@ -1946,16 +1728,10 @@ async def test_light_service_call_color_conversion_named_tuple(
}
entity4 = entities[4]
entity4.supported_features = light.SUPPORT_COLOR
# Set color modes to none to trigger backwards compatibility in LightEntity
entity4.supported_color_modes = None
entity4.color_mode = None
entity4.supported_color_modes = {light.ColorMode.RGBW}
entity5 = entities[5]
entity5.supported_color_modes = {light.ColorMode.RGBW}
entity6 = entities[6]
entity6.supported_color_modes = {light.ColorMode.RGBWW}
entity5.supported_color_modes = {light.ColorMode.RGBWW}
assert await async_setup_component(hass, "light", {"light": {"platform": "test"}})
await hass.async_block_till_done()
@@ -1971,7 +1747,6 @@ async def test_light_service_call_color_conversion_named_tuple(
entity3.entity_id,
entity4.entity_id,
entity5.entity_id,
entity6.entity_id,
],
"brightness_pct": 25,
"rgb_color": color_util.RGBColor(128, 0, 0),
@@ -1987,10 +1762,8 @@ async def test_light_service_call_color_conversion_named_tuple(
_, data = entity3.last_call("turn_on")
assert data == {"brightness": 64, "rgb_color": (128, 0, 0)}
_, data = entity4.last_call("turn_on")
assert data == {"brightness": 64, "hs_color": (0.0, 100.0)}
_, data = entity5.last_call("turn_on")
assert data == {"brightness": 64, "rgbw_color": (128, 0, 0, 0)}
_, data = entity6.last_call("turn_on")
_, data = entity5.last_call("turn_on")
assert data == {"brightness": 64, "rgbww_color": (128, 0, 0, 0, 0)}
@@ -2327,7 +2100,6 @@ async def test_light_state_color_conversion(hass: HomeAssistant) -> None:
MockLight("Test_hs", STATE_ON),
MockLight("Test_rgb", STATE_ON),
MockLight("Test_xy", STATE_ON),
MockLight("Test_legacy", STATE_ON),
]
setup_test_component_platform(hass, light.DOMAIN, entities)
@@ -2352,13 +2124,6 @@ async def test_light_state_color_conversion(hass: HomeAssistant) -> None:
entity2.rgb_color = "Invalid" # Should be ignored
entity2.xy_color = (0.1, 0.8)
entity3 = entities[3]
entity3.hs_color = (240, 100)
entity3.supported_features = light.SUPPORT_COLOR
# Set color modes to none to trigger backwards compatibility in LightEntity
entity3.supported_color_modes = None
entity3.color_mode = None
assert await async_setup_component(hass, "light", {"light": {"platform": "test"}})
await hass.async_block_till_done()
@@ -2380,12 +2145,6 @@ async def test_light_state_color_conversion(hass: HomeAssistant) -> None:
assert state.attributes["rgb_color"] == (0, 255, 22)
assert state.attributes["xy_color"] == (0.1, 0.8)
state = hass.states.get(entity3.entity_id)
assert state.attributes["color_mode"] == light.ColorMode.HS
assert state.attributes["hs_color"] == (240, 100)
assert state.attributes["rgb_color"] == (0, 0, 255)
assert state.attributes["xy_color"] == (0.136, 0.04)
async def test_services_filter_parameters(
hass: HomeAssistant,
@@ -2620,31 +2379,6 @@ def test_filter_supported_color_modes() -> None:
assert light.filter_supported_color_modes(supported) == {light.ColorMode.BRIGHTNESS}
def test_deprecated_supported_features_ints(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
"""Test deprecated supported features ints."""
class MockLightEntityEntity(light.LightEntity):
@property
def supported_features(self) -> int:
"""Return supported features."""
return 1
entity = MockLightEntityEntity()
entity.hass = hass
entity.platform = MockEntityPlatform(hass, domain="test", platform_name="test")
assert entity.supported_features_compat is light.LightEntityFeature(1)
assert "MockLightEntityEntity" in caplog.text
assert "is using deprecated supported features values" in caplog.text
assert "Instead it should use" in caplog.text
assert "LightEntityFeature" in caplog.text
assert "and color modes" in caplog.text
caplog.clear()
assert entity.supported_features_compat is light.LightEntityFeature(1)
assert "is using deprecated supported features values" not in caplog.text
@pytest.mark.parametrize(
("color_mode", "supported_color_modes", "warning_expected"),
[
@@ -2871,46 +2605,3 @@ def test_missing_kelvin_property_warnings(
assert state.attributes[light.ATTR_MIN_COLOR_TEMP_KELVIN] == expected_values[0]
assert state.attributes[light.ATTR_MAX_COLOR_TEMP_KELVIN] == expected_values[1]
@pytest.mark.parametrize(
"module",
[light],
)
def test_all(module: ModuleType) -> None:
"""Test module.__all__ is correctly set."""
help_test_all(module)
@pytest.mark.parametrize(
("constant_name", "constant_value", "constant_replacement"),
[
("SUPPORT_BRIGHTNESS", 1, "supported_color_modes"),
("SUPPORT_COLOR_TEMP", 2, "supported_color_modes"),
("SUPPORT_COLOR", 16, "supported_color_modes"),
],
)
def test_deprecated_light_constants(
caplog: pytest.LogCaptureFixture,
constant_name: str,
constant_value: int | str,
constant_replacement: str,
) -> None:
"""Test deprecated light constants."""
import_and_test_deprecated_constant(
caplog, light, constant_name, constant_replacement, constant_value, "2026.1"
)
@pytest.mark.parametrize(
"entity_feature",
list(light.LightEntityFeature),
)
def test_deprecated_support_light_constants_enums(
caplog: pytest.LogCaptureFixture,
entity_feature: light.LightEntityFeature,
) -> None:
"""Test deprecated support light constants."""
import_and_test_deprecated_constant_enum(
caplog, light, entity_feature, "SUPPORT_", "2026.1"
)

View File

@@ -174,7 +174,9 @@ async def test_rgb_light(
assert state.state == STATE_UNKNOWN
color_modes = [light.ColorMode.HS]
assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes
expected_features = light.SUPPORT_FLASH | light.SUPPORT_TRANSITION
expected_features = (
light.LightEntityFeature.FLASH | light.LightEntityFeature.TRANSITION
)
assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features

View File

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

View File

@@ -0,0 +1,70 @@
"""Test fixtures for Redgtech integration."""
from __future__ import annotations
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from homeassistant.components.redgtech.const import DOMAIN
from tests.common import MockConfigEntry
TEST_EMAIL = "test@example.com"
TEST_PASSWORD = "test_password"
@pytest.fixture
def mock_redgtech_api() -> Generator[MagicMock]:
"""Return a mocked Redgtech API client."""
with (
patch(
"homeassistant.components.redgtech.coordinator.RedgtechAPI", autospec=True
) as api_mock,
patch(
"homeassistant.components.redgtech.config_flow.RedgtechAPI",
new=api_mock,
),
):
api = api_mock.return_value
api.login = AsyncMock(return_value="mock_access_token")
api.get_data = AsyncMock(
return_value={
"boards": [
{
"endpointId": "switch_001",
"friendlyName": "Living Room Switch",
"value": False,
"displayCategories": ["SWITCH"],
},
{
"endpointId": "switch_002",
"friendlyName": "Kitchen Switch",
"value": True,
"displayCategories": ["SWITCH"],
},
{
"endpointId": "light_switch_001",
"friendlyName": "Bedroom Light Switch",
"value": False,
"displayCategories": ["LIGHT", "SWITCH"],
},
]
}
)
api.set_switch_state = AsyncMock()
yield api
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Mock config entry."""
return MockConfigEntry(
domain=DOMAIN,
data={"email": TEST_EMAIL, "password": TEST_PASSWORD},
title="Mock Title",
entry_id="test_entry",
)

View File

@@ -0,0 +1,99 @@
# serializer version: 1
# name: test_entities[switch.kitchen_switch-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.kitchen_switch',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'redgtech',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'switch_002',
'unit_of_measurement': None,
})
# ---
# name: test_entities[switch.kitchen_switch-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Kitchen Switch',
}),
'context': <ANY>,
'entity_id': 'switch.kitchen_switch',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_entities[switch.living_room_switch-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.living_room_switch',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'redgtech',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'switch_001',
'unit_of_measurement': None,
})
# ---
# name: test_entities[switch.living_room_switch-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Living Room Switch',
}),
'context': <ANY>,
'entity_id': 'switch.living_room_switch',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---

View File

@@ -0,0 +1,138 @@
"""Tests Config flow for the Redgtech integration."""
from unittest.mock import MagicMock
import pytest
from redgtech_api.api import RedgtechAuthError, RedgtechConnectionError
from homeassistant.components.redgtech.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
TEST_EMAIL = "test@example.com"
TEST_PASSWORD = "123456"
FAKE_TOKEN = "fake_token"
@pytest.mark.parametrize(
("side_effect", "expected_error"),
[
(RedgtechAuthError, "invalid_auth"),
(RedgtechConnectionError, "cannot_connect"),
(Exception("Generic error"), "unknown"),
],
)
async def test_user_step_errors(
hass: HomeAssistant,
mock_redgtech_api: MagicMock,
side_effect: type[Exception],
expected_error: str,
) -> None:
"""Test user step with various errors."""
user_input = {CONF_EMAIL: TEST_EMAIL, CONF_PASSWORD: TEST_PASSWORD}
mock_redgtech_api.login.side_effect = side_effect
mock_redgtech_api.login.return_value = None
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=user_input
)
assert result["type"] is FlowResultType.FORM
assert result["errors"]["base"] == expected_error
mock_redgtech_api.login.assert_called_once_with(TEST_EMAIL, TEST_PASSWORD)
async def test_user_step_creates_entry(
hass: HomeAssistant,
mock_redgtech_api: MagicMock,
) -> None:
"""Tests the correct creation of the entry in the configuration."""
user_input = {CONF_EMAIL: TEST_EMAIL, CONF_PASSWORD: TEST_PASSWORD}
mock_redgtech_api.login.reset_mock()
mock_redgtech_api.login.return_value = FAKE_TOKEN
mock_redgtech_api.login.side_effect = None
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=user_input
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == TEST_EMAIL
assert result["data"] == user_input
# Verify login was called at least once with correct parameters
mock_redgtech_api.login.assert_any_call(TEST_EMAIL, TEST_PASSWORD)
async def test_user_step_duplicate_entry(
hass: HomeAssistant,
mock_redgtech_api: MagicMock,
) -> None:
"""Test attempt to add duplicate entry."""
existing_entry = MockConfigEntry(
domain=DOMAIN,
unique_id=TEST_EMAIL,
data={CONF_EMAIL: TEST_EMAIL},
)
existing_entry.add_to_hass(hass)
user_input = {CONF_EMAIL: TEST_EMAIL, CONF_PASSWORD: TEST_PASSWORD}
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=user_input
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
mock_redgtech_api.login.assert_not_called()
@pytest.mark.parametrize(
("side_effect", "expected_error"),
[
(RedgtechAuthError, "invalid_auth"),
(RedgtechConnectionError, "cannot_connect"),
(Exception("Generic error"), "unknown"),
],
)
async def test_user_step_error_recovery(
hass: HomeAssistant,
mock_redgtech_api: MagicMock,
side_effect: Exception,
expected_error: str,
) -> None:
"""Test that the flow can recover from errors and complete successfully."""
user_input = {CONF_EMAIL: TEST_EMAIL, CONF_PASSWORD: TEST_PASSWORD}
# Reset mock to start fresh
mock_redgtech_api.login.reset_mock()
mock_redgtech_api.login.return_value = None
mock_redgtech_api.login.side_effect = None
# First attempt fails with error
mock_redgtech_api.login.side_effect = side_effect
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=user_input
)
assert result["type"] is FlowResultType.FORM
assert result["errors"]["base"] == expected_error
# Verify login was called at least once for the first attempt
assert mock_redgtech_api.login.call_count >= 1
first_call_count = mock_redgtech_api.login.call_count
# Second attempt succeeds - flow recovers
mock_redgtech_api.login.side_effect = None
mock_redgtech_api.login.return_value = FAKE_TOKEN
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=user_input
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == TEST_EMAIL
assert result["data"] == user_input
# Verify login was called again for the second attempt (recovery)
assert mock_redgtech_api.login.call_count > first_call_count

View File

@@ -0,0 +1,255 @@
"""Tests for the Redgtech switch platform."""
from datetime import timedelta
from unittest.mock import MagicMock
from freezegun import freeze_time
from freezegun.api import FrozenDateTimeFactory
import pytest
from redgtech_api.api import RedgtechAuthError, RedgtechConnectionError
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TOGGLE,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_UNAVAILABLE,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
@pytest.fixture
def freezer():
"""Provide a freezer fixture that works with freeze_time decorator."""
with freeze_time() as frozen_time:
yield frozen_time
@pytest.fixture
async def setup_redgtech_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_redgtech_api: MagicMock,
) -> MagicMock:
"""Set up the Redgtech integration with mocked API."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_redgtech_api
async def test_entities(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
setup_redgtech_integration,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test entity setup."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_switch_turn_on(
hass: HomeAssistant,
setup_redgtech_integration: MagicMock,
) -> None:
"""Test turning a switch on."""
mock_api = setup_redgtech_integration
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "switch.living_room_switch"},
blocking=True,
)
mock_api.set_switch_state.assert_called_once_with(
"switch_001", True, "mock_access_token"
)
async def test_switch_turn_off(
hass: HomeAssistant,
setup_redgtech_integration: MagicMock,
) -> None:
"""Test turning a switch off."""
mock_api = setup_redgtech_integration
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: "switch.kitchen_switch"},
blocking=True,
)
mock_api.set_switch_state.assert_called_once_with(
"switch_002", False, "mock_access_token"
)
async def test_switch_toggle(
hass: HomeAssistant,
setup_redgtech_integration: MagicMock,
) -> None:
"""Test toggling a switch."""
mock_api = setup_redgtech_integration
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TOGGLE,
{ATTR_ENTITY_ID: "switch.living_room_switch"},
blocking=True,
)
mock_api.set_switch_state.assert_called_once_with(
"switch_001", True, "mock_access_token"
)
@pytest.mark.parametrize(
("exception", "error_message"),
[
(
RedgtechConnectionError("Connection failed"),
"Connection error with Redgtech API",
),
(
RedgtechAuthError("Auth failed"),
"Authentication failed when controlling Redgtech switch",
),
],
)
async def test_exception_handling(
hass: HomeAssistant,
setup_redgtech_integration: MagicMock,
exception: Exception,
error_message: str,
) -> None:
"""Test exception handling when controlling switches."""
mock_api = setup_redgtech_integration
mock_api.set_switch_state.side_effect = exception
with pytest.raises(HomeAssistantError, match=error_message):
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "switch.living_room_switch"},
blocking=True,
)
async def test_switch_auth_error_with_retry(
hass: HomeAssistant,
setup_redgtech_integration: MagicMock,
) -> None:
"""Test handling auth errors with token renewal."""
mock_api = setup_redgtech_integration
# Mock fails with auth error
mock_api.set_switch_state.side_effect = RedgtechAuthError("Auth failed")
# Expect HomeAssistantError to be raised
with pytest.raises(
HomeAssistantError,
match="Authentication failed when controlling Redgtech switch",
):
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "switch.living_room_switch"},
blocking=True,
)
@freeze_time("2023-01-01 12:00:00")
async def test_coordinator_data_update_success(
hass: HomeAssistant,
setup_redgtech_integration: MagicMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test successful data update through coordinator."""
mock_api = setup_redgtech_integration
# Update mock data
mock_api.get_data.return_value = {
"boards": [
{
"endpointId": "switch_001",
"friendlyName": "Living Room Switch",
"value": True, # Changed to True
"displayCategories": ["SWITCH"],
}
]
}
# Use freezer to advance time and trigger update
freezer.tick(delta=timedelta(minutes=2))
await hass.async_block_till_done()
# Verify the entity state was updated successfully
living_room_state = hass.states.get("switch.living_room_switch")
assert living_room_state is not None
assert living_room_state.state == "on"
@freeze_time("2023-01-01 12:00:00")
async def test_coordinator_connection_error_during_update(
hass: HomeAssistant,
setup_redgtech_integration: MagicMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test coordinator handling connection errors during data updates."""
mock_api = setup_redgtech_integration
mock_api.get_data.side_effect = RedgtechConnectionError("Connection failed")
# Use freezer to advance time and trigger update
freezer.tick(delta=timedelta(minutes=2))
await hass.async_block_till_done()
# Verify entities become unavailable due to coordinator error
living_room_state = hass.states.get("switch.living_room_switch")
kitchen_state = hass.states.get("switch.kitchen_switch")
assert living_room_state.state == STATE_UNAVAILABLE
assert kitchen_state.state == STATE_UNAVAILABLE
@freeze_time("2023-01-01 12:00:00")
async def test_coordinator_auth_error_with_token_renewal(
hass: HomeAssistant,
setup_redgtech_integration: MagicMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test coordinator handling auth errors with token renewal."""
mock_api = setup_redgtech_integration
# First call fails with auth error, second succeeds after token renewal
mock_api.get_data.side_effect = [
RedgtechAuthError("Auth failed"),
{
"boards": [
{
"endpointId": "switch_001",
"friendlyName": "Living Room Switch",
"value": True,
"displayCategories": ["SWITCH"],
}
]
},
]
# Use freezer to advance time and trigger update
freezer.tick(delta=timedelta(minutes=2))
await hass.async_block_till_done()
# Verify token renewal was attempted
assert mock_api.login.call_count >= 2
# Verify entity is available after successful token renewal
living_room_state = hass.states.get("switch.living_room_switch")
assert living_room_state is not None
assert living_room_state.state != STATE_UNAVAILABLE