Compare commits

...

108 Commits

Author SHA1 Message Date
Paul Bottein
85336eadd6 Address copilot feedbacks 2026-02-05 14:08:09 +01:00
Paul Bottein
d037b2073d Add tests 2026-02-05 13:26:10 +01:00
Paul Bottein
ff3abb5b0b Refactor code 2026-02-05 12:28:40 +01:00
Paul Bottein
c014d32cac Group Overkiz sub-devices by placeOID 2026-02-05 12:23:09 +01:00
Krisjanis Lejejs
fa58fe5f4e Bump hass-nabucasa from 1.12.0 to 1.13.0 (#162274) 2026-02-05 11:03:44 +01:00
Petro31
46f230c487 Clean up unused cover constants (#162225) 2026-02-05 10:46:36 +01:00
epenet
13a987aba3 Cleanup deprecated SUPPORT_ light constants (#162210) 2026-02-05 10:32:32 +01:00
cdnninja
9cef323581 Update Vesync quality-scale to Bronze (#162260) 2026-02-05 09:44:47 +01:00
epenet
7ea7576188 Cleanup legacy support for extracting color modes from light supported features (#162265) 2026-02-05 09:33:22 +01:00
Franck Nijhof
f8abbfd42b Merge branch 'master' into dev 2026-02-05 08:17:24 +00:00
Erik Montnemery
5cd1821bc9 Update redgtech snapshots (#162267) 2026-02-05 09:13:13 +01:00
Norbert Rittel
2ef7f26ffb Improve description of camera.play_stream action (#162264) 2026-02-05 09:07:10 +01:00
Jonathan Sady do Nascimento
184bea49e2 Add redgtech integration (#136947)
Co-authored-by: luan-nvg <luannnvg@gmail.com>
2026-02-05 09:04:14 +01:00
David Bonnes
c853fb2068 Bump evohome-async to 1.1.3 (#162232) 2026-02-05 08:25:30 +01:00
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
Joost Lekkerkerker
afa0f572ce Add guard for Apple TV text focus state (#162207) 2026-02-04 19:34:53 +01:00
Simone Chemelli
a6a1b9ddbd Fix logic and tests for Alexa Devices utils module (#162223) 2026-02-04 19:31:57 +01:00
Robert Resch
c1f5b4593f Revert "Bump intents (#162205)" (#162226) 2026-02-04 19:30:05 +01:00
Kevin Stillhammer
f1de4dc1cc Filter out invalid trackers in fressnapf_tracker (#161670) 2026-02-04 17:43:24 +01:00
David Bonnes
4ae0d9a9c6 Fix evohome not updating scheduled setpoints in state attrs (#162043) 2026-02-04 17:37:41 +01:00
David Girón
fcd0b579cf Compress container image with zstd (#160665)
Co-authored-by: Robert Resch <robert@resch.dev>
2026-02-04 17:28:07 +01: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
65 changed files with 2661 additions and 673 deletions

View File

@@ -235,6 +235,7 @@ jobs:
build-args: |
BUILD_FROM=${{ steps.vars.outputs.base_image }}
tags: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
outputs: type=image,push=true,compression=zstd,compression-level=9,force-compression=true,oci-mediatypes=true
labels: |
io.hass.arch=${{ matrix.arch }}
io.hass.version=${{ needs.init.outputs.version }}

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

@@ -59,13 +59,15 @@ async def async_setup_entry(
coordinator = entry.runtime_data
# Replace unique id for "DND" switch and remove from Speaker Group
await async_update_unique_id(
hass, coordinator, SWITCH_DOMAIN, "do_not_disturb", "dnd"
)
# DND keys
old_key = "do_not_disturb"
new_key = "dnd"
# Remove DND switch from virtual groups
await async_remove_dnd_from_virtual_group(hass, coordinator)
# Remove old DND switch from virtual groups
await async_remove_dnd_from_virtual_group(hass, coordinator, old_key)
# Replace unique id for DND switch
await async_update_unique_id(hass, coordinator, SWITCH_DOMAIN, old_key, new_key)
known_devices: set[str] = set()

View File

@@ -54,7 +54,7 @@ def alexa_api_call[_T: AmazonEntity, **_P](
async def async_update_unique_id(
hass: HomeAssistant,
coordinator: AmazonDevicesCoordinator,
domain: str,
platform: str,
old_key: str,
new_key: str,
) -> None:
@@ -63,7 +63,9 @@ async def async_update_unique_id(
for serial_num in coordinator.data:
unique_id = f"{serial_num}-{old_key}"
if entity_id := entity_registry.async_get_entity_id(domain, DOMAIN, unique_id):
if entity_id := entity_registry.async_get_entity_id(
DOMAIN, platform, unique_id
):
_LOGGER.debug("Updating unique_id for %s", entity_id)
new_unique_id = unique_id.replace(old_key, new_key)
@@ -74,12 +76,13 @@ async def async_update_unique_id(
async def async_remove_dnd_from_virtual_group(
hass: HomeAssistant,
coordinator: AmazonDevicesCoordinator,
key: str,
) -> None:
"""Remove entity DND from virtual group."""
entity_registry = er.async_get(hass)
for serial_num in coordinator.data:
unique_id = f"{serial_num}-do_not_disturb"
unique_id = f"{serial_num}-{key}"
entity_id = entity_registry.async_get_entity_id(
DOMAIN, SWITCH_DOMAIN, unique_id
)
@@ -104,7 +107,7 @@ async def async_remove_unsupported_notification_sensors(
):
unique_id = f"{serial_num}-{notification_key}"
entity_id = entity_registry.async_get_entity_id(
domain=SENSOR_DOMAIN, platform=DOMAIN, unique_id=unique_id
DOMAIN, SENSOR_DOMAIN, unique_id=unique_id
)
is_unsupported = not coordinator.data[serial_num].notifications_supported

View File

@@ -2,15 +2,16 @@
from __future__ import annotations
from pyatv.const import KeyboardFocusState
from pyatv.const import FeatureName, FeatureState, KeyboardFocusState
from pyatv.interface import AppleTV, KeyboardListener
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AppleTvConfigEntry
from . import SIGNAL_CONNECTED, AppleTvConfigEntry
from .entity import AppleTVEntity
@@ -21,10 +22,22 @@ async def async_setup_entry(
) -> None:
"""Load Apple TV binary sensor based on a config entry."""
# apple_tv config entries always have a unique id
assert config_entry.unique_id is not None
name: str = config_entry.data[CONF_NAME]
manager = config_entry.runtime_data
async_add_entities([AppleTVKeyboardFocused(name, config_entry.unique_id, manager)])
cb: CALLBACK_TYPE
def setup_entities(atv: AppleTV) -> None:
if atv.features.in_state(FeatureState.Available, FeatureName.TextFocusState):
assert config_entry.unique_id is not None
name: str = config_entry.data[CONF_NAME]
async_add_entities(
[AppleTVKeyboardFocused(name, config_entry.unique_id, manager)]
)
cb()
cb = async_dispatcher_connect(
hass, f"{SIGNAL_CONNECTED}_{config_entry.unique_id}", setup_entities
)
config_entry.async_on_unload(cb)
class AppleTVKeyboardFocused(AppleTVEntity, BinarySensorEntity, KeyboardListener):

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

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.2.3"]
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.1.28"]
}

View File

@@ -181,7 +181,7 @@ class EvoChild(EvoEntity):
self._device_state_attrs = {
"activeFaults": self._evo_device.active_faults,
"setpoints": self._setpoints,
"setpoints": self.setpoints,
}
super()._handle_coordinator_update()

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

@@ -1,11 +1,22 @@
"""The Fressnapf Tracker integration."""
from fressnapftracker import AuthClient, FressnapfTrackerAuthenticationError
import logging
from fressnapftracker import (
ApiClient,
AuthClient,
Device,
FressnapfTrackerAuthenticationError,
FressnapfTrackerError,
FressnapfTrackerInvalidTrackerResponseError,
Tracker,
)
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from .const import CONF_USER_ID, DOMAIN
from .coordinator import (
@@ -21,6 +32,43 @@ PLATFORMS: list[Platform] = [
Platform.SWITCH,
]
_LOGGER = logging.getLogger(__name__)
async def _get_valid_tracker(hass: HomeAssistant, device: Device) -> Tracker | None:
"""Test if the tracker returns valid data and return it.
Malformed data might indicate the tracker is broken or hasn't been properly registered with the app.
"""
client = ApiClient(
serial_number=device.serialnumber,
device_token=device.token,
client=get_async_client(hass),
)
try:
return await client.get_tracker()
except FressnapfTrackerInvalidTrackerResponseError:
_LOGGER.warning(
"Tracker with serialnumber %s is invalid. Consider removing it via the App",
device.serialnumber,
)
async_create_issue(
hass,
DOMAIN,
f"invalid_fressnapf_tracker_{device.serialnumber}",
issue_domain=DOMAIN,
learn_more_url="https://www.home-assistant.io/integrations/fressnapf_tracker/",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="invalid_fressnapf_tracker",
translation_placeholders={
"tracker_id": device.serialnumber,
},
)
return None
except FressnapfTrackerError as err:
raise ConfigEntryNotReady(err) from err
async def async_setup_entry(
hass: HomeAssistant, entry: FressnapfTrackerConfigEntry
@@ -40,12 +88,15 @@ async def async_setup_entry(
coordinators: list[FressnapfTrackerDataUpdateCoordinator] = []
for device in devices:
tracker = await _get_valid_tracker(hass, device)
if tracker is None:
continue
coordinator = FressnapfTrackerDataUpdateCoordinator(
hass,
entry,
device,
initial_data=tracker,
)
await coordinator.async_config_entry_first_refresh()
coordinators.append(coordinator)
entry.runtime_data = coordinators

View File

@@ -34,6 +34,7 @@ class FressnapfTrackerDataUpdateCoordinator(DataUpdateCoordinator[Tracker]):
hass: HomeAssistant,
config_entry: FressnapfTrackerConfigEntry,
device: Device,
initial_data: Tracker,
) -> None:
"""Initialize."""
super().__init__(
@@ -49,6 +50,7 @@ class FressnapfTrackerDataUpdateCoordinator(DataUpdateCoordinator[Tracker]):
device_token=device.token,
client=get_async_client(hass),
)
self.data = initial_data
async def _async_update_data(self) -> Tracker:
try:

View File

@@ -92,5 +92,11 @@
"not_seen_recently": {
"message": "The flashlight cannot be activated when the tracker has not moved recently."
}
},
"issues": {
"invalid_fressnapf_tracker": {
"description": "The Fressnapf GPS tracker with the serial number `{tracker_id}` is sending invalid data. This most likely indicates the tracker is broken or hasn't been properly registered with the app. Please remove the tracker via the Fressnapf app. You can then close this issue.",
"title": "Invalid Fressnapf GPS tracker detected"
}
}
}

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

@@ -58,18 +58,95 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]):
"""Return Overkiz device linked to this entity."""
return self.coordinator.data[self.device_url]
def _get_sibling_devices(self) -> list[Device]:
"""Return sibling devices sharing the same base device URL."""
prefix = f"{self.base_device_url}#"
return [
device
for device in self.coordinator.data.values()
if device.device_url != self.device_url
and device.device_url.startswith(prefix)
]
def _has_siblings_with_different_place_oid(self) -> bool:
"""Check if sibling devices have different placeOIDs.
Returns True if siblings have different place_oid values, indicating
devices should be grouped by placeOID rather than by base URL.
"""
my_place_oid = self.device.place_oid
if not my_place_oid:
return False
return any(
sibling.place_oid and sibling.place_oid != my_place_oid
for sibling in self._get_sibling_devices()
)
def _get_device_index(self, device_url: str) -> int | None:
"""Extract numeric index from device URL (e.g., 'io://gw/123#4' -> 4)."""
suffix = device_url.split("#")[-1]
return int(suffix) if suffix.isdigit() else None
def _is_main_device_for_place_oid(self) -> bool:
"""Check if this device is the main device for its placeOID group.
The device with the lowest URL index among siblings sharing the same
placeOID is considered the main device and provides full device info.
"""
my_place_oid = self.device.place_oid
if not my_place_oid:
return True
my_index = self._get_device_index(self.device_url)
if my_index is None:
return True
return not any(
(sibling_index := self._get_device_index(sibling.device_url)) is not None
and sibling_index < my_index
for sibling in self._get_sibling_devices()
if sibling.place_oid == my_place_oid
)
def _get_via_device_id(self, use_place_oid_grouping: bool) -> str:
"""Return the via_device identifier for device registry hierarchy.
Sub-devices link to the main actuator (#1 device) when using placeOID
grouping, otherwise they link directly to the gateway.
"""
gateway_id = self.executor.get_gateway_id()
if not use_place_oid_grouping or self.device_url.endswith("#1"):
return gateway_id
main_device = self.coordinator.data.get(f"{self.base_device_url}#1")
if main_device and main_device.place_oid:
return f"{self.base_device_url}#{main_device.place_oid}"
return gateway_id
def generate_device_info(self) -> DeviceInfo:
"""Return device registry information for this entity."""
# Some devices, such as the Smart Thermostat have several devices
# in one physical device, with same device url, terminated by '#' and a number.
# In this case, we use the base device url as the device identifier.
if self.is_sub_device:
# Only return the url of the base device, to inherit device name
# and model from parent device.
# Some devices, such as the Smart Thermostat, have several sub-devices
# sharing the same base URL (terminated by '#' and a number).
use_place_oid_grouping = self._has_siblings_with_different_place_oid()
# Sub-devices without placeOID grouping inherit info from parent device
if self.is_sub_device and not use_place_oid_grouping:
return DeviceInfo(
identifiers={(DOMAIN, self.executor.base_device_url)},
)
# Determine identifier based on grouping strategy
if use_place_oid_grouping:
identifier = f"{self.base_device_url}#{self.device.place_oid}"
# Non-main devices only reference the identifier
if not self._is_main_device_for_place_oid():
return DeviceInfo(identifiers={(DOMAIN, identifier)})
else:
identifier = self.executor.base_device_url
manufacturer = (
self.executor.select_attribute(OverkizAttribute.CORE_MANUFACTURER)
or self.executor.select_state(OverkizState.CORE_MANUFACTURER_NAME)
@@ -92,7 +169,7 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]):
)
return DeviceInfo(
identifiers={(DOMAIN, self.executor.base_device_url)},
identifiers={(DOMAIN, identifier)},
name=self.device.label,
manufacturer=str(manufacturer),
model=str(model),
@@ -102,7 +179,7 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]):
),
hw_version=self.device.controllable_name,
suggested_area=suggested_area,
via_device=(DOMAIN, self.executor.get_gateway_id()),
via_device=(DOMAIN, self._get_via_device_id(use_place_oid_grouping)),
configuration_url=self.coordinator.client.server.configuration_url,
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -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,11 +37,11 @@ fnv-hash-fast==1.6.0
go2rtc-client==0.4.0
ha-ffmpeg==3.2.2
habluetooth==5.8.0
hass-nabucasa==1.12.0
hass-nabucasa==1.13.0
hassil==3.5.0
home-assistant-bluetooth==1.13.1
home-assistant-frontend==20260128.6
home-assistant-intents==2026.2.3
home-assistant-intents==2026.1.28
httpx==0.28.1
ifaddr==0.2.0
Jinja2==3.1.6

10
mypy.ini generated
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",

4
requirements.txt generated
View File

@@ -25,10 +25,10 @@ cronsim==2.7
cryptography==46.0.2
fnv-hash-fast==1.6.0
ha-ffmpeg==3.2.2
hass-nabucasa==1.12.0
hass-nabucasa==1.13.0
hassil==3.5.0
home-assistant-bluetooth==1.13.1
home-assistant-intents==2026.2.3
home-assistant-intents==2026.1.28
httpx==0.28.1
ifaddr==0.2.0
Jinja2==3.1.6

11
requirements_all.txt generated
View File

@@ -938,7 +938,7 @@ eufylife-ble-client==0.1.8
# evdev==1.6.1
# homeassistant.components.evohome
evohome-async==1.0.6
evohome-async==1.1.3
# homeassistant.components.bryant_evolution
evolutionhttp==0.0.18
@@ -1105,7 +1105,7 @@ google-nest-sdm==9.1.2
google-photos-library-api==0.12.1
# homeassistant.components.google_air_quality
google_air_quality_api==3.0.0
google_air_quality_api==3.0.1
# homeassistant.components.slide
# homeassistant.components.slide_local
@@ -1175,7 +1175,7 @@ habluetooth==5.8.0
hanna-cloud==0.0.7
# homeassistant.components.cloud
hass-nabucasa==1.12.0
hass-nabucasa==1.13.0
# homeassistant.components.splunk
hass-splunk==0.1.1
@@ -1222,7 +1222,7 @@ holidays==0.84
home-assistant-frontend==20260128.6
# homeassistant.components.conversation
home-assistant-intents==2026.2.3
home-assistant-intents==2026.1.28
# homeassistant.components.gentex_homelink
homelink-integration-api==0.0.1
@@ -2744,6 +2744,9 @@ rapt-ble==0.1.2
# homeassistant.components.raspyrfm
raspyrfm-client==1.2.9
# homeassistant.components.redgtech
redgtech-api==0.1.38
# homeassistant.components.refoss
refoss-ha==1.2.5

View File

@@ -826,7 +826,7 @@ eternalegypt==0.0.18
eufylife-ble-client==0.1.8
# homeassistant.components.evohome
evohome-async==1.0.6
evohome-async==1.1.3
# homeassistant.components.bryant_evolution
evolutionhttp==0.0.18
@@ -981,7 +981,7 @@ google-nest-sdm==9.1.2
google-photos-library-api==0.12.1
# homeassistant.components.google_air_quality
google_air_quality_api==3.0.0
google_air_quality_api==3.0.1
# homeassistant.components.slide
# homeassistant.components.slide_local
@@ -1045,7 +1045,7 @@ habluetooth==5.8.0
hanna-cloud==0.0.7
# homeassistant.components.cloud
hass-nabucasa==1.12.0
hass-nabucasa==1.13.0
# homeassistant.components.assist_satellite
# homeassistant.components.conversation
@@ -1080,7 +1080,7 @@ holidays==0.84
home-assistant-frontend==20260128.6
# homeassistant.components.conversation
home-assistant-intents==2026.2.3
home-assistant-intents==2026.1.28
# homeassistant.components.gentex_homelink
homelink-integration-api==0.0.1
@@ -2310,6 +2310,9 @@ radiotherm==2.1.0
# homeassistant.components.rapt_ble
rapt-ble==0.1.2
# homeassistant.components.redgtech
redgtech-api==0.1.38
# homeassistant.components.refoss
refoss-ha==1.2.5

View File

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

View File

@@ -81,8 +81,8 @@ async def test_alexa_unique_id_migration(
)
entity = entity_registry.async_get_or_create(
SWITCH_DOMAIN,
DOMAIN,
SWITCH_DOMAIN,
unique_id=f"{TEST_DEVICE_1_SN}-do_not_disturb",
device_id=device.id,
config_entry=mock_config_entry,

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

@@ -167,6 +167,836 @@
),
])
# ---
# name: test_entities_update_over_time[default][climate.bathroom_dn-state-initial]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 20.0,
'friendly_name': 'Bathroom Dn',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 5.0,
'preset_mode': 'none',
'preset_modes': list([
'none',
'temporary',
'permanent',
]),
'status': dict({
'activeFaults': tuple(
),
'setpoint_status': dict({
'setpoint_mode': 'FollowSchedule',
'target_heat_temperature': 16.0,
}),
'setpoints': dict({
'next_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'next_sp_temp': 18.1,
'this_sp_from': HAFakeDatetime(2024, 7, 9, 23, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'this_sp_temp': 15.9,
}),
'temperature_status': dict({
'is_available': True,
'temperature': 20.0,
}),
'zone_id': '3432579',
}),
'supported_features': <ClimateEntityFeature: 401>,
'temperature': 16.0,
}),
'context': <ANY>,
'entity_id': 'climate.bathroom_dn',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_entities_update_over_time[default][climate.bathroom_dn-state-updated]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 20.0,
'friendly_name': 'Bathroom Dn',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 5.0,
'preset_mode': 'none',
'preset_modes': list([
'none',
'temporary',
'permanent',
]),
'status': dict({
'activeFaults': tuple(
),
'setpoint_status': dict({
'setpoint_mode': 'FollowSchedule',
'target_heat_temperature': 16.0,
}),
'setpoints': dict({
'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'next_sp_temp': 18.6,
'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'this_sp_temp': 16.0,
}),
'temperature_status': dict({
'is_available': True,
'temperature': 20.0,
}),
'zone_id': '3432579',
}),
'supported_features': <ClimateEntityFeature: 401>,
'temperature': 16.0,
}),
'context': <ANY>,
'entity_id': 'climate.bathroom_dn',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_entities_update_over_time[default][climate.dead_zone-state-initial]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': None,
'friendly_name': 'Dead Zone',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 5.0,
'preset_mode': 'none',
'preset_modes': list([
'none',
'temporary',
'permanent',
]),
'status': dict({
'activeFaults': tuple(
),
'setpoint_status': dict({
'setpoint_mode': 'FollowSchedule',
'target_heat_temperature': 17.0,
}),
'setpoints': dict({
'next_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'next_sp_temp': 18.1,
'this_sp_from': HAFakeDatetime(2024, 7, 9, 23, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'this_sp_temp': 15.9,
}),
'temperature_status': dict({
'is_available': False,
}),
'zone_id': '3432521',
}),
'supported_features': <ClimateEntityFeature: 401>,
'temperature': 17.0,
}),
'context': <ANY>,
'entity_id': 'climate.dead_zone',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_entities_update_over_time[default][climate.dead_zone-state-updated]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': None,
'friendly_name': 'Dead Zone',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 5.0,
'preset_mode': 'none',
'preset_modes': list([
'none',
'temporary',
'permanent',
]),
'status': dict({
'activeFaults': tuple(
),
'setpoint_status': dict({
'setpoint_mode': 'FollowSchedule',
'target_heat_temperature': 17.0,
}),
'setpoints': dict({
'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'next_sp_temp': 18.6,
'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'this_sp_temp': 16.0,
}),
'temperature_status': dict({
'is_available': False,
}),
'zone_id': '3432521',
}),
'supported_features': <ClimateEntityFeature: 401>,
'temperature': 17.0,
}),
'context': <ANY>,
'entity_id': 'climate.dead_zone',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_entities_update_over_time[default][climate.front_room-state-initial]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 19.0,
'friendly_name': 'Front Room',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 5.0,
'preset_mode': 'temporary',
'preset_modes': list([
'none',
'temporary',
'permanent',
]),
'status': dict({
'activeFaults': tuple(
),
'setpoint_status': dict({
'setpoint_mode': 'TemporaryOverride',
'target_heat_temperature': 21.0,
'until': '2022-03-07T19:00:00+00:00',
}),
'setpoints': dict({
'next_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'next_sp_temp': 18.1,
'this_sp_from': HAFakeDatetime(2024, 7, 9, 23, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'this_sp_temp': 15.9,
}),
'temperature_status': dict({
'is_available': True,
'temperature': 19.0,
}),
'zone_id': '3432577',
}),
'supported_features': <ClimateEntityFeature: 401>,
'temperature': 21.0,
}),
'context': <ANY>,
'entity_id': 'climate.front_room',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_entities_update_over_time[default][climate.front_room-state-updated]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 19.0,
'friendly_name': 'Front Room',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 5.0,
'preset_mode': 'temporary',
'preset_modes': list([
'none',
'temporary',
'permanent',
]),
'status': dict({
'activeFaults': tuple(
),
'setpoint_status': dict({
'setpoint_mode': 'TemporaryOverride',
'target_heat_temperature': 21.0,
'until': '2022-03-07T19:00:00+00:00',
}),
'setpoints': dict({
'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'next_sp_temp': 18.6,
'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'this_sp_temp': 16.0,
}),
'temperature_status': dict({
'is_available': True,
'temperature': 19.0,
}),
'zone_id': '3432577',
}),
'supported_features': <ClimateEntityFeature: 401>,
'temperature': 21.0,
}),
'context': <ANY>,
'entity_id': 'climate.front_room',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_entities_update_over_time[default][climate.kids_room-state-initial]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 19.5,
'friendly_name': 'Kids Room',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 5.0,
'preset_mode': 'none',
'preset_modes': list([
'none',
'temporary',
'permanent',
]),
'status': dict({
'activeFaults': tuple(
),
'setpoint_status': dict({
'setpoint_mode': 'FollowSchedule',
'target_heat_temperature': 17.0,
}),
'setpoints': dict({
'next_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'next_sp_temp': 18.1,
'this_sp_from': HAFakeDatetime(2024, 7, 9, 23, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'this_sp_temp': 15.9,
}),
'temperature_status': dict({
'is_available': True,
'temperature': 19.5,
}),
'zone_id': '3449703',
}),
'supported_features': <ClimateEntityFeature: 401>,
'temperature': 17.0,
}),
'context': <ANY>,
'entity_id': 'climate.kids_room',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_entities_update_over_time[default][climate.kids_room-state-updated]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 19.5,
'friendly_name': 'Kids Room',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 5.0,
'preset_mode': 'none',
'preset_modes': list([
'none',
'temporary',
'permanent',
]),
'status': dict({
'activeFaults': tuple(
),
'setpoint_status': dict({
'setpoint_mode': 'FollowSchedule',
'target_heat_temperature': 17.0,
}),
'setpoints': dict({
'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'next_sp_temp': 18.6,
'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'this_sp_temp': 16.0,
}),
'temperature_status': dict({
'is_available': True,
'temperature': 19.5,
}),
'zone_id': '3449703',
}),
'supported_features': <ClimateEntityFeature: 401>,
'temperature': 17.0,
}),
'context': <ANY>,
'entity_id': 'climate.kids_room',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_entities_update_over_time[default][climate.kitchen-state-initial]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 20.0,
'friendly_name': 'Kitchen',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 5.0,
'preset_mode': 'none',
'preset_modes': list([
'none',
'temporary',
'permanent',
]),
'status': dict({
'activeFaults': tuple(
),
'setpoint_status': dict({
'setpoint_mode': 'FollowSchedule',
'target_heat_temperature': 17.0,
}),
'setpoints': dict({
'next_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'next_sp_temp': 18.1,
'this_sp_from': HAFakeDatetime(2024, 7, 9, 23, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'this_sp_temp': 15.9,
}),
'temperature_status': dict({
'is_available': True,
'temperature': 20.0,
}),
'zone_id': '3432578',
}),
'supported_features': <ClimateEntityFeature: 401>,
'temperature': 17.0,
}),
'context': <ANY>,
'entity_id': 'climate.kitchen',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_entities_update_over_time[default][climate.kitchen-state-updated]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 20.0,
'friendly_name': 'Kitchen',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 5.0,
'preset_mode': 'none',
'preset_modes': list([
'none',
'temporary',
'permanent',
]),
'status': dict({
'activeFaults': tuple(
),
'setpoint_status': dict({
'setpoint_mode': 'FollowSchedule',
'target_heat_temperature': 17.0,
}),
'setpoints': dict({
'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'next_sp_temp': 18.6,
'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'this_sp_temp': 16.0,
}),
'temperature_status': dict({
'is_available': True,
'temperature': 20.0,
}),
'zone_id': '3432578',
}),
'supported_features': <ClimateEntityFeature: 401>,
'temperature': 17.0,
}),
'context': <ANY>,
'entity_id': 'climate.kitchen',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_entities_update_over_time[default][climate.main_bedroom-state-initial]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 21.0,
'friendly_name': 'Main Bedroom',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 5.0,
'preset_mode': 'none',
'preset_modes': list([
'none',
'temporary',
'permanent',
]),
'status': dict({
'activeFaults': tuple(
),
'setpoint_status': dict({
'setpoint_mode': 'FollowSchedule',
'target_heat_temperature': 16.0,
}),
'setpoints': dict({
'next_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'next_sp_temp': 18.1,
'this_sp_from': HAFakeDatetime(2024, 7, 9, 23, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'this_sp_temp': 15.9,
}),
'temperature_status': dict({
'is_available': True,
'temperature': 21.0,
}),
'zone_id': '3432580',
}),
'supported_features': <ClimateEntityFeature: 401>,
'temperature': 16.0,
}),
'context': <ANY>,
'entity_id': 'climate.main_bedroom',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_entities_update_over_time[default][climate.main_bedroom-state-updated]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 21.0,
'friendly_name': 'Main Bedroom',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 5.0,
'preset_mode': 'none',
'preset_modes': list([
'none',
'temporary',
'permanent',
]),
'status': dict({
'activeFaults': tuple(
),
'setpoint_status': dict({
'setpoint_mode': 'FollowSchedule',
'target_heat_temperature': 16.0,
}),
'setpoints': dict({
'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'next_sp_temp': 18.6,
'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'this_sp_temp': 16.0,
}),
'temperature_status': dict({
'is_available': True,
'temperature': 21.0,
}),
'zone_id': '3432580',
}),
'supported_features': <ClimateEntityFeature: 401>,
'temperature': 16.0,
}),
'context': <ANY>,
'entity_id': 'climate.main_bedroom',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_entities_update_over_time[default][climate.main_room-state-initial]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 19.0,
'friendly_name': 'Main Room',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 5.0,
'preset_mode': 'permanent',
'preset_modes': list([
'none',
'temporary',
'permanent',
]),
'status': dict({
'activeFaults': tuple(
),
'setpoint_status': dict({
'setpoint_mode': 'PermanentOverride',
'target_heat_temperature': 17.0,
}),
'setpoints': dict({
'next_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'next_sp_temp': 18.1,
'this_sp_from': HAFakeDatetime(2024, 7, 9, 23, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'this_sp_temp': 15.9,
}),
'temperature_status': dict({
'is_available': True,
'temperature': 19.0,
}),
'zone_id': '3432576',
}),
'supported_features': <ClimateEntityFeature: 401>,
'temperature': 17.0,
}),
'context': <ANY>,
'entity_id': 'climate.main_room',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_entities_update_over_time[default][climate.main_room-state-updated]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 19.0,
'friendly_name': 'Main Room',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 5.0,
'preset_mode': 'permanent',
'preset_modes': list([
'none',
'temporary',
'permanent',
]),
'status': dict({
'activeFaults': tuple(
),
'setpoint_status': dict({
'setpoint_mode': 'PermanentOverride',
'target_heat_temperature': 17.0,
}),
'setpoints': dict({
'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'next_sp_temp': 18.6,
'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'this_sp_temp': 16.0,
}),
'temperature_status': dict({
'is_available': True,
'temperature': 19.0,
}),
'zone_id': '3432576',
}),
'supported_features': <ClimateEntityFeature: 401>,
'temperature': 17.0,
}),
'context': <ANY>,
'entity_id': 'climate.main_room',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_entities_update_over_time[default][climate.my_home-state-initial]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 19.7,
'friendly_name': 'My Home',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'icon': 'mdi:thermostat',
'max_temp': 35,
'min_temp': 7,
'preset_mode': 'eco',
'preset_modes': list([
'Reset',
'eco',
'away',
'home',
'Custom',
]),
'status': dict({
'activeSystemFaults': tuple(
),
'system_id': '3432522',
'system_mode_status': dict({
'is_permanent': True,
'mode': 'AutoWithEco',
}),
}),
'supported_features': <ClimateEntityFeature: 400>,
}),
'context': <ANY>,
'entity_id': 'climate.my_home',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_entities_update_over_time[default][climate.my_home-state-updated]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 19.7,
'friendly_name': 'My Home',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'icon': 'mdi:thermostat',
'max_temp': 35,
'min_temp': 7,
'preset_mode': 'eco',
'preset_modes': list([
'Reset',
'eco',
'away',
'home',
'Custom',
]),
'status': dict({
'activeSystemFaults': tuple(
),
'system_id': '3432522',
'system_mode_status': dict({
'is_permanent': True,
'mode': 'AutoWithEco',
}),
}),
'supported_features': <ClimateEntityFeature: 400>,
}),
'context': <ANY>,
'entity_id': 'climate.my_home',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_entities_update_over_time[default][climate.spare_room-state-initial]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 19.5,
'friendly_name': 'Spare Room',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 5.0,
'preset_mode': 'permanent',
'preset_modes': list([
'none',
'temporary',
'permanent',
]),
'status': dict({
'activeFaults': tuple(
),
'setpoint_status': dict({
'setpoint_mode': 'PermanentOverride',
'target_heat_temperature': 14.0,
}),
'setpoints': dict({
'next_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'next_sp_temp': 18.1,
'this_sp_from': HAFakeDatetime(2024, 7, 9, 23, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'this_sp_temp': 15.9,
}),
'temperature_status': dict({
'is_available': True,
'temperature': 19.5,
}),
'zone_id': '3450733',
}),
'supported_features': <ClimateEntityFeature: 401>,
'temperature': 14.0,
}),
'context': <ANY>,
'entity_id': 'climate.spare_room',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_entities_update_over_time[default][climate.spare_room-state-updated]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 19.5,
'friendly_name': 'Spare Room',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 5.0,
'preset_mode': 'permanent',
'preset_modes': list([
'none',
'temporary',
'permanent',
]),
'status': dict({
'activeFaults': tuple(
),
'setpoint_status': dict({
'setpoint_mode': 'PermanentOverride',
'target_heat_temperature': 14.0,
}),
'setpoints': dict({
'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'next_sp_temp': 18.6,
'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'this_sp_temp': 16.0,
}),
'temperature_status': dict({
'is_available': True,
'temperature': 19.5,
}),
'zone_id': '3450733',
}),
'supported_features': <ClimateEntityFeature: 401>,
'temperature': 14.0,
}),
'context': <ANY>,
'entity_id': 'climate.spare_room',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_setup_platform[botched][climate.bathroom_dn-state]
StateSnapshot({
'attributes': ReadOnlyDict({

View File

@@ -5,6 +5,7 @@ All evohome systems have controllers and at least one zone.
from __future__ import annotations
from datetime import timedelta
from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
@@ -32,6 +33,8 @@ from homeassistant.exceptions import HomeAssistantError
from .conftest import setup_evohome
from .const import TEST_INSTALLS
from tests.common import async_fire_time_changed
@pytest.mark.parametrize("install", [*TEST_INSTALLS, "botched"])
async def test_setup_platform(
@@ -43,7 +46,7 @@ async def test_setup_platform(
) -> None:
"""Test entities and their states after setup of evohome."""
# Cannot use the evohome fixture, as need to set dtm first
# Cannot use the evohome fixture here, as need to set dtm first
# - some extended state attrs are relative the current time
freezer.move_to("2024-07-10T12:00:00Z")
@@ -54,6 +57,36 @@ async def test_setup_platform(
assert x == snapshot(name=f"{x.entity_id}-state")
@pytest.mark.parametrize("install", ["default"])
async def test_entities_update_over_time(
hass: HomeAssistant,
config: dict[str, str],
install: str,
snapshot: SnapshotAssertion,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test extended attributes update as time passes.
Verifies that time-dependent state attrs (e.g. schedules) vary as time advances.
"""
# Cannot use the evohome fixture here, as need to set dtm first
# - some extended state attrs are relative the current time
freezer.move_to("2024-07-10T05:30:00Z")
# stay inside this context to have the mocked RESTful API
async for _ in setup_evohome(hass, config, install=install):
for x in hass.states.async_all(Platform.CLIMATE):
assert x == snapshot(name=f"{x.entity_id}-state-initial")
freezer.tick(timedelta(hours=12))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
for x in hass.states.async_all(Platform.CLIMATE):
assert x == snapshot(name=f"{x.entity_id}-state-updated")
@pytest.mark.parametrize("install", TEST_INSTALLS)
async def test_ctl_set_hvac_mode(
hass: HomeAssistant,

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

@@ -35,6 +35,38 @@ MOCK_SERIAL_NUMBER = "ABC123456"
MOCK_DEVICE_TOKEN = "mock_device_token"
def create_mock_tracker() -> Tracker:
"""Create a fresh mock Tracker instance."""
return Tracker(
name="Fluffy",
battery=85,
charging=False,
position=Position(
lat=52.520008,
lng=13.404954,
accuracy=10,
timestamp="2024-01-15T12:00:00Z",
),
tracker_settings=TrackerSettings(
generation="GPS Tracker 2.0",
features=TrackerFeatures(
flash_light=True, energy_saving_mode=True, live_tracking=True
),
),
led_brightness=LedBrightness(status="ok", value=50),
energy_saving=EnergySaving(status="ok", value=1),
deep_sleep=None,
led_activatable=LedActivatable(
has_led=True,
seen_recently=True,
nonempty_battery=True,
not_charging=True,
overall=True,
),
icon="http://res.cloudinary.com/iot-venture/image/upload/v1717594357/kyaqq7nfitrdvaoakb8s.jpg",
)
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
@@ -102,42 +134,26 @@ def mock_auth_client(mock_device: Device) -> Generator[MagicMock]:
@pytest.fixture
def mock_api_client() -> Generator[MagicMock]:
"""Mock the ApiClient."""
def mock_api_client_init() -> Generator[MagicMock]:
"""Mock the ApiClient used by _tracker_is_valid in __init__.py."""
with patch(
"homeassistant.components.fressnapf_tracker.coordinator.ApiClient"
) as mock_api_client:
client = mock_api_client.return_value
client.get_tracker = AsyncMock(
return_value=Tracker(
name="Fluffy",
battery=85,
charging=False,
position=Position(
lat=52.520008,
lng=13.404954,
accuracy=10,
timestamp="2024-01-15T12:00:00Z",
),
tracker_settings=TrackerSettings(
generation="GPS Tracker 2.0",
features=TrackerFeatures(
flash_light=True, energy_saving_mode=True, live_tracking=True
),
),
led_brightness=LedBrightness(status="ok", value=50),
energy_saving=EnergySaving(status="ok", value=1),
deep_sleep=None,
led_activatable=LedActivatable(
has_led=True,
seen_recently=True,
nonempty_battery=True,
not_charging=True,
overall=True,
),
icon="http://res.cloudinary.com/iot-venture/image/upload/v1717594357/kyaqq7nfitrdvaoakb8s.jpg",
)
)
"homeassistant.components.fressnapf_tracker.ApiClient",
autospec=True,
) as mock_client:
client = mock_client.return_value
client.get_tracker = AsyncMock(return_value=create_mock_tracker())
yield client
@pytest.fixture
def mock_api_client_coordinator() -> Generator[MagicMock]:
"""Mock the ApiClient used by the coordinator."""
with patch(
"homeassistant.components.fressnapf_tracker.coordinator.ApiClient",
autospec=True,
) as mock_client:
client = mock_client.return_value
client.get_tracker = AsyncMock(return_value=create_mock_tracker())
client.set_led_brightness = AsyncMock(return_value=None)
client.set_energy_saving = AsyncMock(return_value=None)
yield client
@@ -162,7 +178,8 @@ def mock_config_entry() -> MockConfigEntry:
async def init_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_api_client: MagicMock,
mock_api_client_init: MagicMock,
mock_api_client_coordinator: MagicMock,
mock_auth_client: MagicMock,
) -> MockConfigEntry:
"""Set up the integration for testing."""

View File

@@ -216,7 +216,9 @@ async def test_user_flow_duplicate_phone_number(
),
],
)
@pytest.mark.usefixtures("mock_api_client", "mock_auth_client")
@pytest.mark.usefixtures(
"mock_api_client_init", "mock_api_client_coordinator", "mock_auth_client"
)
async def test_reauth_reconfigure_flow(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
@@ -270,7 +272,7 @@ async def test_reauth_reconfigure_flow(
),
],
)
@pytest.mark.usefixtures("mock_api_client")
@pytest.mark.usefixtures("mock_api_client_init", "mock_api_client_coordinator")
async def test_reauth_reconfigure_flow_invalid_phone_number(
hass: HomeAssistant,
mock_auth_client: MagicMock,
@@ -333,7 +335,7 @@ async def test_reauth_reconfigure_flow_invalid_phone_number(
),
],
)
@pytest.mark.usefixtures("mock_api_client")
@pytest.mark.usefixtures("mock_api_client_init", "mock_api_client_coordinator")
async def test_reauth_reconfigure_flow_invalid_sms_code(
hass: HomeAssistant,
mock_auth_client: MagicMock,
@@ -393,7 +395,7 @@ async def test_reauth_reconfigure_flow_invalid_sms_code(
),
],
)
@pytest.mark.usefixtures("mock_api_client")
@pytest.mark.usefixtures("mock_api_client_init", "mock_api_client_coordinator")
async def test_reauth_reconfigure_flow_invalid_user_id(
hass: HomeAssistant,
mock_auth_client: MagicMock,

View File

@@ -40,12 +40,12 @@ async def test_device_tracker_no_position(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_tracker_no_position: Tracker,
mock_api_client: MagicMock,
mock_api_client_init: MagicMock,
) -> None:
"""Test device tracker is unavailable when position is None."""
mock_config_entry.add_to_hass(hass)
mock_api_client.get_tracker = AsyncMock(return_value=mock_tracker_no_position)
mock_api_client_init.get_tracker = AsyncMock(return_value=mock_tracker_no_position)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()

View File

@@ -1,19 +1,40 @@
"""Test the Fressnapf Tracker integration init."""
from unittest.mock import AsyncMock, MagicMock
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
from fressnapftracker import (
FressnapfTrackerError,
FressnapfTrackerInvalidTrackerResponseError,
)
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.fressnapf_tracker.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import device_registry as dr, issue_registry as ir
from .conftest import MOCK_SERIAL_NUMBER
from tests.common import MockConfigEntry
@pytest.mark.usefixtures("mock_auth_client")
@pytest.mark.usefixtures("mock_api_client")
@pytest.fixture
def mock_api_client_malformed_tracker() -> Generator[MagicMock]:
"""Mock the ApiClient for a malformed tracker response in _tracker_is_valid."""
with patch(
"homeassistant.components.fressnapf_tracker.ApiClient",
autospec=True,
) as mock_api_client:
client = mock_api_client.return_value
client.get_tracker = AsyncMock(
side_effect=FressnapfTrackerInvalidTrackerResponseError("Invalid tracker")
)
yield client
@pytest.mark.usefixtures("mock_auth_client", "mock_api_client_init")
async def test_setup_entry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
@@ -27,8 +48,7 @@ async def test_setup_entry(
assert mock_config_entry.state is ConfigEntryState.LOADED
@pytest.mark.usefixtures("mock_auth_client")
@pytest.mark.usefixtures("mock_api_client")
@pytest.mark.usefixtures("mock_auth_client", "mock_api_client_init")
async def test_unload_entry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
@@ -48,15 +68,18 @@ async def test_unload_entry(
@pytest.mark.usefixtures("mock_auth_client")
async def test_setup_entry_api_error(
async def test_setup_entry_tracker_is_valid_api_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_api_client: MagicMock,
mock_api_client_init: MagicMock,
) -> None:
"""Test setup fails when API returns error."""
"""Test setup retries when API returns error during _tracker_is_valid."""
mock_config_entry.add_to_hass(hass)
mock_api_client.get_tracker = AsyncMock(side_effect=Exception("API Error"))
mock_api_client_init.get_tracker = AsyncMock(
side_effect=FressnapfTrackerError("API Error")
)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
@@ -78,3 +101,48 @@ async def test_state_entity_device_snapshots(
assert device_entry == snapshot(name=f"{device_entry.name}-entry"), (
f"device entry snapshot failed for {device_entry.name}"
)
@pytest.mark.usefixtures("mock_auth_client", "mock_api_client_malformed_tracker")
async def test_invalid_tracker(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test that an issue is created when an invalid tracker is detected."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
assert len(issue_registry.issues) == 1
issue_id = f"invalid_fressnapf_tracker_{MOCK_SERIAL_NUMBER}"
assert issue_registry.async_get_issue(DOMAIN, issue_id)
@pytest.mark.usefixtures("mock_auth_client", "mock_api_client_malformed_tracker")
async def test_invalid_tracker_already_exists(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test that an existing issue is not duplicated."""
ir.async_create_issue(
hass,
DOMAIN,
f"invalid_fressnapf_tracker_{MOCK_SERIAL_NUMBER}",
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key="invalid_fressnapf_tracker",
translation_placeholders={"tracker_id": MOCK_SERIAL_NUMBER},
)
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
assert len(issue_registry.issues) == 1

View File

@@ -63,10 +63,10 @@ async def test_not_added_when_no_led(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
mock_api_client: MagicMock,
mock_api_client_init: MagicMock,
) -> None:
"""Test light entity is created correctly."""
mock_api_client.get_tracker.return_value = TRACKER_NO_LED
mock_api_client_init.get_tracker.return_value = TRACKER_NO_LED
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
@@ -81,7 +81,7 @@ async def test_not_added_when_no_led(
@pytest.mark.usefixtures("init_integration")
async def test_turn_on(
hass: HomeAssistant,
mock_api_client: MagicMock,
mock_api_client_coordinator: MagicMock,
) -> None:
"""Test turning the light on."""
entity_id = "light.fluffy_flashlight"
@@ -97,13 +97,13 @@ async def test_turn_on(
blocking=True,
)
mock_api_client.set_led_brightness.assert_called_once_with(100)
mock_api_client_coordinator.set_led_brightness.assert_called_once_with(100)
@pytest.mark.usefixtures("init_integration")
async def test_turn_on_with_brightness(
hass: HomeAssistant,
mock_api_client: MagicMock,
mock_api_client_coordinator: MagicMock,
) -> None:
"""Test turning the light on with brightness."""
entity_id = "light.fluffy_flashlight"
@@ -116,13 +116,13 @@ async def test_turn_on_with_brightness(
)
# 128/255 * 100 = 50
mock_api_client.set_led_brightness.assert_called_once_with(50)
mock_api_client_coordinator.set_led_brightness.assert_called_once_with(50)
@pytest.mark.usefixtures("init_integration")
async def test_turn_off(
hass: HomeAssistant,
mock_api_client: MagicMock,
mock_api_client_coordinator: MagicMock,
) -> None:
"""Test turning the light off."""
entity_id = "light.fluffy_flashlight"
@@ -138,7 +138,7 @@ async def test_turn_off(
blocking=True,
)
mock_api_client.set_led_brightness.assert_called_once_with(0)
mock_api_client_coordinator.set_led_brightness.assert_called_once_with(0)
@pytest.mark.parametrize(
@@ -153,12 +153,13 @@ async def test_turn_off(
async def test_turn_on_led_not_activatable(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_api_client: MagicMock,
mock_api_client_init: MagicMock,
mock_api_client_coordinator: MagicMock,
activatable_parameter: str,
) -> None:
"""Test turning on the light when LED is not activatable raises."""
setattr(
mock_api_client.get_tracker.return_value.led_activatable,
mock_api_client_init.get_tracker.return_value.led_activatable,
activatable_parameter,
False,
)
@@ -177,7 +178,7 @@ async def test_turn_on_led_not_activatable(
blocking=True,
)
mock_api_client.set_led_brightness.assert_not_called()
mock_api_client_coordinator.set_led_brightness.assert_not_called()
@pytest.mark.parametrize(
@@ -191,11 +192,11 @@ async def test_turn_on_led_not_activatable(
],
)
@pytest.mark.parametrize("service", [SERVICE_TURN_ON, SERVICE_TURN_OFF])
@pytest.mark.usefixtures("mock_auth_client")
@pytest.mark.usefixtures("mock_auth_client", "mock_api_client_init")
async def test_turn_on_off_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_api_client: MagicMock,
mock_api_client_coordinator: MagicMock,
api_exception: FressnapfTrackerError,
expected_exception: type[HomeAssistantError],
service: str,
@@ -208,7 +209,7 @@ async def test_turn_on_off_error(
entity_id = "light.fluffy_flashlight"
mock_api_client.set_led_brightness.side_effect = api_exception
mock_api_client_coordinator.set_led_brightness.side_effect = api_exception
with pytest.raises(expected_exception):
await hass.services.async_call(
LIGHT_DOMAIN,

View File

@@ -62,10 +62,10 @@ async def test_not_added_when_no_energy_saving_mode(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
mock_api_client: MagicMock,
mock_api_client_init: MagicMock,
) -> None:
"""Test switch entity is created correctly."""
mock_api_client.get_tracker.return_value = TRACKER_NO_ENERGY_SAVING_MODE
mock_api_client_init.get_tracker.return_value = TRACKER_NO_ENERGY_SAVING_MODE
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
@@ -80,7 +80,7 @@ async def test_not_added_when_no_energy_saving_mode(
@pytest.mark.usefixtures("init_integration")
async def test_turn_on(
hass: HomeAssistant,
mock_api_client: MagicMock,
mock_api_client_coordinator: MagicMock,
) -> None:
"""Test turning the switch on."""
entity_id = "switch.fluffy_sleep_mode"
@@ -96,13 +96,13 @@ async def test_turn_on(
blocking=True,
)
mock_api_client.set_energy_saving.assert_called_once_with(True)
mock_api_client_coordinator.set_energy_saving.assert_called_once_with(True)
@pytest.mark.usefixtures("init_integration")
async def test_turn_off(
hass: HomeAssistant,
mock_api_client: MagicMock,
mock_api_client_coordinator: MagicMock,
) -> None:
"""Test turning the switch off."""
entity_id = "switch.fluffy_sleep_mode"
@@ -118,7 +118,7 @@ async def test_turn_off(
blocking=True,
)
mock_api_client.set_energy_saving.assert_called_once_with(False)
mock_api_client_coordinator.set_energy_saving.assert_called_once_with(False)
@pytest.mark.parametrize(
@@ -132,11 +132,11 @@ async def test_turn_off(
],
)
@pytest.mark.parametrize("service", [SERVICE_TURN_ON, SERVICE_TURN_OFF])
@pytest.mark.usefixtures("mock_auth_client")
@pytest.mark.usefixtures("mock_auth_client", "mock_api_client_init")
async def test_turn_on_off_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_api_client: MagicMock,
mock_api_client_coordinator: MagicMock,
api_exception: FressnapfTrackerError,
expected_exception: type[HomeAssistantError],
service: str,
@@ -149,7 +149,7 @@ async def test_turn_on_off_error(
entity_id = "switch.fluffy_sleep_mode"
mock_api_client.set_energy_saving.side_effect = api_exception
mock_api_client_coordinator.set_energy_saving.side_effect = api_exception
with pytest.raises(expected_exception):
await hass.services.async_call(
SWITCH_DOMAIN,

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,177 @@
"""Tests for Overkiz entity."""
from unittest.mock import Mock
import pytest
from homeassistant.components.overkiz.entity import OverkizEntity
def _create_mock_device(
device_url: str, place_oid: str | None, label: str = "Device"
) -> Mock:
"""Create a mock device with the given properties."""
device = Mock()
device.device_url = device_url
device.place_oid = place_oid
device.label = label
device.available = True
device.states = []
device.widget = Mock(value="TestWidget")
device.controllable_name = "test:Component"
return device
def _create_mock_entity(device: Mock, all_devices: list[Mock]) -> Mock:
"""Create a mock entity with the given device and coordinator data."""
entity = Mock(spec=OverkizEntity)
entity.device = device
entity.device_url = device.device_url
entity.base_device_url = device.device_url.split("#")[0]
entity.coordinator = Mock()
entity.coordinator.data = {d.device_url: d for d in all_devices}
prefix = f"{entity.base_device_url}#"
entity._get_sibling_devices = lambda: [
d
for d in all_devices
if d.device_url != device.device_url and d.device_url.startswith(prefix)
]
entity._get_device_index = lambda url: (
int(url.split("#")[-1]) if url.split("#")[-1].isdigit() else None
)
return entity
@pytest.mark.parametrize(
("place_oids", "expected"),
[
(["place-a", "place-b"], True),
(["place-a", "place-a"], False),
],
ids=["different_place_oids", "same_place_oids"],
)
def test_has_siblings_with_different_place_oid(
place_oids: list[str], expected: bool
) -> None:
"""Test detection of siblings with different placeOIDs."""
devices = [
_create_mock_device("io://gateway/123#1", place_oids[0], "Device 1"),
_create_mock_device("io://gateway/123#2", place_oids[1], "Device 2"),
]
entity = _create_mock_entity(devices[0], devices)
result = OverkizEntity._has_siblings_with_different_place_oid(entity)
assert result is expected
@pytest.mark.parametrize(
("device_index", "expected"),
[
(0, True),
(1, False),
],
ids=["lowest_index_is_main", "higher_index_not_main"],
)
def test_is_main_device_for_place_oid(device_index: int, expected: bool) -> None:
"""Test main device detection for placeOID group."""
devices = [
_create_mock_device("io://gateway/123#1", "place-a", "Device 1"),
_create_mock_device("io://gateway/123#4", "place-a", "Device 4"),
]
entity = _create_mock_entity(devices[device_index], devices)
result = OverkizEntity._is_main_device_for_place_oid(entity)
assert result is expected
def test_get_via_device_id_sub_device_links_to_main() -> None:
"""Test sub-device links to main actuator with placeOID grouping."""
devices = [
_create_mock_device("io://gateway/123#1", "place-a", "Actuator"),
_create_mock_device("io://gateway/123#2", "place-b", "Zone"),
]
entity = _create_mock_entity(devices[1], devices)
entity.executor = Mock()
entity.executor.get_gateway_id = Mock(return_value="gateway-id")
result = OverkizEntity._get_via_device_id(entity, use_place_oid_grouping=True)
assert result == "io://gateway/123#place-a"
def test_get_via_device_id_main_device_links_to_gateway() -> None:
"""Test main device (#1) links to gateway."""
devices = [
_create_mock_device("io://gateway/123#1", "place-a", "Actuator"),
]
entity = _create_mock_entity(devices[0], devices)
entity.executor = Mock()
entity.executor.get_gateway_id = Mock(return_value="gateway-id")
result = OverkizEntity._get_via_device_id(entity, use_place_oid_grouping=True)
assert result == "gateway-id"
def test_has_siblings_with_no_place_oid() -> None:
"""Test device with no placeOID returns False."""
devices = [
_create_mock_device("io://gateway/123#1", None, "Device 1"),
_create_mock_device("io://gateway/123#2", "place-b", "Device 2"),
]
entity = _create_mock_entity(devices[0], devices)
result = OverkizEntity._has_siblings_with_different_place_oid(entity)
assert result is False
def test_is_main_device_with_no_place_oid() -> None:
"""Test device with no placeOID is always considered main."""
devices = [
_create_mock_device("io://gateway/123#2", None, "Device 2"),
_create_mock_device("io://gateway/123#1", "place-a", "Device 1"),
]
entity = _create_mock_entity(devices[0], devices)
result = OverkizEntity._is_main_device_for_place_oid(entity)
assert result is True
def test_get_via_device_id_main_device_without_place_oid() -> None:
"""Test fallback to gateway when #1 device has no placeOID."""
devices = [
_create_mock_device("io://gateway/123#1", None, "Actuator"),
_create_mock_device("io://gateway/123#2", "place-b", "Zone"),
]
entity = _create_mock_entity(devices[1], devices)
entity.executor = Mock()
entity.executor.get_gateway_id = Mock(return_value="gateway-id")
result = OverkizEntity._get_via_device_id(entity, use_place_oid_grouping=True)
assert result == "gateway-id"
@pytest.mark.parametrize(
("device_url", "expected"),
[
("io://gateway/123#4", 4),
("io://gateway/123#10", 10),
("io://gateway/123#abc", None),
("io://gateway/123#", None),
],
ids=["single_digit", "multi_digit", "non_numeric", "empty_suffix"],
)
def test_get_device_index(device_url: str, expected: int | None) -> None:
"""Test extracting numeric index from device URL."""
device = _create_mock_device(device_url, "place-a")
entity = _create_mock_entity(device, [device])
result = OverkizEntity._get_device_index(entity, device_url)
assert result == expected

View File

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

View File

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

View File

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

View File

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

View File

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