Compare commits

..

25 Commits

Author SHA1 Message Date
Jan Čermák
d1d105a3a2 Sleep twice as suggested in PR 2026-03-13 16:29:19 +01:00
Jan Čermák
3102fad342 Bump base image to 2026.02.0 with Python 3.14.3, use 3.14.3 in CI
This also bumps libcec used in the base image to 7.1.1, full changelog:
* https://github.com/home-assistant/docker/releases/tag/2026.02.0

Python changelog:
* https://docs.python.org/release/3.14.3/whatsnew/changelog.html
2026-03-13 16:28:33 +01:00
Robin Lintermann
6962288e85 Add spring status sensor entity (#164332) 2026-03-13 14:29:37 +01:00
Eli Sand
fab4355cc8 Enhance generic_thermostat with min/max run time and cooldown time (#136298) 2026-03-13 14:22:33 +01:00
Robin Lintermann
e39d84e8fc Bump pysmarlaapi to 1.0.2 (#165454) 2026-03-13 12:46:09 +01:00
Christian Lackas
35f597223a Add DHW operating mode select entity to ViCare integration (#163832) 2026-03-13 12:44:24 +01:00
Galorhallen
9d61c8336d Update govee local api to 2.4.0 (#165418) 2026-03-13 12:43:41 +01:00
Robert Resch
6fd3603b7b Bump orjson to 3.11.7 (#165443) 2026-03-13 12:34:13 +01:00
epenet
49ac5c42ee Add base entity to arcam_fmj (#165447) 2026-03-13 12:27:52 +01:00
epenet
df0db5853c Fix device name in arcam_fmj (#165448) 2026-03-13 12:25:52 +01:00
dependabot[bot]
7afc5b777c Bump docker/metadata-action from 5.10.0 to 6.0.0 (#165438)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-13 12:25:35 +01:00
dependabot[bot]
595aeea8cc Bump github/codeql-action from 4.32.4 to 4.32.6 (#165436)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-13 12:22:09 +01:00
dependabot[bot]
02abba02d1 Bump docker/setup-buildx-action from 3.12.0 to 4.0.0 (#165437)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-13 12:21:54 +01:00
dependabot[bot]
4ca1ad96f1 Bump docker/build-push-action from 6.19.2 to 7.0.0 (#165435)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-13 12:21:20 +01:00
Erik Montnemery
9f3beba97a Fix vera test opening sockets (#165439) 2026-03-13 11:00:17 +01:00
johanzander
9f86006328 Update Growatt quality scale: add config flow data descriptions (#165426)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 08:46:14 +01:00
Erik Montnemery
4ac651d0b4 Add occupancy triggers (#165374) 2026-03-13 08:41:48 +01:00
J. Nick Koston
9e54abbcb5 Handle OAuth token request exceptions in Yale setup (#165430) 2026-03-13 08:19:24 +01:00
Erik Montnemery
d5915c8811 Add motion triggers (#165373) 2026-03-13 07:54:51 +01:00
Erik Montnemery
0c2887df9e Fix numerical entity trigger schema (#165411) 2026-03-13 07:32:43 +01:00
Zach Feldman
3767bac850 August oauth2 exception migration (#165397)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-03-12 17:28:08 -10:00
J. Nick Koston
9d962d3815 Add missing ON_OFF support and target_temperature_step to ESPHome water heater (#165427)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-12 16:10:29 -10:00
Bram Kragten
786fd40ae8 Update frontend to 20260312.0 (#165420) 2026-03-12 23:07:04 +01:00
Joakim Plate
5ec65dbd58 Remove use of media player internals in arcam (#165359) 2026-03-12 21:55:39 +00:00
Josef Zweck
35878bb203 Bump onedrive-personal-sdk to 0.1.7 (#165401) 2026-03-12 21:59:40 +01:00
75 changed files with 2570 additions and 303 deletions

View File

@@ -14,7 +14,7 @@ env:
UV_HTTP_TIMEOUT: 60
UV_SYSTEM_PYTHON: "true"
# Base image version from https://github.com/home-assistant/docker
BASE_IMAGE_VERSION: "2026.01.0"
BASE_IMAGE_VERSION: "2026.02.0"
ARCHITECTURES: '["amd64", "aarch64"]'
permissions: {}
@@ -208,7 +208,7 @@ jobs:
cosign-release: "v2.5.3"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Build variables
id: vars
@@ -242,7 +242,7 @@ jobs:
- name: Build base image
id: build
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with:
context: .
file: ./Dockerfile
@@ -442,7 +442,7 @@ jobs:
# 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
- name: Generate Docker metadata
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ${{ matrix.registry }}/home-assistant
sep-tags: ","
@@ -456,7 +456,7 @@ jobs:
type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.7.1
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v3.7.1
- name: Copy architecture images to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
@@ -592,7 +592,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker image
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile
@@ -605,7 +605,7 @@ jobs:
- name: Push Docker image
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
id: push
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile

View File

@@ -28,11 +28,11 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
with:
category: "/language:python"

View File

@@ -1 +1 @@
3.14.2
3.14.3

4
CODEOWNERS generated
View File

@@ -1071,6 +1071,8 @@ build.json @home-assistant/supervisor
/tests/components/moon/ @fabaff @frenck
/homeassistant/components/mopeka/ @bdraco
/tests/components/mopeka/ @bdraco
/homeassistant/components/motion/ @home-assistant/core
/tests/components/motion/ @home-assistant/core
/homeassistant/components/motion_blinds/ @starkillerOG
/tests/components/motion_blinds/ @starkillerOG
/homeassistant/components/motionblinds_ble/ @LennP @jerrybboy
@@ -1184,6 +1186,8 @@ build.json @home-assistant/supervisor
/tests/components/nzbget/ @chriscla
/homeassistant/components/obihai/ @dshokouhi @ejpenney
/tests/components/obihai/ @dshokouhi @ejpenney
/homeassistant/components/occupancy/ @home-assistant/core
/tests/components/occupancy/ @home-assistant/core
/homeassistant/components/octoprint/ @rfleming71
/tests/components/octoprint/ @rfleming71
/homeassistant/components/ohmconnect/ @robbiet480

View File

@@ -245,6 +245,8 @@ DEFAULT_INTEGRATIONS = {
"garage_door",
"gate",
"humidity",
"motion",
"occupancy",
"window",
}
DEFAULT_INTEGRATIONS_RECOVERY_MODE = {

View File

@@ -66,6 +66,7 @@ class ArcamFmjCoordinator(DataUpdateCoordinator[None]):
model="Arcam FMJ AVR",
name=name,
)
self.zone_unique_id = f"{unique_id}-{zone}"
if zone != 1:
self.device_info["via_device"] = (DOMAIN, unique_id)

View File

@@ -0,0 +1,20 @@
"""Base entity for Arcam FMJ integration."""
from __future__ import annotations
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import ArcamFmjCoordinator
class ArcamFmjEntity(CoordinatorEntity[ArcamFmjCoordinator]):
"""Base entity for Arcam FMJ."""
_attr_has_entity_name = True
def __init__(self, coordinator: ArcamFmjCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._attr_device_info = coordinator.device_info
self._attr_entity_registry_enabled_default = coordinator.state.zn == 1
self._attr_unique_id = coordinator.zone_unique_id

View File

@@ -22,10 +22,10 @@ from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import EVENT_TURN_ON
from .coordinator import ArcamFmjConfigEntry, ArcamFmjCoordinator
from .entity import ArcamFmjEntity
_LOGGER = logging.getLogger(__name__)
@@ -39,14 +39,7 @@ async def async_setup_entry(
coordinators = config_entry.runtime_data.coordinators
async_add_entities(
[
ArcamFmj(
config_entry.title,
coordinators[zone],
config_entry.unique_id or config_entry.entry_id,
)
for zone in (1, 2)
],
[ArcamFmj(coordinators[zone]) for zone in (1, 2)],
)
@@ -67,21 +60,13 @@ def convert_exception[**_P, _R](
return _convert_exception
class ArcamFmj(CoordinatorEntity[ArcamFmjCoordinator], MediaPlayerEntity):
class ArcamFmj(ArcamFmjEntity, MediaPlayerEntity):
"""Representation of a media device."""
_attr_has_entity_name = True
def __init__(
self,
device_name: str,
coordinator: ArcamFmjCoordinator,
uuid: str,
) -> None:
def __init__(self, coordinator: ArcamFmjCoordinator) -> None:
"""Initialize device."""
super().__init__(coordinator)
self._state = coordinator.state
self._attr_name = f"Zone {self._state.zn}"
self._attr_supported_features = (
MediaPlayerEntityFeature.SELECT_SOURCE
| MediaPlayerEntityFeature.PLAY_MEDIA
@@ -94,9 +79,6 @@ class ArcamFmj(CoordinatorEntity[ArcamFmjCoordinator], MediaPlayerEntity):
)
if self._state.zn == 1:
self._attr_supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE
self._attr_unique_id = f"{uuid}-{self._state.zn}"
self._attr_entity_registry_enabled_default = self._state.zn == 1
self._attr_device_info = coordinator.device_info
@property
def state(self) -> MediaPlayerState:

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from pathlib import Path
from typing import cast
from aiohttp import ClientResponseError
from aiohttp import ClientError
from yalexs.exceptions import AugustApiAIOHTTPError
from yalexs.manager.exceptions import CannotConnect, InvalidAuth, RequireValidation
from yalexs.manager.gateway import Config as YaleXSConfig
@@ -13,7 +13,12 @@ from yalexs.manager.gateway import Config as YaleXSConfig
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
OAuth2TokenRequestError,
OAuth2TokenRequestReauthError,
)
from homeassistant.helpers import device_registry as dr, issue_registry as ir
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
@@ -45,11 +50,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: AugustConfigEntry) -> bo
august_gateway = AugustGateway(Path(hass.config.config_dir), session, oauth_session)
try:
await async_setup_august(hass, entry, august_gateway)
except OAuth2TokenRequestReauthError as err:
raise ConfigEntryAuthFailed from err
except (RequireValidation, InvalidAuth) as err:
raise ConfigEntryAuthFailed from err
except TimeoutError as err:
raise ConfigEntryNotReady("Timed out connecting to august api") from err
except (AugustApiAIOHTTPError, ClientResponseError, CannotConnect) as err:
except (
AugustApiAIOHTTPError,
OAuth2TokenRequestError,
ClientError,
CannotConnect,
) as err:
raise ConfigEntryNotReady from err
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True

View File

@@ -152,6 +152,8 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"light",
"lock",
"media_player",
"motion",
"occupancy",
"person",
"remote",
"scene",

View File

@@ -5,7 +5,13 @@ from __future__ import annotations
from functools import partial
from typing import Any
from aioesphomeapi import EntityInfo, WaterHeaterInfo, WaterHeaterMode, WaterHeaterState
from aioesphomeapi import (
EntityInfo,
WaterHeaterFeature,
WaterHeaterInfo,
WaterHeaterMode,
WaterHeaterState,
)
from homeassistant.components.water_heater import (
WaterHeaterEntity,
@@ -54,6 +60,7 @@ class EsphomeWaterHeater(
static_info = self._static_info
self._attr_min_temp = static_info.min_temperature
self._attr_max_temp = static_info.max_temperature
self._attr_target_temperature_step = static_info.target_temperature_step
features = WaterHeaterEntityFeature.TARGET_TEMPERATURE
if static_info.supported_modes:
features |= WaterHeaterEntityFeature.OPERATION_MODE
@@ -63,6 +70,8 @@ class EsphomeWaterHeater(
]
else:
self._attr_operation_list = None
if static_info.supported_features & WaterHeaterFeature.SUPPORTS_ON_OFF:
features |= WaterHeaterEntityFeature.ON_OFF
self._attr_supported_features = features
@property
@@ -101,6 +110,24 @@ class EsphomeWaterHeater(
device_id=self._static_info.device_id,
)
@convert_api_error_ha_error
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the water heater on."""
self._client.water_heater_command(
key=self._key,
on=True,
device_id=self._static_info.device_id,
)
@convert_api_error_ha_error
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the water heater off."""
self._client.water_heater_command(
key=self._key,
on=False,
device_id=self._static_info.device_id,
)
async_setup_entry = partial(
platform_async_setup_entry,

View File

@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260304.0"]
"requirements": ["home-assistant-frontend==20260312.0"]
}

View File

@@ -12,7 +12,7 @@ from homeassistant.helpers.helper_integration import (
async_remove_helper_config_entry_from_source_device,
)
from .const import CONF_HEATER, CONF_SENSOR, PLATFORMS
from .const import CONF_DUR_COOLDOWN, CONF_HEATER, CONF_MIN_DUR, CONF_SENSOR, PLATFORMS
_LOGGER = logging.getLogger(__name__)
@@ -91,8 +91,13 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
helper_config_entry_id=config_entry.entry_id,
source_device_id=source_device_id,
)
if config_entry.minor_version < 3:
# Set `cycle_cooldown` to `min_cycle_duration` to mimic the old behavior
if CONF_MIN_DUR in options:
options[CONF_DUR_COOLDOWN] = options[CONF_MIN_DUR]
hass.config_entries.async_update_entry(
config_entry, options=options, minor_version=2
config_entry, options=options, minor_version=3
)
_LOGGER.debug(

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
from collections.abc import Mapping
from datetime import datetime, timedelta
from functools import partial
import logging
import math
from typing import Any
@@ -38,7 +39,9 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import (
CALLBACK_TYPE,
DOMAIN as HOMEASSISTANT_DOMAIN,
Context,
CoreState,
Event,
EventStateChangedData,
@@ -46,27 +49,30 @@ from homeassistant.core import (
State,
callback,
)
from homeassistant.exceptions import ConditionError
from homeassistant.helpers import condition, config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device import async_entity_id_to_device
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.helpers.event import (
async_call_later,
async_track_state_change_event,
async_track_time_interval,
)
from homeassistant.helpers.reload import async_setup_reload_service
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolDictType
from homeassistant.util import dt as dt_util
from .const import (
CONF_AC_MODE,
CONF_COLD_TOLERANCE,
CONF_DUR_COOLDOWN,
CONF_HEATER,
CONF_HOT_TOLERANCE,
CONF_KEEP_ALIVE,
CONF_MAX_DUR,
CONF_MAX_TEMP,
CONF_MIN_DUR,
CONF_MIN_TEMP,
@@ -98,6 +104,8 @@ PLATFORM_SCHEMA_COMMON = vol.Schema(
vol.Optional(CONF_AC_MODE): cv.boolean,
vol.Optional(CONF_MAX_TEMP): vol.Coerce(float),
vol.Optional(CONF_MIN_DUR): cv.positive_time_period,
vol.Optional(CONF_MAX_DUR): cv.positive_time_period,
vol.Optional(CONF_DUR_COOLDOWN): cv.positive_time_period,
vol.Optional(CONF_MIN_TEMP): vol.Coerce(float),
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_COLD_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce(float),
@@ -167,6 +175,8 @@ async def _async_setup_config(
target_temp: float | None = config.get(CONF_TARGET_TEMP)
ac_mode: bool | None = config.get(CONF_AC_MODE)
min_cycle_duration: timedelta | None = config.get(CONF_MIN_DUR)
max_cycle_duration: timedelta | None = config.get(CONF_MAX_DUR)
cycle_cooldown: timedelta | None = config.get(CONF_DUR_COOLDOWN)
cold_tolerance: float = config[CONF_COLD_TOLERANCE]
hot_tolerance: float = config[CONF_HOT_TOLERANCE]
keep_alive: timedelta | None = config.get(CONF_KEEP_ALIVE)
@@ -190,6 +200,8 @@ async def _async_setup_config(
target_temp=target_temp,
ac_mode=ac_mode,
min_cycle_duration=min_cycle_duration,
max_cycle_duration=max_cycle_duration,
cycle_cooldown=cycle_cooldown,
cold_tolerance=cold_tolerance,
hot_tolerance=hot_tolerance,
keep_alive=keep_alive,
@@ -221,6 +233,8 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
target_temp: float | None,
ac_mode: bool | None,
min_cycle_duration: timedelta | None,
max_cycle_duration: timedelta | None,
cycle_cooldown: timedelta | None,
cold_tolerance: float,
hot_tolerance: float,
keep_alive: timedelta | None,
@@ -240,8 +254,16 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
heater_entity_id,
)
self.ac_mode = ac_mode
self.min_cycle_duration = min_cycle_duration
self.min_cycle_duration = min_cycle_duration or timedelta()
self.max_cycle_duration = max_cycle_duration
self.cycle_cooldown = cycle_cooldown or timedelta()
self._cold_tolerance = cold_tolerance
# Subtract the cooldown so it doesn't impact startup
self._last_toggled_time = dt_util.utcnow() - self.cycle_cooldown
self._cycle_callback: CALLBACK_TYPE | None = None
self._check_callback: CALLBACK_TYPE | None = None
# Context ID used to detect our own toggles
self._last_context_id: str | None = None
self._hot_tolerance = hot_tolerance
self._keep_alive = keep_alive
self._hvac_mode = initial_hvac_mode
@@ -289,6 +311,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
self.hass, [self.heater_entity_id], self._async_switch_changed
)
)
self.async_on_remove(self._cancel_timers)
if self._keep_alive:
self.async_on_remove(
@@ -482,6 +505,18 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
self.hass.async_create_task(
self._check_switch_initial_state(), eager_start=True
)
# Update timestamp on toggle
self._last_toggled_time = new_state.last_changed
# If the user toggles the switch, assume they want control and clear the timers.
# Note: If a manual interaction occurs within the 2s context window of a switch
# toggle initiated by us, we may not detect manual control. Users are advised to
# use the climate entity for reliable control, not the switch entity.
if new_state.context.id != self._last_context_id:
_LOGGER.debug("External switch change detected, clearing timers")
self._last_context_id = None
self._cancel_timers()
self.async_write_ha_state()
@callback
@@ -517,57 +552,69 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
if not self._active or self._hvac_mode == HVACMode.OFF:
return
# If the `force` argument is True, we
# ignore `min_cycle_duration`.
# If the `time` argument is not none, we were invoked for
# keep-alive purposes, and `min_cycle_duration` is irrelevant.
if not force and time is None and self.min_cycle_duration:
if self._is_device_active:
current_state = STATE_ON
else:
current_state = HVACMode.OFF
try:
long_enough = condition.state(
self.hass,
self.heater_entity_id,
current_state,
self.min_cycle_duration,
)
except ConditionError:
long_enough = False
if not long_enough:
return
if force and time is not None and self.max_cycle_duration:
# We were invoked due to `max_cycle_duration`, so turn off
_LOGGER.debug(
"Turning off heater %s due to max cycle time of %s",
self.heater_entity_id,
self.max_cycle_duration,
)
self._cancel_cycle_timer()
await self._async_heater_turn_off()
return
assert self._cur_temp is not None and self._target_temp is not None
min_temp = self._target_temp - self._cold_tolerance
max_temp = self._target_temp + self._hot_tolerance
too_cold = self._target_temp > self._cur_temp + self._cold_tolerance
too_hot = self._target_temp < self._cur_temp - self._hot_tolerance
now = dt_util.utcnow()
if self._is_device_active:
if (self.ac_mode and self._cur_temp <= min_temp) or (
not self.ac_mode and self._cur_temp >= max_temp
):
_LOGGER.debug("Turning off heater %s", self.heater_entity_id)
await self._async_heater_turn_off()
if (self.ac_mode and too_cold) or (not self.ac_mode and too_hot):
# Make sure it's past the `min_cycle_duration` before turning off
if (
self._last_toggled_time + self.min_cycle_duration <= now
or force
):
_LOGGER.debug("Turning off heater %s", self.heater_entity_id)
await self._async_heater_turn_off()
elif self._check_callback is None:
_LOGGER.debug(
"Minimum cycle time not reached, check again at %s",
self._last_toggled_time + self.min_cycle_duration,
)
self._check_callback = async_call_later(
self.hass,
now - self._last_toggled_time + self.min_cycle_duration,
self._async_timer_control_heating,
)
elif time is not None:
# The time argument is passed only in keep-alive case
# This is a keep-alive call, so ensure it's on
_LOGGER.debug(
"Keep-alive - Turning on heater heater %s",
"Keep-alive - Turning on heater %s",
self.heater_entity_id,
)
await self._async_heater_turn_on(keepalive=True)
elif (self.ac_mode and too_hot) or (not self.ac_mode and too_cold):
# Make sure it's past the `cycle_cooldown` before turning on
if self._last_toggled_time + self.cycle_cooldown <= now or force:
_LOGGER.debug("Turning on heater %s", self.heater_entity_id)
await self._async_heater_turn_on()
elif (self.ac_mode and self._cur_temp > max_temp) or (
not self.ac_mode and self._cur_temp < min_temp
):
_LOGGER.debug("Turning on heater %s", self.heater_entity_id)
await self._async_heater_turn_on()
elif self._check_callback is None:
_LOGGER.debug(
"Cooldown time not reached, check again at %s",
self._last_toggled_time + self.cycle_cooldown,
)
self._check_callback = async_call_later(
self.hass,
now - self._last_toggled_time + self.cycle_cooldown,
self._async_timer_control_heating,
)
elif time is not None:
# The time argument is passed only in keep-alive case
# This is a keep-alive call, so ensure it's off
_LOGGER.debug(
"Keep-alive - Turning off heater %s", self.heater_entity_id
)
await self._async_heater_turn_off()
await self._async_heater_turn_off(keepalive=True)
@property
def _is_device_active(self) -> bool | None:
@@ -577,19 +624,48 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
return self.hass.states.is_state(self.heater_entity_id, STATE_ON)
async def _async_heater_turn_on(self) -> None:
async def _async_heater_turn_on(self, keepalive: bool = False) -> None:
"""Turn heater toggleable device on."""
data = {ATTR_ENTITY_ID: self.heater_entity_id}
# Create a new context for this service call so we can identify
# the resulting state change event as originating from us
new_context = Context(parent_id=self._context.id if self._context else None)
self.async_set_context(new_context)
self._last_context_id = new_context.id
await self.hass.services.async_call(
HOMEASSISTANT_DOMAIN, SERVICE_TURN_ON, data, context=self._context
HOMEASSISTANT_DOMAIN, SERVICE_TURN_ON, data, context=new_context
)
if not keepalive:
# Update timestamp on turn on
self._last_toggled_time = dt_util.utcnow()
self._cancel_check_timer()
if self.max_cycle_duration:
_LOGGER.debug(
"Scheduling maximum run-time shut-off for %s",
self._last_toggled_time + self.max_cycle_duration,
)
self._cancel_cycle_timer()
self._cycle_callback = async_call_later(
self.hass,
self.max_cycle_duration,
partial(self._async_control_heating, force=True),
)
async def _async_heater_turn_off(self) -> None:
async def _async_heater_turn_off(self, keepalive: bool = False) -> None:
"""Turn heater toggleable device off."""
data = {ATTR_ENTITY_ID: self.heater_entity_id}
# Create a new context for this service call so we can identify
# the resulting state change event as originating from us
new_context = Context(parent_id=self._context.id if self._context else None)
self.async_set_context(new_context)
self._last_context_id = new_context.id
await self.hass.services.async_call(
HOMEASSISTANT_DOMAIN, SERVICE_TURN_OFF, data, context=self._context
HOMEASSISTANT_DOMAIN, SERVICE_TURN_OFF, data, context=new_context
)
if not keepalive:
# Update timestamp on turn off
self._last_toggled_time = dt_util.utcnow()
self._cancel_timers()
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
@@ -613,3 +689,30 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
await self._async_control_heating(force=True)
self.async_write_ha_state()
async def _async_timer_control_heating(self, _: datetime | None = None) -> None:
"""Reset check timer and control heating."""
self._check_callback = None
await self._async_control_heating()
@callback
def _cancel_check_timer(self) -> None:
"""Reset check timer."""
if self._check_callback:
_LOGGER.debug("Cancelling scheduled state check")
self._check_callback()
self._check_callback = None
@callback
def _cancel_cycle_timer(self) -> None:
"""Reset cycle timer."""
if self._cycle_callback:
_LOGGER.debug("Cancelling scheduled shut-off")
self._cycle_callback()
self._cycle_callback = None
@callback
def _cancel_timers(self) -> None:
"""Reset timers."""
self._cancel_check_timer()
self._cancel_cycle_timer()

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import Mapping
from datetime import timedelta
from typing import Any, cast
import voluptuous as vol
@@ -12,16 +13,20 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDevic
from homeassistant.const import CONF_NAME, DEGREE
from homeassistant.helpers import selector
from homeassistant.helpers.schema_config_entry_flow import (
SchemaCommonFlowHandler,
SchemaConfigFlowHandler,
SchemaFlowError,
SchemaFlowFormStep,
)
from .const import (
CONF_AC_MODE,
CONF_COLD_TOLERANCE,
CONF_DUR_COOLDOWN,
CONF_HEATER,
CONF_HOT_TOLERANCE,
CONF_KEEP_ALIVE,
CONF_MAX_DUR,
CONF_MAX_TEMP,
CONF_MIN_DUR,
CONF_MIN_TEMP,
@@ -63,6 +68,12 @@ OPTIONS_SCHEMA = {
vol.Optional(CONF_KEEP_ALIVE): selector.DurationSelector(
selector.DurationSelectorConfig(allow_negative=False)
),
vol.Optional(CONF_MAX_DUR): selector.DurationSelector(
selector.DurationSelectorConfig(allow_negative=False)
),
vol.Optional(CONF_DUR_COOLDOWN): selector.DurationSelector(
selector.DurationSelectorConfig(allow_negative=False)
),
vol.Optional(CONF_MIN_TEMP): selector.NumberSelector(
selector.NumberSelectorConfig(
mode=selector.NumberSelectorMode.BOX, unit_of_measurement=DEGREE, step=0.1
@@ -90,13 +101,31 @@ CONFIG_SCHEMA = {
}
async def _validate_config(
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
) -> dict[str, Any]:
"""Validate config."""
if all(x in user_input for x in (CONF_MIN_DUR, CONF_MAX_DUR)):
min_cycle = timedelta(**user_input[CONF_MIN_DUR])
max_cycle = timedelta(**user_input[CONF_MAX_DUR])
if min_cycle >= max_cycle:
raise SchemaFlowError("min_max_runtime")
return user_input
CONFIG_FLOW = {
"user": SchemaFlowFormStep(vol.Schema(CONFIG_SCHEMA), next_step="presets"),
"presets": SchemaFlowFormStep(vol.Schema(PRESETS_SCHEMA)),
}
OPTIONS_FLOW = {
"init": SchemaFlowFormStep(vol.Schema(OPTIONS_SCHEMA), next_step="presets"),
"init": SchemaFlowFormStep(
vol.Schema(OPTIONS_SCHEMA),
validate_user_input=_validate_config,
next_step="presets",
),
"presets": SchemaFlowFormStep(vol.Schema(PRESETS_SCHEMA)),
}
@@ -104,7 +133,7 @@ OPTIONS_FLOW = {
class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
"""Handle a config or options flow."""
MINOR_VERSION = 2
MINOR_VERSION = 3
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW

View File

@@ -20,6 +20,8 @@ CONF_HEATER = "heater"
CONF_HOT_TOLERANCE = "hot_tolerance"
CONF_MAX_TEMP = "max_temp"
CONF_MIN_DUR = "min_cycle_duration"
CONF_MAX_DUR = "max_cycle_duration"
CONF_DUR_COOLDOWN = "cycle_cooldown"
CONF_MIN_TEMP = "min_temp"
CONF_PRESETS = {
p: f"{p}_temp"

View File

@@ -16,11 +16,13 @@
"data": {
"ac_mode": "Cooling mode",
"cold_tolerance": "Cold tolerance",
"cycle_cooldown": "Cooldown period after running",
"heater": "Actuator switch",
"hot_tolerance": "Hot tolerance",
"keep_alive": "Keep-alive interval",
"max_cycle_duration": "Maximum run time",
"max_temp": "Maximum target temperature",
"min_cycle_duration": "Minimum cycle duration",
"min_cycle_duration": "Minimum run time",
"min_temp": "Minimum target temperature",
"name": "[%key:common::config_flow::data::name%]",
"target_sensor": "Temperature sensor"
@@ -28,10 +30,12 @@
"data_description": {
"ac_mode": "Set the actuator specified to be treated as a cooling device instead of a heating device.",
"cold_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched on. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will start when the sensor goes below 24.5.",
"cycle_cooldown": "After switching off, the minimum amount of time that must elapse before it can be switched back on.",
"heater": "Switch entity used to cool or heat depending on A/C mode.",
"hot_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched off. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will stop when the sensor equals or goes above 25.5.",
"keep_alive": "Trigger the heater periodically to keep devices from losing state. When set, min cycle duration is ignored.",
"min_cycle_duration": "Set a minimum amount of time that the switch specified must be in its current state prior to being switched either off or on.",
"keep_alive": "Trigger the heater periodically to keep devices from losing state.",
"max_cycle_duration": "Once switched on, the maximum amount of time that can elapse before it will be switched off.",
"min_cycle_duration": "Once switched on, the minimum amount of time that must elapse before it may be switched off.",
"target_sensor": "Temperature sensor that reflects the current temperature."
},
"description": "Create a climate entity that controls the temperature via a switch and sensor.",
@@ -40,14 +44,19 @@
}
},
"options": {
"error": {
"min_max_runtime": "Minimum run time must be less than the maximum run time."
},
"step": {
"init": {
"data": {
"ac_mode": "[%key:component::generic_thermostat::config::step::user::data::ac_mode%]",
"cold_tolerance": "[%key:component::generic_thermostat::config::step::user::data::cold_tolerance%]",
"cycle_cooldown": "[%key:component::generic_thermostat::config::step::user::data::cycle_cooldown%]",
"heater": "[%key:component::generic_thermostat::config::step::user::data::heater%]",
"hot_tolerance": "[%key:component::generic_thermostat::config::step::user::data::hot_tolerance%]",
"keep_alive": "[%key:component::generic_thermostat::config::step::user::data::keep_alive%]",
"max_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data::max_cycle_duration%]",
"max_temp": "[%key:component::generic_thermostat::config::step::user::data::max_temp%]",
"min_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data::min_cycle_duration%]",
"min_temp": "[%key:component::generic_thermostat::config::step::user::data::min_temp%]",
@@ -56,9 +65,11 @@
"data_description": {
"ac_mode": "[%key:component::generic_thermostat::config::step::user::data_description::ac_mode%]",
"cold_tolerance": "[%key:component::generic_thermostat::config::step::user::data_description::cold_tolerance%]",
"cycle_cooldown": "[%key:component::generic_thermostat::config::step::user::data_description::cycle_cooldown%]",
"heater": "[%key:component::generic_thermostat::config::step::user::data_description::heater%]",
"hot_tolerance": "[%key:component::generic_thermostat::config::step::user::data_description::hot_tolerance%]",
"keep_alive": "[%key:component::generic_thermostat::config::step::user::data_description::keep_alive%]",
"max_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data_description::max_cycle_duration%]",
"min_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data_description::min_cycle_duration%]",
"target_sensor": "[%key:component::generic_thermostat::config::step::user::data_description::target_sensor%]"
}

View File

@@ -6,5 +6,5 @@
"dependencies": ["network"],
"documentation": "https://www.home-assistant.io/integrations/govee_light_local",
"iot_class": "local_push",
"requirements": ["govee-local-api==2.3.0"]
"requirements": ["govee-local-api==2.4.0"]
}

View File

@@ -5,9 +5,7 @@ rules:
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow:
status: todo
comment: data-descriptions missing
config-flow: done
dependency-transparency: done
docs-actions: done
docs-high-level-description: done
@@ -25,7 +23,7 @@ rules:
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: todo
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
@@ -55,7 +53,7 @@ rules:
status: todo
comment: Replace custom precision field with suggested_display_precision to preserve full data granularity.
entity-disabled-by-default: todo
entity-translations: todo
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo

View File

@@ -17,12 +17,20 @@
"region": "Server region",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "The password for your Growatt account.",
"region": "The server region that matches your Growatt account location.",
"username": "The email address or username for your Growatt account."
},
"title": "Enter your Growatt login credentials"
},
"plant": {
"data": {
"plant_id": "Plant"
},
"data_description": {
"plant_id": "The Growatt plant (solar installation) to integrate."
},
"title": "Select your plant"
},
"reauth_confirm": {
@@ -32,6 +40,12 @@
"token": "[%key:component::growatt_server::config::step::token_auth::data::token%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "[%key:component::growatt_server::config::step::password_auth::data_description::password%]",
"region": "[%key:component::growatt_server::config::step::password_auth::data_description::region%]",
"token": "[%key:component::growatt_server::config::step::token_auth::data_description::token%]",
"username": "[%key:component::growatt_server::config::step::password_auth::data_description::username%]"
},
"description": "Re-enter your credentials to continue using this integration.",
"title": "Re-authenticate with Growatt"
},
@@ -40,6 +54,10 @@
"region": "[%key:component::growatt_server::config::step::password_auth::data::region%]",
"token": "API token"
},
"data_description": {
"region": "[%key:component::growatt_server::config::step::password_auth::data_description::region%]",
"token": "The API token for your Growatt account. You can generate one via the Growatt web portal or ShinePhone app."
},
"description": "Token authentication is only supported for MIN/TLX devices. For other device types, please use username/password authentication.",
"title": "Enter your API token"
},

View File

@@ -225,6 +225,10 @@
"description": "System is unsupported because Home Assistant cannot determine when an Internet connection is available. For troubleshooting information, select Learn more.",
"title": "Unsupported system - Connectivity check disabled"
},
"unsupported_content_trust": {
"description": "System is unsupported because Home Assistant cannot verify content being run is trusted and not modified by attackers. For troubleshooting information, select Learn more.",
"title": "Unsupported system - Content-trust check disabled"
},
"unsupported_dbus": {
"description": "System is unsupported because D-Bus is working incorrectly. Many things fail without this as Supervisor cannot communicate with the host. For troubleshooting information, select Learn more.",
"title": "Unsupported system - D-Bus issues"
@@ -277,6 +281,10 @@
"description": "System is unsupported because additional software outside the Home Assistant ecosystem has been detected. For troubleshooting information, select Learn more.",
"title": "Unsupported system - Unsupported software"
},
"unsupported_source_mods": {
"description": "System is unsupported because Supervisor source code has been modified. For troubleshooting information, select Learn more.",
"title": "Unsupported system - Supervisor source modifications"
},
"unsupported_supervisor_version": {
"description": "System is unsupported because an out-of-date version of Supervisor is in use and auto-update has been disabled. For troubleshooting information, select Learn more.",
"title": "Unsupported system - Supervisor version"

View File

@@ -0,0 +1,17 @@
"""Integration for motion triggers."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
DOMAIN = "motion"
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
__all__ = []
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
return True

View File

@@ -0,0 +1,10 @@
{
"triggers": {
"cleared": {
"trigger": "mdi:motion-sensor-off"
},
"detected": {
"trigger": "mdi:motion-sensor"
}
}
}

View File

@@ -0,0 +1,8 @@
{
"domain": "motion",
"name": "Motion",
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/motion",
"integration_type": "system",
"quality_scale": "internal"
}

View File

@@ -0,0 +1,38 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted motion sensors to trigger on.",
"trigger_behavior_name": "Behavior"
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"title": "Motion",
"triggers": {
"cleared": {
"description": "Triggers after one or more motion sensors stop detecting motion.",
"fields": {
"behavior": {
"description": "[%key:component::motion::common::trigger_behavior_description%]",
"name": "[%key:component::motion::common::trigger_behavior_name%]"
}
},
"name": "Motion cleared"
},
"detected": {
"description": "Triggers after one or more motion sensors start detecting motion.",
"fields": {
"behavior": {
"description": "[%key:component::motion::common::trigger_behavior_description%]",
"name": "[%key:component::motion::common::trigger_behavior_name%]"
}
},
"name": "Motion detected"
}
}
}

View File

@@ -0,0 +1,53 @@
"""Provides triggers for motion."""
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
)
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import (
EntityTargetStateTriggerBase,
EntityTriggerBase,
Trigger,
get_device_class_or_undefined,
)
class _MotionBinaryTriggerBase(EntityTriggerBase):
"""Base trigger for motion binary sensor state changes."""
_domains = {BINARY_SENSOR_DOMAIN}
def entity_filter(self, entities: set[str]) -> set[str]:
"""Filter entities by motion device class."""
entities = super().entity_filter(entities)
return {
entity_id
for entity_id in entities
if get_device_class_or_undefined(self._hass, entity_id)
== BinarySensorDeviceClass.MOTION
}
class MotionDetectedTrigger(_MotionBinaryTriggerBase, EntityTargetStateTriggerBase):
"""Trigger for motion detected (binary sensor ON)."""
_to_states = {STATE_ON}
class MotionClearedTrigger(_MotionBinaryTriggerBase, EntityTargetStateTriggerBase):
"""Trigger for motion cleared (binary sensor OFF)."""
_to_states = {STATE_OFF}
TRIGGERS: dict[str, type[Trigger]] = {
"detected": MotionDetectedTrigger,
"cleared": MotionClearedTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for motion."""
return TRIGGERS

View File

@@ -0,0 +1,25 @@
.trigger_common_fields: &trigger_common_fields
behavior:
required: true
default: any
selector:
select:
translation_key: trigger_behavior
options:
- first
- last
- any
detected:
fields: *trigger_common_fields
target:
entity:
- domain: binary_sensor
device_class: motion
cleared:
fields: *trigger_common_fields
target:
entity:
- domain: binary_sensor
device_class: motion

View File

@@ -0,0 +1,17 @@
"""Integration for occupancy triggers."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
DOMAIN = "occupancy"
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
__all__ = []
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
return True

View File

@@ -0,0 +1,10 @@
{
"triggers": {
"cleared": {
"trigger": "mdi:home-outline"
},
"detected": {
"trigger": "mdi:home-account"
}
}
}

View File

@@ -0,0 +1,8 @@
{
"domain": "occupancy",
"name": "Occupancy",
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/occupancy",
"integration_type": "system",
"quality_scale": "internal"
}

View File

@@ -0,0 +1,38 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted occupancy sensors to trigger on.",
"trigger_behavior_name": "Behavior"
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"title": "Occupancy",
"triggers": {
"cleared": {
"description": "Triggers after one or more occupancy sensors stop detecting occupancy.",
"fields": {
"behavior": {
"description": "[%key:component::occupancy::common::trigger_behavior_description%]",
"name": "[%key:component::occupancy::common::trigger_behavior_name%]"
}
},
"name": "Occupancy cleared"
},
"detected": {
"description": "Triggers after one or more occupancy sensors start detecting occupancy.",
"fields": {
"behavior": {
"description": "[%key:component::occupancy::common::trigger_behavior_description%]",
"name": "[%key:component::occupancy::common::trigger_behavior_name%]"
}
},
"name": "Occupancy detected"
}
}
}

View File

@@ -0,0 +1,57 @@
"""Provides triggers for occupancy."""
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
)
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import (
EntityTargetStateTriggerBase,
EntityTriggerBase,
Trigger,
get_device_class_or_undefined,
)
class _OccupancyBinaryTriggerBase(EntityTriggerBase):
"""Base trigger for occupancy binary sensor state changes."""
_domains = {BINARY_SENSOR_DOMAIN}
def entity_filter(self, entities: set[str]) -> set[str]:
"""Filter entities by occupancy device class."""
entities = super().entity_filter(entities)
return {
entity_id
for entity_id in entities
if get_device_class_or_undefined(self._hass, entity_id)
== BinarySensorDeviceClass.OCCUPANCY
}
class OccupancyDetectedTrigger(
_OccupancyBinaryTriggerBase, EntityTargetStateTriggerBase
):
"""Trigger for occupancy detected (binary sensor ON)."""
_to_states = {STATE_ON}
class OccupancyClearedTrigger(
_OccupancyBinaryTriggerBase, EntityTargetStateTriggerBase
):
"""Trigger for occupancy cleared (binary sensor OFF)."""
_to_states = {STATE_OFF}
TRIGGERS: dict[str, type[Trigger]] = {
"detected": OccupancyDetectedTrigger,
"cleared": OccupancyClearedTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for occupancy."""
return TRIGGERS

View File

@@ -0,0 +1,25 @@
.trigger_common_fields: &trigger_common_fields
behavior:
required: true
default: any
selector:
select:
translation_key: trigger_behavior
options:
- first
- last
- any
detected:
fields: *trigger_common_fields
target:
entity:
- domain: binary_sensor
device_class: occupancy
cleared:
fields: *trigger_common_fields
target:
entity:
- domain: binary_sensor
device_class: occupancy

View File

@@ -10,5 +10,5 @@
"iot_class": "cloud_polling",
"loggers": ["onedrive_personal_sdk"],
"quality_scale": "platinum",
"requirements": ["onedrive-personal-sdk==0.1.6"]
"requirements": ["onedrive-personal-sdk==0.1.7"]
}

View File

@@ -10,5 +10,5 @@
"iot_class": "cloud_polling",
"loggers": ["onedrive_personal_sdk"],
"quality_scale": "platinum",
"requirements": ["onedrive-personal-sdk==0.1.6"]
"requirements": ["onedrive-personal-sdk==0.1.7"]
}

View File

@@ -15,6 +15,9 @@
"period": {
"default": "mdi:sine-wave"
},
"spring_status": {
"default": "mdi:feather"
},
"swing_count": {
"default": "mdi:counter"
},

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_push",
"loggers": ["pysmarlaapi", "pysignalr"],
"quality_scale": "silver",
"requirements": ["pysmarlaapi==1.0.1"]
"requirements": ["pysmarlaapi==1.0.2"]
}

View File

@@ -1,14 +1,18 @@
"""Support for the Swing2Sleep Smarla sensor entities."""
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, Generic, TypeVar
from pysmarlaapi.federwiege.services.classes import Property
from pysmarlaapi.federwiege.services.types import SpringStatus
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
StateType,
)
from homeassistant.const import UnitOfLength, UnitOfTime
from homeassistant.core import HomeAssistant
@@ -19,53 +23,56 @@ from .entity import SmarlaBaseEntity, SmarlaEntityDescription
PARALLEL_UPDATES = 0
_VT = TypeVar("_VT")
@dataclass(frozen=True, kw_only=True)
class SmarlaSensorEntityDescription(SmarlaEntityDescription, SensorEntityDescription):
class SmarlaSensorEntityDescription(
SmarlaEntityDescription, SensorEntityDescription, Generic[_VT]
):
"""Class describing Swing2Sleep Smarla sensor entities."""
multiple: bool = False
value_pos: int = 0
value_fn: Callable[[_VT | None], StateType] = lambda value: (
value if isinstance(value, (str, int, float)) else None
)
SENSORS: list[SmarlaSensorEntityDescription] = [
SmarlaSensorEntityDescription(
SENSORS: list[SmarlaSensorEntityDescription[Any]] = [
SmarlaSensorEntityDescription[list[int]](
key="amplitude",
translation_key="amplitude",
service="analyser",
property="oscillation",
multiple=True,
value_pos=0,
device_class=SensorDeviceClass.DISTANCE,
native_unit_of_measurement=UnitOfLength.MILLIMETERS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda value: value[0] if value else None,
),
SmarlaSensorEntityDescription(
SmarlaSensorEntityDescription[list[int]](
key="period",
translation_key="period",
service="analyser",
property="oscillation",
multiple=True,
value_pos=1,
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.MILLISECONDS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda value: value[1] if value else None,
),
SmarlaSensorEntityDescription(
SmarlaSensorEntityDescription[int](
key="activity",
translation_key="activity",
service="analyser",
property="activity",
state_class=SensorStateClass.MEASUREMENT,
),
SmarlaSensorEntityDescription(
SmarlaSensorEntityDescription[int](
key="swing_count",
translation_key="swing_count",
service="analyser",
property="swing_count",
state_class=SensorStateClass.TOTAL_INCREASING,
),
SmarlaSensorEntityDescription(
SmarlaSensorEntityDescription[int](
key="total_swing_time",
translation_key="total_swing_time",
service="info",
@@ -75,6 +82,21 @@ SENSORS: list[SmarlaSensorEntityDescription] = [
suggested_unit_of_measurement=UnitOfTime.HOURS,
state_class=SensorStateClass.TOTAL_INCREASING,
),
SmarlaSensorEntityDescription[SpringStatus](
key="spring_status",
translation_key="spring_status",
service="analyser",
property="spring_status",
device_class=SensorDeviceClass.ENUM,
options=[
status.name.lower()
for status in SpringStatus
if status != SpringStatus.UNKNOWN
],
value_fn=lambda value: (
value.name.lower() if value and value != SpringStatus.UNKNOWN else None
),
),
]
@@ -85,38 +107,18 @@ async def async_setup_entry(
) -> None:
"""Set up the Smarla sensors from config entry."""
federwiege = config_entry.runtime_data
async_add_entities(
(
SmarlaSensor(federwiege, desc)
if not desc.multiple
else SmarlaSensorMultiple(federwiege, desc)
)
for desc in SENSORS
)
async_add_entities(SmarlaSensor(federwiege, desc) for desc in SENSORS)
class SmarlaSensor(SmarlaBaseEntity, SensorEntity):
class SmarlaSensor(SmarlaBaseEntity, SensorEntity, Generic[_VT]):
"""Representation of Smarla sensor."""
entity_description: SmarlaSensorEntityDescription
entity_description: SmarlaSensorEntityDescription[_VT]
_property: Property[int]
_property: Property[_VT]
@property
def native_value(self) -> int | None:
def native_value(self) -> StateType:
"""Return the entity value to represent the entity state."""
return self._property.get()
class SmarlaSensorMultiple(SmarlaBaseEntity, SensorEntity):
"""Representation of Smarla sensor with multiple values inside property."""
entity_description: SmarlaSensorEntityDescription
_property: Property[list[int]]
@property
def native_value(self) -> int | None:
"""Return the entity value to represent the entity state."""
v = self._property.get()
return v[self.entity_description.value_pos] if v is not None else None
value = self._property.get()
return self.entity_description.value_fn(value)

View File

@@ -50,6 +50,16 @@
"period": {
"name": "Period"
},
"spring_status": {
"name": "Spring status",
"state": {
"constellation_critical_too_high": "Critically too strong",
"constellation_critical_too_low": "Critically too weak",
"constellation_too_high": "Too strong",
"constellation_too_low": "Too weak",
"normal": "Normal"
}
},
"swing_count": {
"name": "Swing count",
"unit_of_measurement": "swings"

View File

@@ -12,6 +12,7 @@ PLATFORMS = [
Platform.CLIMATE,
Platform.FAN,
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.WATER_HEATER,
]

View File

@@ -0,0 +1,117 @@
"""Viessmann ViCare select device."""
from __future__ import annotations
from contextlib import suppress
import logging
from PyViCare.PyViCareDevice import Device as PyViCareDevice
from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig
from PyViCare.PyViCareUtils import (
PyViCareInvalidDataError,
PyViCareNotSupportedFeatureError,
PyViCareRateLimitError,
)
import requests
from homeassistant.components.select import SelectEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import ViCareEntity
from .types import ViCareConfigEntry, ViCareDevice
from .utils import get_device_serial, is_supported
_LOGGER = logging.getLogger(__name__)
# Map API values to snake_case for HA, and back
DHW_MODE_API_TO_HA: dict[str, str] = {
"efficient": "efficient",
"efficientWithMinComfort": "efficient_with_min_comfort",
"off": "off",
}
DHW_MODE_HA_TO_API: dict[str, str] = {v: k for k, v in DHW_MODE_API_TO_HA.items()}
def _build_entities(
device_list: list[ViCareDevice],
) -> list[ViCareDHWOperatingModeSelect]:
"""Create ViCare select entities for a device."""
return [
ViCareDHWOperatingModeSelect(
get_device_serial(device.api),
device.config,
device.api,
)
for device in device_list
if is_supported(
"dhw_operating_mode",
lambda api: api.getDomesticHotWaterActiveOperatingMode(),
device.api,
)
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ViCareConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the ViCare select platform."""
async_add_entities(
await hass.async_add_executor_job(
_build_entities,
config_entry.runtime_data.devices,
)
)
class ViCareDHWOperatingModeSelect(ViCareEntity, SelectEntity):
"""Representation of the ViCare DHW operating mode select entity."""
_attr_entity_category = EntityCategory.CONFIG
_attr_translation_key = "dhw_operating_mode"
def __init__(
self,
device_serial: str | None,
device_config: PyViCareDeviceConfig,
device: PyViCareDevice,
) -> None:
"""Initialize the DHW operating mode select entity."""
super().__init__("dhw_operating_mode", device_serial, device_config, device)
self._attr_options = [
DHW_MODE_API_TO_HA.get(mode, mode)
for mode in device.getDomesticHotWaterOperatingModes()
]
active = device.getDomesticHotWaterActiveOperatingMode()
self._attr_current_option = DHW_MODE_API_TO_HA.get(active, active)
def update(self) -> None:
"""Update state from the ViCare API."""
try:
with suppress(PyViCareNotSupportedFeatureError):
self._attr_options = [
DHW_MODE_API_TO_HA.get(mode, mode)
for mode in self._api.getDomesticHotWaterOperatingModes()
]
with suppress(PyViCareNotSupportedFeatureError):
active = self._api.getDomesticHotWaterActiveOperatingMode()
self._attr_current_option = DHW_MODE_API_TO_HA.get(active, active)
except requests.exceptions.ConnectionError:
_LOGGER.error("Unable to retrieve data from ViCare server")
except PyViCareRateLimitError as limit_exception:
_LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception)
except ValueError:
_LOGGER.error("Unable to decode data from ViCare server")
except PyViCareInvalidDataError as invalid_data_exception:
_LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception)
def select_option(self, option: str) -> None:
"""Set the DHW operating mode."""
api_mode = DHW_MODE_HA_TO_API.get(option, option)
self._api.setDomesticHotWaterOperatingMode(api_mode)
self._attr_current_option = option
self.schedule_update_ha_state()

View File

@@ -160,6 +160,16 @@
"name": "Reduced temperature"
}
},
"select": {
"dhw_operating_mode": {
"name": "DHW operating mode",
"state": {
"efficient": "Efficient",
"efficient_with_min_comfort": "Efficient with minimum comfort",
"off": "[%key:common::state::off%]"
}
}
},
"sensor": {
"boiler_supply_temperature": {
"name": "Boiler supply temperature"

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from pathlib import Path
from typing import cast
from aiohttp import ClientResponseError
from aiohttp import ClientError
from yalexs.const import Brand
from yalexs.exceptions import YaleApiError
from yalexs.manager.const import CONF_BRAND
@@ -15,7 +15,12 @@ from yalexs.manager.gateway import Config as YaleXSConfig
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
OAuth2TokenRequestError,
OAuth2TokenRequestReauthError,
)
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
@@ -42,11 +47,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: YaleConfigEntry) -> bool
yale_gateway = YaleGateway(Path(hass.config.config_dir), session, oauth_session)
try:
await async_setup_yale(hass, entry, yale_gateway)
except OAuth2TokenRequestReauthError as err:
raise ConfigEntryAuthFailed from err
except (RequireValidation, InvalidAuth) as err:
raise ConfigEntryAuthFailed from err
except TimeoutError as err:
raise ConfigEntryNotReady("Timed out connecting to yale api") from err
except (YaleApiError, ClientResponseError, CannotConnect) as err:
except (
YaleApiError,
OAuth2TokenRequestError,
ClientError,
CannotConnect,
) as err:
raise ConfigEntryNotReady from err
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True

View File

@@ -961,7 +961,8 @@ class HomeAssistant:
async def async_block_till_done(self, wait_background_tasks: bool = False) -> None:
"""Block until all pending work is done."""
# To flush out any call_soon_threadsafe
# Sleep twice to flush out any call_soon_threadsafe
await asyncio.sleep(0)
await asyncio.sleep(0)
start_time: float | None = None
current_task = asyncio.current_task()

View File

@@ -584,7 +584,7 @@ _number_or_entity = vol.All(
NUMERICAL_ATTRIBUTE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend(
{
vol.Required(CONF_OPTIONS): vol.All(
vol.Required(CONF_OPTIONS, default={}): vol.All(
{
vol.Optional(CONF_ABOVE): _number_or_entity,
vol.Optional(CONF_BELOW): _number_or_entity,

View File

@@ -40,7 +40,7 @@ habluetooth==5.9.1
hass-nabucasa==2.0.0
hassil==3.5.0
home-assistant-bluetooth==1.13.1
home-assistant-frontend==20260304.0
home-assistant-frontend==20260312.0
home-assistant-intents==2026.3.3
httpx==0.28.1
ifaddr==0.2.0
@@ -48,7 +48,7 @@ Jinja2==3.1.6
lru-dict==1.3.0
mutagen==1.47.0
openai==2.21.0
orjson==3.11.5
orjson==3.11.7
packaging>=23.1
paho-mqtt==2.1.0
Pillow==12.1.1

View File

@@ -65,7 +65,7 @@ dependencies = [
"Pillow==12.1.1",
"propcache==0.4.1",
"pyOpenSSL==25.3.0",
"orjson==3.11.5",
"orjson==3.11.7",
"packaging>=23.1",
"psutil-home-assistant==0.0.1",
"python-slugify==8.0.4",

2
requirements.txt generated
View File

@@ -35,7 +35,7 @@ infrared-protocols==1.0.0
Jinja2==3.1.6
lru-dict==1.3.0
mutagen==1.47.0
orjson==3.11.5
orjson==3.11.7
packaging>=23.1
Pillow==12.1.1
propcache==0.4.1

8
requirements_all.txt generated
View File

@@ -1119,7 +1119,7 @@ gotailwind==0.3.0
govee-ble==1.2.0
# homeassistant.components.govee_light_local
govee-local-api==2.3.0
govee-local-api==2.4.0
# homeassistant.components.remote_rpi_gpio
gpiozero==1.6.2
@@ -1223,7 +1223,7 @@ hole==0.9.0
holidays==0.84
# homeassistant.components.frontend
home-assistant-frontend==20260304.0
home-assistant-frontend==20260312.0
# homeassistant.components.conversation
home-assistant-intents==2026.3.3
@@ -1676,7 +1676,7 @@ ondilo==0.5.0
# homeassistant.components.onedrive
# homeassistant.components.onedrive_for_business
onedrive-personal-sdk==0.1.6
onedrive-personal-sdk==0.1.7
# homeassistant.components.onvif
onvif-zeep-async==4.0.4
@@ -2479,7 +2479,7 @@ pysma==1.1.0
pysmappee==0.2.29
# homeassistant.components.smarla
pysmarlaapi==1.0.1
pysmarlaapi==1.0.2
# homeassistant.components.smartthings
pysmartthings==3.6.0

View File

@@ -995,7 +995,7 @@ gotailwind==0.3.0
govee-ble==1.2.0
# homeassistant.components.govee_light_local
govee-local-api==2.3.0
govee-local-api==2.4.0
# homeassistant.components.gpsd
gps3==0.33.3
@@ -1084,7 +1084,7 @@ hole==0.9.0
holidays==0.84
# homeassistant.components.frontend
home-assistant-frontend==20260304.0
home-assistant-frontend==20260312.0
# homeassistant.components.conversation
home-assistant-intents==2026.3.3
@@ -1462,7 +1462,7 @@ ondilo==0.5.0
# homeassistant.components.onedrive
# homeassistant.components.onedrive_for_business
onedrive-personal-sdk==0.1.6
onedrive-personal-sdk==0.1.7
# homeassistant.components.onvif
onvif-zeep-async==4.0.4
@@ -2111,7 +2111,7 @@ pysma==1.1.0
pysmappee==0.2.29
# homeassistant.components.smarla
pysmarlaapi==1.0.1
pysmarlaapi==1.0.2
# homeassistant.components.smartthings
pysmartthings==3.6.0

View File

@@ -102,7 +102,9 @@ NO_IOT_CLASS = [
"logger",
"lovelace",
"media_source",
"motion",
"my",
"occupancy",
"onboarding",
"panel_custom",
"plant",

View File

@@ -2137,7 +2137,9 @@ NO_QUALITY_SCALE = [
"logger",
"lovelace",
"media_source",
"motion",
"my",
"occupancy",
"onboarding",
"panel_custom",
"proxy",

View File

@@ -20,7 +20,7 @@ MOCK_TURN_ON = {
"service": "switch.turn_on",
"data": {"entity_id": "switch.test"},
}
MOCK_ENTITY_ID = "media_player.arcam_fmj_127_0_0_1_zone_1"
MOCK_ENTITY_ID = "media_player.arcam_fmj_127_0_0_1"
MOCK_UUID = "456789abcdef"
MOCK_UDN = f"uuid:01234567-89ab-cdef-0123-{MOCK_UUID}"
MOCK_NAME = f"{DEFAULT_NAME} ({MOCK_HOST})"
@@ -44,12 +44,14 @@ def state_1_fixture(client: Mock) -> State:
state.zn = 1
state.get_power.return_value = True
state.get_volume.return_value = 0.0
state.get_source.return_value = None
state.get_source_list.return_value = []
state.get_incoming_audio_format.return_value = (None, None)
state.get_incoming_video_parameters.return_value = None
state.get_incoming_audio_sample_rate.return_value = 0
state.get_mute.return_value = None
state.get_decode_modes.return_value = []
state.get_decode_mode.return_value = None
return state
@@ -61,12 +63,14 @@ def state_2_fixture(client: Mock) -> State:
state.zn = 2
state.get_power.return_value = True
state.get_volume.return_value = 0.0
state.get_source.return_value = None
state.get_source_list.return_value = []
state.get_incoming_audio_format.return_value = (None, None)
state.get_incoming_video_parameters.return_value = None
state.get_incoming_audio_sample_rate.return_value = 0
state.get_mute.return_value = None
state.get_decode_modes.return_value = []
state.get_decode_mode.return_value = None
return state
@@ -90,7 +94,7 @@ async def player_setup_fixture(
state_1: State,
state_2: State,
client: Mock,
) -> AsyncGenerator[str]:
) -> AsyncGenerator[None]:
"""Get standard player."""
def state_mock(cli, zone):
@@ -101,7 +105,15 @@ async def player_setup_fixture(
raise ValueError(f"Unknown player zone: {zone}")
async def _mock_run_client(hass: HomeAssistant, runtime_data, interval):
for coordinator in runtime_data.coordinators.values():
coordinators = runtime_data.coordinators
def _notify_data_updated() -> None:
for coordinator in coordinators.values():
coordinator.async_notify_data_updated()
client.notify_data_updated = _notify_data_updated
for coordinator in coordinators.values():
coordinator.async_notify_connected()
await async_setup_component(hass, "homeassistant", {})
@@ -119,4 +131,4 @@ async def player_setup_fixture(
):
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
yield MOCK_ENTITY_ID
yield

View File

@@ -0,0 +1,105 @@
# serializer version: 1
# name: test_setup[media_player.arcam_fmj_127_0_0_1-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'media_player',
'entity_category': None,
'entity_id': 'media_player.arcam_fmj_127_0_0_1',
'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': 'arcam_fmj',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <MediaPlayerEntityFeature: 200588>,
'translation_key': None,
'unique_id': '456789abcdef-1',
'unit_of_measurement': None,
})
# ---
# name: test_setup[media_player.arcam_fmj_127_0_0_1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Arcam FMJ (127.0.0.1)',
'supported_features': <MediaPlayerEntityFeature: 200588>,
'volume_level': 0.0,
}),
'context': <ANY>,
'entity_id': 'media_player.arcam_fmj_127_0_0_1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_setup[media_player.arcam_fmj_127_0_0_1_zone_2-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'media_player',
'entity_category': None,
'entity_id': 'media_player.arcam_fmj_127_0_0_1_zone_2',
'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': 'arcam_fmj',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <MediaPlayerEntityFeature: 135052>,
'translation_key': None,
'unique_id': '456789abcdef-2',
'unit_of_measurement': None,
})
# ---
# name: test_setup[media_player.arcam_fmj_127_0_0_1_zone_2-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Arcam FMJ (127.0.0.1) Zone 2',
'supported_features': <MediaPlayerEntityFeature: 135052>,
'volume_level': 0.0,
}),
'context': <ANY>,
'entity_id': 'media_player.arcam_fmj_127_0_0_1_zone_2',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---

View File

@@ -9,6 +9,8 @@ from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
from .conftest import MOCK_ENTITY_ID
from tests.common import MockConfigEntry, async_get_device_automations
@@ -59,7 +61,7 @@ async def test_if_fires_on_turn_on_request(
state_1: State,
) -> None:
"""Test for turn_on and turn_off triggers firing."""
entry = entity_registry.async_get(player_setup)
entry = entity_registry.async_get(MOCK_ENTITY_ID)
state_1.get_power.return_value = None
@@ -91,13 +93,13 @@ async def test_if_fires_on_turn_on_request(
await hass.services.async_call(
"media_player",
"turn_on",
{"entity_id": player_setup},
{"entity_id": MOCK_ENTITY_ID},
blocking=True,
)
await hass.async_block_till_done()
assert len(service_calls) == 2
assert service_calls[1].data["some"] == player_setup
assert service_calls[1].data["some"] == MOCK_ENTITY_ID
assert service_calls[1].data["id"] == 0
@@ -109,7 +111,7 @@ async def test_if_fires_on_turn_on_request_legacy(
state_1: State,
) -> None:
"""Test for turn_on and turn_off triggers firing."""
entry = entity_registry.async_get(player_setup)
entry = entity_registry.async_get(MOCK_ENTITY_ID)
state_1.get_power.return_value = None
@@ -141,11 +143,11 @@ async def test_if_fires_on_turn_on_request_legacy(
await hass.services.async_call(
"media_player",
"turn_on",
{"entity_id": player_setup},
{"entity_id": MOCK_ENTITY_ID},
blocking=True,
)
await hass.async_block_till_done()
assert len(service_calls) == 2
assert service_calls[1].data["some"] == player_setup
assert service_calls[1].data["some"] == MOCK_ENTITY_ID
assert service_calls[1].data["id"] == 0

View File

@@ -6,6 +6,7 @@ from unittest.mock import Mock, PropertyMock, patch
from arcam.fmj import ConnectionFailed, DecodeMode2CH, DecodeModeMCH, SourceCodes
from arcam.fmj.state import State
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.arcam_fmj.media_player import ArcamFmj
from homeassistant.components.homeassistant import (
@@ -14,145 +15,146 @@ from homeassistant.components.homeassistant import (
)
from homeassistant.components.media_player import (
ATTR_INPUT_SOURCE,
ATTR_MEDIA_ARTIST,
ATTR_MEDIA_CHANNEL,
ATTR_MEDIA_CONTENT_TYPE,
ATTR_MEDIA_VOLUME_LEVEL,
ATTR_MEDIA_VOLUME_MUTED,
ATTR_SOUND_MODE,
ATTR_SOUND_MODE_LIST,
DATA_COMPONENT,
DOMAIN as MEDIA_PLAYER_DOMAIN,
SERVICE_SELECT_SOUND_MODE,
SERVICE_SELECT_SOURCE,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
SERVICE_VOLUME_DOWN,
SERVICE_VOLUME_MUTE,
SERVICE_VOLUME_SET,
SERVICE_VOLUME_UP,
MediaType,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_IDENTIFIERS,
ATTR_MANUFACTURER,
ATTR_MODEL,
ATTR_NAME,
)
from homeassistant.core import HomeAssistant
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant, State as CoreState
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from .conftest import MOCK_ENTITY_ID, MOCK_HOST, MOCK_UUID
from .conftest import MOCK_ENTITY_ID
from tests.common import MockConfigEntry
MOCK_TURN_ON = {
"service": "switch.turn_on",
"data": {"entity_id": "switch.test"},
}
from tests.common import MockConfigEntry, snapshot_platform
@pytest.fixture(name="player")
def player_fixture(
@pytest.fixture(autouse=True)
def platform_fixture():
"""Only test single platform."""
with patch("homeassistant.components.arcam_fmj.PLATFORMS", [Platform.MEDIA_PLAYER]):
yield
@pytest.mark.usefixtures("player_setup")
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_setup(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_config_entry: MockConfigEntry,
client: Mock,
state_1: State,
player_setup: str,
) -> ArcamFmj:
"""Get standard player.
This fixture tests internals and should not be used going forward.
"""
player: ArcamFmj = hass.data[DATA_COMPONENT].get_entity(MOCK_ENTITY_ID)
player.async_write_ha_state = Mock(wraps=player.async_write_ha_state)
return player
async def update(player: ArcamFmj, force_refresh=False):
"""Force a update of player and return current state data."""
await player.async_update_ha_state(force_refresh=force_refresh)
return player.hass.states.get(player.entity_id)
async def test_properties(player: ArcamFmj) -> None:
"""Test standard properties."""
assert player.unique_id == f"{MOCK_UUID}-1"
assert player.device_info == {
ATTR_NAME: f"Arcam FMJ ({MOCK_HOST})",
ATTR_IDENTIFIERS: {
("arcam_fmj", MOCK_UUID),
},
ATTR_MODEL: "Arcam FMJ AVR",
ATTR_MANUFACTURER: "Arcam",
}
assert not player.should_poll
async def test_powered_off(
hass: HomeAssistant, player: ArcamFmj, state_1: State
entity_registry: er.EntityRegistry,
) -> None:
"""Test setup creates expected entities."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def update(hass: HomeAssistant, client: Mock, entity_id: str) -> CoreState:
"""Force a update of player and return current state data."""
client.notify_data_updated()
await hass.async_block_till_done()
data = hass.states.get(entity_id)
assert data
return data
@pytest.mark.usefixtures("player_setup")
async def test_powered_off(hass: HomeAssistant, client: Mock, state_1: State) -> None:
"""Test properties in powered off state."""
state_1.get_source.return_value = None
state_1.get_power.return_value = None
data = await update(player)
data = await update(hass, client, MOCK_ENTITY_ID)
assert "source" not in data.attributes
assert data.state == "off"
async def test_powered_on(player: ArcamFmj, state_1: State) -> None:
@pytest.mark.usefixtures("player_setup")
async def test_powered_on(hass: HomeAssistant, client: Mock, state_1: State) -> None:
"""Test properties in powered on state."""
state_1.get_source.return_value = SourceCodes.PVR
state_1.get_power.return_value = True
data = await update(player)
data = await update(hass, client, MOCK_ENTITY_ID)
assert data.attributes["source"] == "PVR"
assert data.state == "on"
async def test_supported_features(player: ArcamFmj) -> None:
"""Test supported features."""
data = await update(player)
assert data.attributes["supported_features"] == 200588
async def test_turn_on(player: ArcamFmj, state_1: State) -> None:
@pytest.mark.usefixtures("player_setup")
async def test_turn_on(hass: HomeAssistant, state_1: State) -> None:
"""Test turn on service."""
state_1.get_power.return_value = None
await player.async_turn_on()
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_TURN_ON,
service_data={ATTR_ENTITY_ID: MOCK_ENTITY_ID},
blocking=True,
)
state_1.set_power.assert_not_called()
state_1.get_power.return_value = False
await player.async_turn_on()
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_TURN_ON,
service_data={ATTR_ENTITY_ID: MOCK_ENTITY_ID},
blocking=True,
)
state_1.set_power.assert_called_with(True)
async def test_turn_off(player: ArcamFmj, state_1: State) -> None:
@pytest.mark.usefixtures("player_setup")
async def test_turn_off(hass: HomeAssistant, state_1: State) -> None:
"""Test command to turn off."""
await player.async_turn_off()
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_TURN_OFF,
service_data={ATTR_ENTITY_ID: MOCK_ENTITY_ID},
blocking=True,
)
state_1.set_power.assert_called_with(False)
@pytest.mark.parametrize("mute", [True, False])
async def test_mute_volume(player: ArcamFmj, state_1: State, mute: bool) -> None:
@pytest.mark.usefixtures("player_setup")
async def test_mute_volume(hass: HomeAssistant, state_1: State, mute: bool) -> None:
"""Test mute functionality."""
player.async_write_ha_state.reset_mock()
await player.async_mute_volume(mute)
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_VOLUME_MUTE,
service_data={ATTR_ENTITY_ID: MOCK_ENTITY_ID, ATTR_MEDIA_VOLUME_MUTED: mute},
blocking=True,
)
state_1.set_mute.assert_called_with(mute)
player.async_write_ha_state.assert_called_with()
async def test_name(player: ArcamFmj) -> None:
"""Test name."""
data = await update(player)
assert data.attributes["friendly_name"] == "Arcam FMJ (127.0.0.1) Zone 1"
async def test_update(hass: HomeAssistant, player_setup: str, state_1: State) -> None:
@pytest.mark.usefixtures("player_setup")
async def test_update(hass: HomeAssistant, state_1: State) -> None:
"""Test update."""
await hass.services.async_call(
HA_DOMAIN,
SERVICE_UPDATE_ENTITY,
service_data={ATTR_ENTITY_ID: player_setup},
service_data={ATTR_ENTITY_ID: MOCK_ENTITY_ID},
blocking=True,
)
state_1.update.assert_called_with()
@pytest.mark.usefixtures("player_setup")
async def test_update_lost(
hass: HomeAssistant,
player_setup: str,
state_1: State,
caplog: pytest.LogCaptureFixture,
) -> None:
@@ -162,7 +164,7 @@ async def test_update_lost(
await hass.services.async_call(
HA_DOMAIN,
SERVICE_UPDATE_ENTITY,
service_data={ATTR_ENTITY_ID: player_setup},
service_data={ATTR_ENTITY_ID: MOCK_ENTITY_ID},
blocking=True,
)
state_1.update.assert_called_with()
@@ -172,9 +174,9 @@ async def test_update_lost(
("source", "value"),
[("PVR", SourceCodes.PVR), ("BD", SourceCodes.BD), ("INVALID", None)],
)
@pytest.mark.usefixtures("player_setup")
async def test_select_source(
hass: HomeAssistant,
player_setup,
state_1: State,
source: str,
value: SourceCodes | None,
@@ -183,7 +185,7 @@ async def test_select_source(
await hass.services.async_call(
"media_player",
SERVICE_SELECT_SOURCE,
service_data={ATTR_ENTITY_ID: player_setup, ATTR_INPUT_SOURCE: source},
service_data={ATTR_ENTITY_ID: MOCK_ENTITY_ID, ATTR_INPUT_SOURCE: source},
blocking=True,
)
@@ -193,10 +195,11 @@ async def test_select_source(
state_1.set_source.assert_not_called()
async def test_source_list(player: ArcamFmj, state_1: State) -> None:
@pytest.mark.usefixtures("player_setup")
async def test_source_list(hass: HomeAssistant, client: Mock, state_1: State) -> None:
"""Test source list."""
state_1.get_source_list.return_value = [SourceCodes.BD]
data = await update(player)
data = await update(hass, client, MOCK_ENTITY_ID)
assert data.attributes["source_list"] == ["BD"]
@@ -207,26 +210,42 @@ async def test_source_list(player: ArcamFmj, state_1: State) -> None:
"DOLBY_PL",
],
)
async def test_select_sound_mode(player: ArcamFmj, state_1: State, mode: str) -> None:
@pytest.mark.usefixtures("player_setup")
async def test_select_sound_mode(
hass: HomeAssistant, state_1: State, mode: str
) -> None:
"""Test selection sound mode."""
await player.async_select_sound_mode(mode)
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_SELECT_SOUND_MODE,
service_data={ATTR_ENTITY_ID: MOCK_ENTITY_ID, ATTR_SOUND_MODE: mode},
blocking=True,
)
state_1.set_decode_mode.assert_called_with(mode)
async def test_volume_up(player: ArcamFmj, state_1: State) -> None:
@pytest.mark.usefixtures("player_setup")
async def test_volume_up(hass: HomeAssistant, state_1: State) -> None:
"""Test mute functionality."""
player.async_write_ha_state.reset_mock()
await player.async_volume_up()
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_VOLUME_UP,
service_data={ATTR_ENTITY_ID: MOCK_ENTITY_ID},
blocking=True,
)
state_1.inc_volume.assert_called_with()
player.async_write_ha_state.assert_called_with()
async def test_volume_down(player: ArcamFmj, state_1: State) -> None:
@pytest.mark.usefixtures("player_setup")
async def test_volume_down(hass: HomeAssistant, state_1: State) -> None:
"""Test mute functionality."""
player.async_write_ha_state.reset_mock()
await player.async_volume_down()
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_VOLUME_DOWN,
service_data={ATTR_ENTITY_ID: MOCK_ENTITY_ID},
blocking=True,
)
state_1.dec_volume.assert_called_with()
player.async_write_ha_state.assert_called_with()
@pytest.mark.parametrize(
@@ -237,10 +256,13 @@ async def test_volume_down(player: ArcamFmj, state_1: State) -> None:
(None, None),
],
)
async def test_sound_mode(player: ArcamFmj, state_1: State, mode, mode_enum) -> None:
@pytest.mark.usefixtures("player_setup")
async def test_sound_mode(
hass: HomeAssistant, client: Mock, state_1: State, mode, mode_enum
) -> None:
"""Test selection sound mode."""
state_1.get_decode_mode.return_value = mode_enum
data = await update(player)
data = await update(hass, client, MOCK_ENTITY_ID)
assert data.attributes.get(ATTR_SOUND_MODE) == mode
@@ -252,56 +274,73 @@ async def test_sound_mode(player: ArcamFmj, state_1: State, mode, mode_enum) ->
(None, None),
],
)
@pytest.mark.usefixtures("player_setup")
async def test_sound_mode_list(
player: ArcamFmj, state_1: State, modes, modes_enum
hass: HomeAssistant, client: Mock, state_1: State, modes, modes_enum
) -> None:
"""Test sound mode list."""
state_1.get_decode_modes.return_value = modes_enum
data = await update(player)
data = await update(hass, client, MOCK_ENTITY_ID)
assert data.attributes.get(ATTR_SOUND_MODE_LIST) == modes
async def test_is_volume_muted(player: ArcamFmj, state_1: State) -> None:
@pytest.mark.usefixtures("player_setup")
async def test_is_volume_muted(
hass: HomeAssistant, client: Mock, state_1: State
) -> None:
"""Test muted."""
state_1.get_mute.return_value = True
assert player.is_volume_muted is True
data = await update(hass, client, MOCK_ENTITY_ID)
assert data.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is True
state_1.get_mute.return_value = False
assert player.is_volume_muted is False
data = await update(hass, client, MOCK_ENTITY_ID)
assert data.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is False
state_1.get_mute.return_value = None
assert player.is_volume_muted is None
data = await update(hass, client, MOCK_ENTITY_ID)
assert data.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is None
async def test_volume_level(player: ArcamFmj, state_1: State) -> None:
@pytest.mark.usefixtures("player_setup")
async def test_volume_level(hass: HomeAssistant, client: Mock, state_1: State) -> None:
"""Test volume."""
state_1.get_volume.return_value = 0
assert isclose(player.volume_level, 0.0)
data = await update(hass, client, MOCK_ENTITY_ID)
assert isclose(data.attributes[ATTR_MEDIA_VOLUME_LEVEL], 0.0)
state_1.get_volume.return_value = 50
assert isclose(player.volume_level, 50.0 / 99)
data = await update(hass, client, MOCK_ENTITY_ID)
assert isclose(data.attributes[ATTR_MEDIA_VOLUME_LEVEL], 50.0 / 99)
state_1.get_volume.return_value = 99
assert isclose(player.volume_level, 1.0)
data = await update(hass, client, MOCK_ENTITY_ID)
assert isclose(data.attributes[ATTR_MEDIA_VOLUME_LEVEL], 1.0)
state_1.get_volume.return_value = None
assert player.volume_level is None
data = await update(hass, client, MOCK_ENTITY_ID)
assert ATTR_MEDIA_VOLUME_LEVEL not in data.attributes
@pytest.mark.parametrize(("volume", "call"), [(0.0, 0), (0.5, 50), (1.0, 99)])
@pytest.mark.usefixtures("player_setup")
async def test_set_volume_level(
hass: HomeAssistant, player_setup: str, state_1: State, volume, call
hass: HomeAssistant, state_1: State, volume, call
) -> None:
"""Test setting volume."""
await hass.services.async_call(
"media_player",
SERVICE_VOLUME_SET,
service_data={ATTR_ENTITY_ID: player_setup, ATTR_MEDIA_VOLUME_LEVEL: volume},
service_data={ATTR_ENTITY_ID: MOCK_ENTITY_ID, ATTR_MEDIA_VOLUME_LEVEL: volume},
blocking=True,
)
state_1.set_volume.assert_called_with(call)
async def test_set_volume_level_lost(
hass: HomeAssistant, player_setup: str, state_1: State
) -> None:
@pytest.mark.usefixtures("player_setup")
async def test_set_volume_level_lost(hass: HomeAssistant, state_1: State) -> None:
"""Test setting volume, with a lost connection."""
state_1.set_volume.side_effect = ConnectionFailed()
@@ -310,7 +349,7 @@ async def test_set_volume_level_lost(
await hass.services.async_call(
"media_player",
SERVICE_VOLUME_SET,
service_data={ATTR_ENTITY_ID: player_setup, ATTR_MEDIA_VOLUME_LEVEL: 0.0},
service_data={ATTR_ENTITY_ID: MOCK_ENTITY_ID, ATTR_MEDIA_VOLUME_LEVEL: 0.0},
blocking=True,
)
@@ -324,12 +363,14 @@ async def test_set_volume_level_lost(
(None, None),
],
)
@pytest.mark.usefixtures("player_setup")
async def test_media_content_type(
player: ArcamFmj, state_1: State, source, media_content_type
hass: HomeAssistant, client: Mock, state_1: State, source, media_content_type
) -> None:
"""Test content type deduction."""
state_1.get_source.return_value = source
assert player.media_content_type == media_content_type
data = await update(hass, client, MOCK_ENTITY_ID)
assert data.attributes.get(ATTR_MEDIA_CONTENT_TYPE) == media_content_type
@pytest.mark.parametrize(
@@ -342,14 +383,16 @@ async def test_media_content_type(
(SourceCodes.PVR, "dab", "rds", None),
],
)
@pytest.mark.usefixtures("player_setup")
async def test_media_channel(
player: ArcamFmj, state_1: State, source, dab, rds, channel
hass: HomeAssistant, client: Mock, state_1: State, source, dab, rds, channel
) -> None:
"""Test media channel."""
state_1.get_dab_station.return_value = dab
state_1.get_rds_information.return_value = rds
state_1.get_source.return_value = source
assert player.media_channel == channel
data = await update(hass, client, MOCK_ENTITY_ID)
assert data.attributes.get(ATTR_MEDIA_CHANNEL) == channel
@pytest.mark.parametrize(
@@ -360,13 +403,15 @@ async def test_media_channel(
(SourceCodes.DAB, None, None),
],
)
@pytest.mark.usefixtures("player_setup")
async def test_media_artist(
player: ArcamFmj, state_1: State, source, dls, artist
hass: HomeAssistant, client: Mock, state_1: State, source, dls, artist
) -> None:
"""Test media artist."""
state_1.get_dls_pdt.return_value = dls
state_1.get_source.return_value = source
assert player.media_artist == artist
data = await update(hass, client, MOCK_ENTITY_ID)
assert data.attributes.get(ATTR_MEDIA_ARTIST) == artist
@pytest.mark.parametrize(
@@ -377,8 +422,9 @@ async def test_media_artist(
(None, None, None),
],
)
@pytest.mark.usefixtures("player_setup")
async def test_media_title(
player: ArcamFmj, state_1: State, source, channel, title
hass: HomeAssistant, client: Mock, state_1: State, source, channel, title
) -> None:
"""Test media title."""
@@ -387,7 +433,7 @@ async def test_media_title(
ArcamFmj, "media_channel", new_callable=PropertyMock
) as media_channel:
media_channel.return_value = channel
data = await update(player)
data = await update(hass, client, MOCK_ENTITY_ID)
if title is None:
assert "media_title" not in data.attributes
else:

View File

@@ -2,7 +2,7 @@
from unittest.mock import Mock, patch
from aiohttp import ClientResponseError
from aiohttp import ClientError, ClientResponseError
import pytest
from yalexs.const import Brand
from yalexs.exceptions import AugustApiAIOHTTPError, InvalidAuth
@@ -18,7 +18,11 @@ from homeassistant.const import (
STATE_ON,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.exceptions import (
HomeAssistantError,
OAuth2TokenRequestReauthError,
OAuth2TokenRequestTransientError,
)
from homeassistant.helpers import (
device_registry as dr,
entity_registry as er,
@@ -304,3 +308,59 @@ async def test_oauth_implementation_not_available(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.SETUP_RETRY
async def test_oauth_token_request_reauth_error(hass: HomeAssistant) -> None:
"""Test OAuth token request reauth error starts a reauth flow."""
entry = await mock_august_config_entry(hass)
with patch(
"homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid",
side_effect=OAuth2TokenRequestReauthError(
request_info=Mock(real_url="https://auth.august.com/access_token"),
status=401,
domain=DOMAIN,
),
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.SETUP_ERROR
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
assert flows[0]["step_id"] == "pick_implementation"
assert flows[0]["context"]["source"] == "reauth"
async def test_oauth_token_request_transient_error_is_retryable(
hass: HomeAssistant,
) -> None:
"""Test OAuth token transient request error marks entry for setup retry."""
entry = await mock_august_config_entry(hass)
with patch(
"homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid",
side_effect=OAuth2TokenRequestTransientError(
request_info=Mock(real_url="https://auth.august.com/access_token"),
status=500,
domain=DOMAIN,
),
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.SETUP_RETRY
async def test_oauth_client_error_is_retryable(hass: HomeAssistant) -> None:
"""Test OAuth transport client errors mark entry for setup retry."""
entry = await mock_august_config_entry(hass)
with patch(
"homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid",
side_effect=ClientError("connection error"),
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.SETUP_RETRY

View File

@@ -2,13 +2,22 @@
from unittest.mock import call
from aioesphomeapi import APIClient, WaterHeaterInfo, WaterHeaterMode, WaterHeaterState
from aioesphomeapi import (
APIClient,
WaterHeaterFeature,
WaterHeaterInfo,
WaterHeaterMode,
WaterHeaterState,
)
from homeassistant.components.water_heater import (
ATTR_OPERATION_LIST,
DOMAIN as WATER_HEATER_DOMAIN,
SERVICE_SET_OPERATION_MODE,
SERVICE_SET_TEMPERATURE,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
WaterHeaterEntityFeature,
)
from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE
from homeassistant.core import HomeAssistant
@@ -183,3 +192,130 @@ async def test_water_heater_set_operation_mode(
mock_client.water_heater_command.assert_has_calls(
[call(key=1, mode=WaterHeaterMode.GAS, device_id=0)]
)
async def test_water_heater_on_off(
hass: HomeAssistant,
mock_client: APIClient,
mock_generic_device_entry: MockGenericDeviceEntryType,
) -> None:
"""Test turning the water heater on and off."""
entity_info = [
WaterHeaterInfo(
object_id="my_boiler",
key=1,
name="My Boiler",
min_temperature=10.0,
max_temperature=85.0,
supported_features=WaterHeaterFeature.SUPPORTS_ON_OFF,
)
]
states = [
WaterHeaterState(
key=1,
target_temperature=50.0,
)
]
await mock_generic_device_entry(
mock_client=mock_client,
entity_info=entity_info,
states=states,
)
state = hass.states.get("water_heater.test_my_boiler")
assert state is not None
assert state.attributes["supported_features"] & WaterHeaterEntityFeature.ON_OFF
await hass.services.async_call(
WATER_HEATER_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "water_heater.test_my_boiler"},
blocking=True,
)
mock_client.water_heater_command.assert_has_calls(
[call(key=1, on=True, device_id=0)]
)
mock_client.water_heater_command.reset_mock()
await hass.services.async_call(
WATER_HEATER_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: "water_heater.test_my_boiler"},
blocking=True,
)
mock_client.water_heater_command.assert_has_calls(
[call(key=1, on=False, device_id=0)]
)
async def test_water_heater_target_temperature_step(
hass: HomeAssistant,
mock_client: APIClient,
mock_generic_device_entry: MockGenericDeviceEntryType,
) -> None:
"""Test target temperature step is respected."""
entity_info = [
WaterHeaterInfo(
object_id="my_boiler",
key=1,
name="My Boiler",
min_temperature=10.0,
max_temperature=85.0,
target_temperature_step=5.0,
)
]
states = [
WaterHeaterState(
key=1,
target_temperature=50.0,
)
]
await mock_generic_device_entry(
mock_client=mock_client,
entity_info=entity_info,
states=states,
)
state = hass.states.get("water_heater.test_my_boiler")
assert state is not None
assert state.attributes["target_temp_step"] == 5.0
async def test_water_heater_no_on_off_without_feature(
hass: HomeAssistant,
mock_client: APIClient,
mock_generic_device_entry: MockGenericDeviceEntryType,
) -> None:
"""Test ON_OFF feature is not set when not supported."""
entity_info = [
WaterHeaterInfo(
object_id="my_boiler",
key=1,
name="My Boiler",
min_temperature=10.0,
max_temperature=85.0,
)
]
states = [
WaterHeaterState(
key=1,
target_temperature=50.0,
)
]
await mock_generic_device_entry(
mock_client=mock_client,
entity_info=entity_info,
states=states,
)
state = hass.states.get("water_heater.test_my_boiler")
assert state is not None
assert not (
state.attributes["supported_features"] & WaterHeaterEntityFeature.ON_OFF
)

View File

@@ -4,6 +4,7 @@ import datetime
from unittest.mock import patch
from freezegun import freeze_time
from freezegun.api import FrozenDateTimeFactory
import pytest
import voluptuous as vol
@@ -42,7 +43,11 @@ from homeassistant.core import (
callback,
)
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers import (
device_registry as dr,
entity_platform,
entity_registry as er,
)
from homeassistant.helpers.typing import StateType
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
@@ -540,6 +545,40 @@ async def test_temp_change_heater_on_outside_tolerance(hass: HomeAssistant) -> N
assert call.data["entity_id"] == ENT_SWITCH
async def test_external_toggle_resets_min_cycle(
hass: HomeAssistant, freezer: FrozenDateTimeFactory
) -> None:
"""Test that an external toggle cancels the min_cycle scheduled check."""
# Set up thermostat with min cycle duration and cooldown
await _setup_thermostat_with_min_cycle_duration(hass, False, HVACMode.HEAT)
fake_changed = datetime.datetime.now(dt_util.UTC)
# Perform initial actions at the same frozen time so the cycle timer is recent
freezer.move_to(fake_changed)
# Start with switch on and record service call registrations
calls = _setup_switch(hass, True)
# Cause condition to try to turn off (inside min cycle)
await common.async_set_temperature(hass, 25)
_setup_sensor(hass, 30)
await hass.async_block_till_done()
# No service calls should have been made because we're within min_cycle
assert len(calls) == 0
# Simulate an external toggle shortly after (resets internals)
freezer.move_to(fake_changed + datetime.timedelta(minutes=1))
hass.states.async_set(ENT_SWITCH, STATE_OFF)
await hass.async_block_till_done()
# Advance past the original min_cycle; since callbacks were cancelled by
# the external toggle, no automatic turn_off should occur
async_fire_time_changed(hass, fake_changed + datetime.timedelta(minutes=11))
await hass.async_block_till_done()
assert len(calls) == 0
@pytest.mark.usefixtures("setup_comp_2")
async def test_temp_change_heater_off_within_tolerance(hass: HomeAssistant) -> None:
"""Test if temperature change doesn't turn off within tolerance."""
@@ -795,6 +834,9 @@ async def _setup_thermostat_with_min_cycle_duration(
"heater": ENT_SWITCH,
"target_sensor": ENT_SENSOR,
"ac_mode": ac_mode,
# cycle_cooldown ensures switch stays off for n minutes
"cycle_cooldown": datetime.timedelta(minutes=10),
# min_cycle_duration only ensures switch stays on for n minutes
"min_cycle_duration": datetime.timedelta(minutes=10),
"initial_hvac_mode": initial_hvac_mode,
}
@@ -950,6 +992,8 @@ async def setup_comp_7(hass: HomeAssistant) -> None:
"target_sensor": ENT_SENSOR,
"ac_mode": True,
"min_cycle_duration": datetime.timedelta(minutes=15),
# cycle_cooldown ensures switch stays off for n minutes
"cycle_cooldown": datetime.timedelta(minutes=15),
"keep_alive": datetime.timedelta(minutes=10),
"initial_hvac_mode": HVACMode.COOL,
}
@@ -1024,6 +1068,8 @@ async def setup_comp_8(hass: HomeAssistant) -> None:
"heater": ENT_SWITCH,
"target_sensor": ENT_SENSOR,
"min_cycle_duration": datetime.timedelta(minutes=15),
# cycle_cooldown ensures switch stays off for n minutes
"cycle_cooldown": datetime.timedelta(minutes=15),
"keep_alive": datetime.timedelta(minutes=10),
"initial_hvac_mode": HVACMode.HEAT,
}
@@ -1082,6 +1128,195 @@ async def test_temp_change_heater_trigger_off_long_enough_2(
assert call.data["entity_id"] == ENT_SWITCH
async def test_max_cycle_duration_turns_off(hass: HomeAssistant) -> None:
"""Test that max_cycle_duration forces the heater off after the duration."""
hass.config.temperature_unit = UnitOfTemperature.CELSIUS
assert await async_setup_component(
hass,
CLIMATE_DOMAIN,
{
"climate": {
"platform": "generic_thermostat",
"name": "test",
"cold_tolerance": 0.3,
"hot_tolerance": 0.3,
"target_temp": 25,
"heater": ENT_SWITCH,
"target_sensor": ENT_SENSOR,
"min_cycle_duration": datetime.timedelta(minutes=0),
"max_cycle_duration": datetime.timedelta(minutes=10),
"initial_hvac_mode": HVACMode.HEAT,
}
},
)
await hass.async_block_till_done()
calls = _setup_switch(hass, False)
# Ensure sensor indicates below target so heater will turn on
_setup_sensor(hass, 20)
await hass.async_block_till_done()
# Heater should have been turned on
assert len(calls) == 1
call = calls[0]
assert call.service == SERVICE_TURN_ON
# Advance time to trigger max cycle shut-off
test_time = datetime.datetime.now(dt_util.UTC)
async_fire_time_changed(hass, test_time)
await hass.async_block_till_done()
async_fire_time_changed(hass, test_time + datetime.timedelta(minutes=10))
await hass.async_block_till_done()
# One additional turn_off call should have occurred
assert len(calls) == 2
assert calls[1].service == SERVICE_TURN_OFF
async def test_external_toggle_resets_max_cycle(
hass: HomeAssistant, freezer: FrozenDateTimeFactory
) -> None:
"""Test that an external toggle cancels the max_cycle scheduled check."""
hass.config.temperature_unit = UnitOfTemperature.CELSIUS
assert await async_setup_component(
hass,
CLIMATE_DOMAIN,
{
"climate": {
"platform": "generic_thermostat",
"name": "test",
"cold_tolerance": 0.3,
"hot_tolerance": 0.3,
"target_temp": 25,
"heater": ENT_SWITCH,
"target_sensor": ENT_SENSOR,
"min_cycle_duration": datetime.timedelta(minutes=0),
"max_cycle_duration": datetime.timedelta(minutes=10),
"initial_hvac_mode": HVACMode.HEAT,
}
},
)
await hass.async_block_till_done()
calls = _setup_switch(hass, False)
# Trigger heater to turn on
_setup_sensor(hass, 20)
await hass.async_block_till_done()
assert len(calls) == 1
# Simulate an external toggle event shortly after (resets internals)
test_time = datetime.datetime.now(dt_util.UTC)
async_fire_time_changed(hass, test_time)
freezer.move_to(test_time + datetime.timedelta(minutes=1))
hass.states.async_set(ENT_SWITCH, STATE_ON)
await hass.async_block_till_done()
# Advance past the original max duration; since callbacks were cancelled by
# the external toggle, no automatic turn_off should occur
async_fire_time_changed(hass, test_time + datetime.timedelta(minutes=11))
await hass.async_block_till_done()
# Only the original turn_on call should be present
assert len(calls) == 1
async def test_default_cycle_cooldown_allows_immediate_restart(
hass: HomeAssistant,
) -> None:
"""Test default `cycle_cooldown` allows immediate restart when omitted."""
hass.config.temperature_unit = UnitOfTemperature.CELSIUS
# Do not provide `cycle_cooldown` here; default should be zero timedelta
assert await async_setup_component(
hass,
CLIMATE_DOMAIN,
{
"climate": {
"platform": "generic_thermostat",
"name": "test",
"cold_tolerance": 0.3,
"hot_tolerance": 0.3,
"target_temp": 25,
"heater": ENT_SWITCH,
"target_sensor": ENT_SENSOR,
"min_cycle_duration": datetime.timedelta(minutes=0),
"initial_hvac_mode": HVACMode.HEAT,
}
},
)
await hass.async_block_till_done()
# Start with the switch ON so the thermostat can issue a turn_off
calls = _setup_switch(hass, True)
# Trigger off
_setup_sensor(hass, 30)
await hass.async_block_till_done()
assert len(calls) == 1
assert calls[0].service == SERVICE_TURN_OFF
# Reflect the physical device change (services are not changing state in
# this test harness). Update the entity to OFF so the thermostat sees the
# device as inactive and can attempt to turn it on again.
hass.states.async_set(ENT_SWITCH, STATE_OFF)
await hass.async_block_till_done()
# Immediately trigger on again; with default cooldown=0 this should be allowed
_setup_sensor(hass, 20)
await hass.async_block_till_done()
assert len(calls) == 2
assert calls[1].service == SERVICE_TURN_ON
async def test_cycle_cooldown_schedules_restart_after_cooldown(
hass: HomeAssistant, freezer: FrozenDateTimeFactory
) -> None:
"""Test that cooldown blocks restart and schedules a restart check."""
hass.config.temperature_unit = UnitOfTemperature.CELSIUS
now = datetime.datetime.now(dt_util.UTC)
freezer.move_to(now)
assert await async_setup_component(
hass,
CLIMATE_DOMAIN,
{
"climate": {
"platform": "generic_thermostat",
"name": "test",
"cold_tolerance": 0.3,
"hot_tolerance": 0.3,
"target_temp": 25,
"heater": ENT_SWITCH,
"target_sensor": ENT_SENSOR,
"min_cycle_duration": datetime.timedelta(minutes=0),
"cycle_cooldown": datetime.timedelta(minutes=15),
"initial_hvac_mode": HVACMode.HEAT,
}
},
)
await hass.async_block_till_done()
# Force the thermostat into cooldown by faking a recent toggle time.
thermostats = hass.data[entity_platform.DATA_DOMAIN_PLATFORM_ENTITIES][
(CLIMATE_DOMAIN, "generic_thermostat")
]
thermostat = thermostats[ENTITY]
thermostat._last_toggled_time = now
# Ensure turning on is blocked while in cooldown
calls = _setup_switch(hass, False)
_setup_sensor(hass, 20)
await hass.async_block_till_done()
assert len(calls) == 0
# Advance to end of cooldown and trigger the scheduled check
freezer.move_to(now + datetime.timedelta(minutes=15))
async_fire_time_changed(hass, now + datetime.timedelta(minutes=15))
await hass.async_block_till_done()
assert len(calls) == 1
assert calls[0].service == SERVICE_TURN_ON
@pytest.fixture
async def setup_comp_9(hass: HomeAssistant) -> None:
"""Initialize components."""
@@ -1098,6 +1333,8 @@ async def setup_comp_9(hass: HomeAssistant) -> None:
"heater": ENT_SWITCH,
"target_sensor": ENT_SENSOR,
"min_cycle_duration": datetime.timedelta(minutes=15),
# cycle_cooldown ensures switch stays off for n minutes
"cycle_cooldown": datetime.timedelta(minutes=15),
"keep_alive": datetime.timedelta(minutes=10),
"precision": 0.1,
}
@@ -1155,12 +1392,12 @@ async def test_zero_tolerances(hass: HomeAssistant) -> None:
await common.async_set_temperature(hass, 25)
assert len(calls) == 0
# if the switch is on, it should turn off
# if the switch is on, it should remain on
calls = _setup_switch(hass, True)
_setup_sensor(hass, 25)
await hass.async_block_till_done()
await common.async_set_temperature(hass, 25)
assert len(calls) == 1
assert len(calls) == 0
async def test_custom_setup_params(hass: HomeAssistant) -> None:

View File

@@ -2,16 +2,20 @@
from unittest.mock import patch
import pytest
from syrupy.assertion import SnapshotAssertion
from syrupy.filters import props
from homeassistant.components.climate import PRESET_AWAY
from homeassistant.components.generic_thermostat.config_flow import _validate_config
from homeassistant.components.generic_thermostat.const import (
CONF_AC_MODE,
CONF_COLD_TOLERANCE,
CONF_HEATER,
CONF_HOT_TOLERANCE,
CONF_KEEP_ALIVE,
CONF_MAX_DUR,
CONF_MIN_DUR,
CONF_PRESETS,
CONF_SENSOR,
DOMAIN,
@@ -26,6 +30,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.schema_config_entry_flow import SchemaFlowError
from tests.common import MockConfigEntry
@@ -225,3 +230,39 @@ async def test_config_flow_with_keep_alive(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
assert len(mock_setup_entry.mock_calls) == 1
async def test_validate_config_min_max_duration() -> None:
"""Test _validate_config with min and max cycle duration validation."""
# Test valid case: min_dur < max_dur
user_input = {
CONF_MIN_DUR: {"seconds": 30},
CONF_MAX_DUR: {"minutes": 1},
}
result = await _validate_config(None, user_input)
assert result == user_input
# Test invalid case: min_dur >= max_dur
user_input_invalid = {
CONF_MIN_DUR: {"minutes": 2},
CONF_MAX_DUR: {"minutes": 1},
}
with pytest.raises(SchemaFlowError) as exc_info:
await _validate_config(None, user_input_invalid)
assert str(exc_info.value) == "min_max_runtime"
# Test equal durations (should fail)
user_input_equal = {
CONF_MIN_DUR: {"minutes": 1},
CONF_MAX_DUR: {"minutes": 1},
}
with pytest.raises(SchemaFlowError) as exc_info:
await _validate_config(None, user_input_equal)
assert str(exc_info.value) == "min_max_runtime"
# Test without both durations (should pass)
user_input_partial = {
CONF_MIN_DUR: {"seconds": 30},
}
result = await _validate_config(None, user_input_partial)
assert result == user_input_partial

View File

@@ -8,7 +8,11 @@ import pytest
from homeassistant.components import generic_thermostat
from homeassistant.components.generic_thermostat.config_flow import ConfigFlowHandler
from homeassistant.components.generic_thermostat.const import DOMAIN
from homeassistant.components.generic_thermostat.const import (
CONF_DUR_COOLDOWN,
CONF_MIN_DUR,
DOMAIN,
)
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
@@ -595,7 +599,7 @@ async def test_migration_1_1(
assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id
assert generic_thermostat_config_entry.version == 1
assert generic_thermostat_config_entry.minor_version == 2
assert generic_thermostat_config_entry.minor_version == 3
async def test_migration_from_future_version(
@@ -622,3 +626,36 @@ async def test_migration_from_future_version(
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.MIGRATION_ERROR
async def test_migration_1_2(hass: HomeAssistant) -> None:
"""Test migration from 1.2 to 1.3 copies CONF_MIN_DUR to CONF_DUR_COOLDOWN."""
config_entry = MockConfigEntry(
data={},
domain=DOMAIN,
options={
"name": "My generic thermostat",
"heater": "switch.test",
"target_sensor": "sensor.test",
CONF_MIN_DUR: {"hours": 0, "minutes": 5, "seconds": 0},
"ac_mode": False,
"cold_tolerance": 0.3,
"hot_tolerance": 0.3,
},
title="My generic thermostat",
version=1,
minor_version=2,
)
config_entry.add_to_hass(hass)
# Run migration
result = await generic_thermostat.async_migrate_entry(hass, config_entry)
assert result is True
# After migration, cooldown should be set to min_cycle_duration and minor version bumped
assert config_entry.options.get(CONF_DUR_COOLDOWN) == {
"hours": 0,
"minutes": 5,
"seconds": 0,
}
assert config_entry.minor_version == 3

View File

@@ -0,0 +1 @@
"""Tests for the motion integration."""

View File

@@ -0,0 +1,327 @@
"""Test motion trigger."""
from typing import Any
import pytest
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_LABEL_ID,
CONF_ENTITY_ID,
STATE_OFF,
STATE_ON,
)
from homeassistant.core import HomeAssistant, ServiceCall
from tests.components import (
TriggerStateDescription,
arm_trigger,
parametrize_target_entities,
parametrize_trigger_states,
set_or_remove_state,
target_entities,
)
@pytest.fixture
async def target_binary_sensors(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple binary sensor entities associated with different targets."""
return await target_entities(hass, "binary_sensor")
@pytest.mark.parametrize(
"trigger_key",
[
"motion.detected",
"motion.cleared",
],
)
async def test_motion_triggers_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str
) -> None:
"""Test the motion triggers are gated by the labs flag."""
await arm_trigger(hass, trigger_key, None, {ATTR_LABEL_ID: "test_label"})
assert (
"Unnamed automation failed to setup triggers and has been disabled: Trigger "
f"'{trigger_key}' requires the experimental 'New triggers and conditions' "
"feature to be enabled in Home Assistant Labs settings (feature flag: "
"'new_triggers_conditions')"
) in caplog.text
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("binary_sensor"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_trigger_states(
trigger="motion.detected",
target_states=[STATE_ON],
other_states=[STATE_OFF],
additional_attributes={ATTR_DEVICE_CLASS: "motion"},
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger="motion.cleared",
target_states=[STATE_OFF],
other_states=[STATE_ON],
additional_attributes={ATTR_DEVICE_CLASS: "motion"},
trigger_from_none=False,
),
],
)
async def test_motion_trigger_binary_sensor_behavior_any(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_binary_sensors: dict[str, list[str]],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test motion trigger fires for binary_sensor entities with device_class motion."""
other_entity_ids = set(target_binary_sensors["included"]) - {entity_id}
excluded_entity_ids = set(target_binary_sensors["excluded"]) - {entity_id}
for eid in target_binary_sensors["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
for eid in excluded_entity_ids:
set_or_remove_state(hass, eid, states[0]["excluded"])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {}, trigger_target_config)
for state in states[1:]:
excluded_state = state["excluded"]
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
for excluded_entity_id in excluded_entity_ids:
set_or_remove_state(hass, excluded_entity_id, excluded_state)
await hass.async_block_till_done()
assert len(service_calls) == (entities_in_target - 1) * state["count"]
service_calls.clear()
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("binary_sensor"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_trigger_states(
trigger="motion.detected",
target_states=[STATE_ON],
other_states=[STATE_OFF],
additional_attributes={ATTR_DEVICE_CLASS: "motion"},
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger="motion.cleared",
target_states=[STATE_OFF],
other_states=[STATE_ON],
additional_attributes={ATTR_DEVICE_CLASS: "motion"},
trigger_from_none=False,
),
],
)
async def test_motion_trigger_binary_sensor_behavior_first(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_binary_sensors: dict[str, list[str]],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test motion trigger fires on the first binary_sensor state change."""
other_entity_ids = set(target_binary_sensors["included"]) - {entity_id}
excluded_entity_ids = set(target_binary_sensors["excluded"]) - {entity_id}
for eid in target_binary_sensors["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
for eid in excluded_entity_ids:
set_or_remove_state(hass, eid, states[0]["excluded"])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config)
for state in states[1:]:
excluded_state = state["excluded"]
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, excluded_state)
await hass.async_block_till_done()
for excluded_entity_id in excluded_entity_ids:
set_or_remove_state(hass, excluded_entity_id, excluded_state)
await hass.async_block_till_done()
assert len(service_calls) == 0
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("binary_sensor"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_trigger_states(
trigger="motion.detected",
target_states=[STATE_ON],
other_states=[STATE_OFF],
additional_attributes={ATTR_DEVICE_CLASS: "motion"},
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger="motion.cleared",
target_states=[STATE_OFF],
other_states=[STATE_ON],
additional_attributes={ATTR_DEVICE_CLASS: "motion"},
trigger_from_none=False,
),
],
)
async def test_motion_trigger_binary_sensor_behavior_last(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_binary_sensors: dict[str, list[str]],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test motion trigger fires when the last binary_sensor changes state."""
other_entity_ids = set(target_binary_sensors["included"]) - {entity_id}
excluded_entity_ids = set(target_binary_sensors["excluded"]) - {entity_id}
for eid in target_binary_sensors["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
for eid in excluded_entity_ids:
set_or_remove_state(hass, eid, states[0]["excluded"])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config)
for state in states[1:]:
excluded_state = state["excluded"]
included_state = state["included"]
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, excluded_state)
await hass.async_block_till_done()
assert len(service_calls) == 0
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
for excluded_entity_id in excluded_entity_ids:
set_or_remove_state(hass, excluded_entity_id, excluded_state)
await hass.async_block_till_done()
assert len(service_calls) == 0
# --- Device class exclusion tests ---
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
(
"trigger_key",
"trigger_options",
"initial_state",
"target_state",
),
[
(
"motion.detected",
{},
STATE_OFF,
STATE_ON,
),
(
"motion.cleared",
{},
STATE_ON,
STATE_OFF,
),
],
)
async def test_motion_trigger_excludes_non_motion_binary_sensor(
hass: HomeAssistant,
service_calls: list[ServiceCall],
trigger_key: str,
trigger_options: dict[str, Any],
initial_state: str,
target_state: str,
) -> None:
"""Test motion trigger does not fire for entities without device_class motion."""
entity_id_motion = "binary_sensor.test_motion"
entity_id_occupancy = "binary_sensor.test_occupancy"
# Set initial states
hass.states.async_set(
entity_id_motion, initial_state, {ATTR_DEVICE_CLASS: "motion"}
)
hass.states.async_set(
entity_id_occupancy, initial_state, {ATTR_DEVICE_CLASS: "occupancy"}
)
await hass.async_block_till_done()
await arm_trigger(
hass,
trigger_key,
trigger_options,
{
CONF_ENTITY_ID: [
entity_id_motion,
entity_id_occupancy,
]
},
)
# Motion binary_sensor changes - should trigger
hass.states.async_set(entity_id_motion, target_state, {ATTR_DEVICE_CLASS: "motion"})
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id_motion
service_calls.clear()
# Occupancy binary_sensor changes - should NOT trigger (wrong device class)
hass.states.async_set(
entity_id_occupancy, target_state, {ATTR_DEVICE_CLASS: "occupancy"}
)
await hass.async_block_till_done()
assert len(service_calls) == 0

View File

@@ -0,0 +1 @@
"""Tests for the occupancy integration."""

View File

@@ -0,0 +1,327 @@
"""Test occupancy trigger."""
from typing import Any
import pytest
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_LABEL_ID,
CONF_ENTITY_ID,
STATE_OFF,
STATE_ON,
)
from homeassistant.core import HomeAssistant, ServiceCall
from tests.components import (
TriggerStateDescription,
arm_trigger,
parametrize_target_entities,
parametrize_trigger_states,
set_or_remove_state,
target_entities,
)
@pytest.fixture
async def target_binary_sensors(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple binary sensor entities associated with different targets."""
return await target_entities(hass, "binary_sensor")
@pytest.mark.parametrize(
"trigger_key",
[
"occupancy.detected",
"occupancy.cleared",
],
)
async def test_occupancy_triggers_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str
) -> None:
"""Test the occupancy triggers are gated by the labs flag."""
await arm_trigger(hass, trigger_key, None, {ATTR_LABEL_ID: "test_label"})
assert (
"Unnamed automation failed to setup triggers and has been disabled: Trigger "
f"'{trigger_key}' requires the experimental 'New triggers and conditions' "
"feature to be enabled in Home Assistant Labs settings (feature flag: "
"'new_triggers_conditions')"
) in caplog.text
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("binary_sensor"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_trigger_states(
trigger="occupancy.detected",
target_states=[STATE_ON],
other_states=[STATE_OFF],
additional_attributes={ATTR_DEVICE_CLASS: "occupancy"},
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger="occupancy.cleared",
target_states=[STATE_OFF],
other_states=[STATE_ON],
additional_attributes={ATTR_DEVICE_CLASS: "occupancy"},
trigger_from_none=False,
),
],
)
async def test_occupancy_trigger_binary_sensor_behavior_any(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_binary_sensors: dict[str, list[str]],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test occupancy trigger fires for binary_sensor entities with device_class occupancy."""
other_entity_ids = set(target_binary_sensors["included"]) - {entity_id}
excluded_entity_ids = set(target_binary_sensors["excluded"]) - {entity_id}
for eid in target_binary_sensors["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
for eid in excluded_entity_ids:
set_or_remove_state(hass, eid, states[0]["excluded"])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {}, trigger_target_config)
for state in states[1:]:
excluded_state = state["excluded"]
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
for excluded_entity_id in excluded_entity_ids:
set_or_remove_state(hass, excluded_entity_id, excluded_state)
await hass.async_block_till_done()
assert len(service_calls) == (entities_in_target - 1) * state["count"]
service_calls.clear()
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("binary_sensor"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_trigger_states(
trigger="occupancy.detected",
target_states=[STATE_ON],
other_states=[STATE_OFF],
additional_attributes={ATTR_DEVICE_CLASS: "occupancy"},
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger="occupancy.cleared",
target_states=[STATE_OFF],
other_states=[STATE_ON],
additional_attributes={ATTR_DEVICE_CLASS: "occupancy"},
trigger_from_none=False,
),
],
)
async def test_occupancy_trigger_binary_sensor_behavior_first(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_binary_sensors: dict[str, list[str]],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test occupancy trigger fires on the first binary_sensor state change."""
other_entity_ids = set(target_binary_sensors["included"]) - {entity_id}
excluded_entity_ids = set(target_binary_sensors["excluded"]) - {entity_id}
for eid in target_binary_sensors["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
for eid in excluded_entity_ids:
set_or_remove_state(hass, eid, states[0]["excluded"])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config)
for state in states[1:]:
excluded_state = state["excluded"]
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, excluded_state)
await hass.async_block_till_done()
for excluded_entity_id in excluded_entity_ids:
set_or_remove_state(hass, excluded_entity_id, excluded_state)
await hass.async_block_till_done()
assert len(service_calls) == 0
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("binary_sensor"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_trigger_states(
trigger="occupancy.detected",
target_states=[STATE_ON],
other_states=[STATE_OFF],
additional_attributes={ATTR_DEVICE_CLASS: "occupancy"},
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger="occupancy.cleared",
target_states=[STATE_OFF],
other_states=[STATE_ON],
additional_attributes={ATTR_DEVICE_CLASS: "occupancy"},
trigger_from_none=False,
),
],
)
async def test_occupancy_trigger_binary_sensor_behavior_last(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_binary_sensors: dict[str, list[str]],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test occupancy trigger fires when the last binary_sensor changes state."""
other_entity_ids = set(target_binary_sensors["included"]) - {entity_id}
excluded_entity_ids = set(target_binary_sensors["excluded"]) - {entity_id}
for eid in target_binary_sensors["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
for eid in excluded_entity_ids:
set_or_remove_state(hass, eid, states[0]["excluded"])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config)
for state in states[1:]:
excluded_state = state["excluded"]
included_state = state["included"]
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, excluded_state)
await hass.async_block_till_done()
assert len(service_calls) == 0
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
for excluded_entity_id in excluded_entity_ids:
set_or_remove_state(hass, excluded_entity_id, excluded_state)
await hass.async_block_till_done()
assert len(service_calls) == 0
# --- Device class exclusion tests ---
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
(
"trigger_key",
"trigger_options",
"initial_state",
"target_state",
),
[
(
"occupancy.detected",
{},
STATE_OFF,
STATE_ON,
),
(
"occupancy.cleared",
{},
STATE_ON,
STATE_OFF,
),
],
)
async def test_occupancy_trigger_excludes_non_occupancy_binary_sensor(
hass: HomeAssistant,
service_calls: list[ServiceCall],
trigger_key: str,
trigger_options: dict[str, Any],
initial_state: str,
target_state: str,
) -> None:
"""Test occupancy trigger does not fire for entities without device_class occupancy."""
entity_id_occupancy = "binary_sensor.test_occupancy"
entity_id_motion = "binary_sensor.test_motion"
# Set initial states
hass.states.async_set(
entity_id_occupancy, initial_state, {ATTR_DEVICE_CLASS: "occupancy"}
)
hass.states.async_set(
entity_id_motion, initial_state, {ATTR_DEVICE_CLASS: "motion"}
)
await hass.async_block_till_done()
await arm_trigger(
hass,
trigger_key,
trigger_options,
{
CONF_ENTITY_ID: [
entity_id_occupancy,
entity_id_motion,
]
},
)
# Occupancy binary_sensor changes - should trigger
hass.states.async_set(
entity_id_occupancy, target_state, {ATTR_DEVICE_CLASS: "occupancy"}
)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id_occupancy
service_calls.clear()
# Motion binary_sensor changes - should NOT trigger (wrong device class)
hass.states.async_set(entity_id_motion, target_state, {ATTR_DEVICE_CLASS: "motion"})
await hass.async_block_till_done()
assert len(service_calls) == 0

View File

@@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
from pysmarlaapi import AuthToken
from pysmarlaapi.federwiege.services.classes import Property, Service
from pysmarlaapi.federwiege.services.types import UpdateStatus
from pysmarlaapi.federwiege.services.types import SpringStatus, UpdateStatus
import pytest
from homeassistant.components.smarla.const import DOMAIN
@@ -80,11 +80,13 @@ def _mock_analyser_service() -> MagicMock:
"oscillation": MagicMock(spec=Property),
"activity": MagicMock(spec=Property),
"swing_count": MagicMock(spec=Property),
"spring_status": MagicMock(spec=Property),
}
mock_analyser_service.props["oscillation"].get.return_value = [0, 0]
mock_analyser_service.props["activity"].get.return_value = 0
mock_analyser_service.props["swing_count"].get.return_value = 0
mock_analyser_service.props["spring_status"].get.return_value = SpringStatus.UNKNOWN
return mock_analyser_service

View File

@@ -165,6 +165,71 @@
'state': '0',
})
# ---
# name: test_entities[sensor.smarla_spring_status-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'normal',
'constellation_too_high',
'constellation_too_low',
'constellation_critical_too_high',
'constellation_critical_too_low',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.smarla_spring_status',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Spring status',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': None,
'original_name': 'Spring status',
'platform': 'smarla',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'spring_status',
'unique_id': 'ABCD-spring_status',
'unit_of_measurement': None,
})
# ---
# name: test_entities[sensor.smarla_spring_status-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
'friendly_name': 'Smarla Spring status',
'options': list([
'normal',
'constellation_too_high',
'constellation_too_low',
'constellation_critical_too_high',
'constellation_critical_too_low',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.smarla_spring_status',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_entities[sensor.smarla_swing_count-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -67,7 +67,6 @@ async def test_number_action(
entity_id = entity_info["entity_id"]
# Turn on
await hass.services.async_call(
NUMBER_DOMAIN,
service,

View File

@@ -3,10 +3,11 @@
from typing import Any
from unittest.mock import MagicMock, patch
from pysmarlaapi.federwiege.services.types import SpringStatus
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.const import STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@@ -50,6 +51,13 @@ SENSOR_ENTITIES = [
"initial_state": "0.0",
"test": (3600, "1.0"),
},
{
"entity_id": "sensor.smarla_spring_status",
"service": "analyser",
"property": "spring_status",
"initial_state": STATE_UNKNOWN,
"test": (SpringStatus.NORMAL, "normal"),
},
]
@@ -87,17 +95,19 @@ async def test_sensor_state_update(
entity_id = entity_info["entity_id"]
# Verify initial state
state = hass.states.get(entity_id)
assert state is not None
assert state.state == entity_info["initial_state"]
test_value, expected_state = entity_info["test"]
# Set new value and trigger update
mock_sensor_property.get.return_value = test_value
await update_property_listeners(mock_sensor_property)
await hass.async_block_till_done()
# Verify updated state
state = hass.states.get(entity_id)
assert state is not None
assert state.state == expected_state

View File

@@ -78,7 +78,6 @@ async def test_switch_action(
entity_id = entity_info["entity_id"]
# Turn on
await hass.services.async_call(
SWITCH_DOMAIN,
service,

View File

@@ -1,7 +1,9 @@
"""Vera tests."""
from unittest.mock import MagicMock, patch
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from requests.exceptions import RequestException
from homeassistant import config_entries
@@ -18,6 +20,15 @@ from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry
@pytest.fixture(autouse=True)
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.vera.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
async def test_async_step_user_success(hass: HomeAssistant) -> None:
"""Test user step success."""
with patch("pyvera.VeraController") as vera_controller_class_mock:

View File

@@ -0,0 +1,61 @@
# serializer version: 1
# name: test_all_entities[select.model0_dhw_operating_mode-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'efficient_with_min_comfort',
'efficient',
'off',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'select.model0_dhw_operating_mode',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'DHW operating mode',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'DHW operating mode',
'platform': 'vicare',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'dhw_operating_mode',
'unique_id': 'gateway0_deviceSerialVitocal250A-dhw_operating_mode',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[select.model0_dhw_operating_mode-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'model0 DHW operating mode',
'options': list([
'efficient_with_min_comfort',
'efficient',
'off',
]),
}),
'context': <ANY>,
'entity_id': 'select.model0_dhw_operating_mode',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'efficient',
})
# ---

View File

@@ -0,0 +1,35 @@
"""Test ViCare select entity."""
from unittest.mock import patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import MODULE, setup_integration
from .conftest import Fixture, MockPyViCare
from tests.common import MockConfigEntry, snapshot_platform
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_all_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test all entities."""
fixtures: list[Fixture] = [
Fixture({"type:heatpump"}, "vicare/Vitocal250A.json"),
]
with (
patch(f"{MODULE}.login", return_value=MockPyViCare(fixtures)),
patch(f"{MODULE}.PLATFORMS", [Platform.SELECT]),
):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)

View File

@@ -2,7 +2,7 @@
from unittest.mock import Mock, patch
from aiohttp import ClientResponseError
from aiohttp import ClientError, ClientResponseError
import pytest
from yalexs.exceptions import InvalidAuth, YaleApiError
@@ -17,7 +17,11 @@ from homeassistant.const import (
STATE_ON,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.exceptions import (
HomeAssistantError,
OAuth2TokenRequestReauthError,
OAuth2TokenRequestTransientError,
)
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
@@ -254,3 +258,58 @@ async def test_oauth_implementation_not_available(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.SETUP_RETRY
async def test_oauth_token_request_reauth_error(hass: HomeAssistant) -> None:
"""Test OAuth token request reauth error starts a reauth flow."""
entry = await mock_yale_config_entry(hass)
with patch(
"homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid",
side_effect=OAuth2TokenRequestReauthError(
request_info=Mock(real_url="https://auth.yale.com/access_token"),
status=401,
domain=DOMAIN,
),
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.SETUP_ERROR
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
assert flows[0]["context"]["source"] == "reauth"
async def test_oauth_token_request_transient_error_is_retryable(
hass: HomeAssistant,
) -> None:
"""Test OAuth token transient request error marks entry for setup retry."""
entry = await mock_yale_config_entry(hass)
with patch(
"homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid",
side_effect=OAuth2TokenRequestTransientError(
request_info=Mock(real_url="https://auth.yale.com/access_token"),
status=500,
domain=DOMAIN,
),
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.SETUP_RETRY
async def test_oauth_client_error_is_retryable(hass: HomeAssistant) -> None:
"""Test OAuth transport client errors mark entry for setup retry."""
entry = await mock_yale_config_entry(hass)
with patch(
"homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid",
side_effect=ClientError("connection error"),
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.SETUP_RETRY

View File

@@ -65,9 +65,11 @@
'lovelace',
'media_player',
'media_source',
'motion',
'network',
'notify',
'number',
'occupancy',
'onboarding',
'person',
'remote',
@@ -167,9 +169,11 @@
'lovelace',
'media_player',
'media_source',
'motion',
'network',
'notify',
'number',
'occupancy',
'onboarding',
'person',
'remote',