mirror of
https://github.com/home-assistant/core.git
synced 2026-04-16 14:46:15 +02:00
Compare commits
16 Commits
ariel-pyth
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f6e078d1d | ||
|
|
71d857b5e1 | ||
|
|
0de75a013b | ||
|
|
f87ec0a7b8 | ||
|
|
6d1bd15256 | ||
|
|
9fe9064884 | ||
|
|
f9f57b00bb | ||
|
|
2b65b06003 | ||
|
|
206c498027 | ||
|
|
0ac62b241e | ||
|
|
4ba123a1a8 | ||
|
|
8b8b39c1b7 | ||
|
|
5b70e5f829 | ||
|
|
4f8e7125d4 | ||
|
|
baf5e32c59 | ||
|
|
0f0ceaace2 |
5
Dockerfile
generated
5
Dockerfile
generated
@@ -28,8 +28,7 @@ COPY rootfs /
|
||||
COPY --from=ghcr.io/alexxit/go2rtc@sha256:675c318b23c06fd862a61d262240c9a63436b4050d177ffc68a32710d9e05bae /usr/local/bin/go2rtc /bin/go2rtc
|
||||
|
||||
## Setup Home Assistant Core dependencies
|
||||
COPY requirements.txt homeassistant/
|
||||
COPY homeassistant/package_constraints.txt homeassistant/homeassistant/
|
||||
COPY --parents requirements.txt homeassistant/package_constraints.txt homeassistant/
|
||||
RUN \
|
||||
# Verify go2rtc can be executed
|
||||
go2rtc --version \
|
||||
@@ -49,7 +48,7 @@ RUN \
|
||||
-r homeassistant/requirements_all.txt
|
||||
|
||||
## Setup Home Assistant Core
|
||||
COPY . homeassistant/
|
||||
COPY --parents LICENSE* README* homeassistant/ pyproject.toml homeassistant/
|
||||
RUN \
|
||||
uv pip install \
|
||||
-e ./homeassistant \
|
||||
|
||||
@@ -7,23 +7,31 @@ to speed up the process.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Container, Iterable, Sequence
|
||||
from datetime import timedelta
|
||||
from functools import lru_cache, partial
|
||||
from typing import Any
|
||||
from functools import lru_cache
|
||||
from typing import Any, override
|
||||
|
||||
from jwt import DecodeError, PyJWS, PyJWT
|
||||
from jwt import DecodeError, PyJWK, PyJWS, PyJWT
|
||||
from jwt.algorithms import AllowedPublicKeys
|
||||
from jwt.types import Options
|
||||
|
||||
from homeassistant.util.json import json_loads
|
||||
|
||||
JWT_TOKEN_CACHE_SIZE = 16
|
||||
MAX_TOKEN_SIZE = 8192
|
||||
|
||||
_VERIFY_KEYS = ("signature", "exp", "nbf", "iat", "aud", "iss", "sub", "jti")
|
||||
|
||||
_VERIFY_OPTIONS: dict[str, Any] = {f"verify_{key}": True for key in _VERIFY_KEYS} | {
|
||||
"require": []
|
||||
}
|
||||
_NO_VERIFY_OPTIONS = {f"verify_{key}": False for key in _VERIFY_KEYS}
|
||||
_NO_VERIFY_OPTIONS = Options(
|
||||
verify_signature=False,
|
||||
verify_exp=False,
|
||||
verify_nbf=False,
|
||||
verify_iat=False,
|
||||
verify_aud=False,
|
||||
verify_iss=False,
|
||||
verify_sub=False,
|
||||
verify_jti=False,
|
||||
require=[],
|
||||
)
|
||||
|
||||
|
||||
class _PyJWSWithLoadCache(PyJWS):
|
||||
@@ -38,9 +46,6 @@ class _PyJWSWithLoadCache(PyJWS):
|
||||
return super()._load(jwt)
|
||||
|
||||
|
||||
_jws = _PyJWSWithLoadCache()
|
||||
|
||||
|
||||
@lru_cache(maxsize=JWT_TOKEN_CACHE_SIZE)
|
||||
def _decode_payload(json_payload: str) -> dict[str, Any]:
|
||||
"""Decode the payload from a JWS dictionary."""
|
||||
@@ -56,21 +61,12 @@ def _decode_payload(json_payload: str) -> dict[str, Any]:
|
||||
class _PyJWTWithVerify(PyJWT):
|
||||
"""PyJWT with a fast decode implementation."""
|
||||
|
||||
def decode_payload(
|
||||
self, jwt: str, key: str, options: dict[str, Any], algorithms: list[str]
|
||||
) -> dict[str, Any]:
|
||||
"""Decode a JWT's payload."""
|
||||
if len(jwt) > MAX_TOKEN_SIZE:
|
||||
# Avoid caching impossible tokens
|
||||
raise DecodeError("Token too large")
|
||||
return _decode_payload(
|
||||
_jws.decode_complete(
|
||||
jwt=jwt,
|
||||
key=key,
|
||||
algorithms=algorithms,
|
||||
options=options,
|
||||
)["payload"]
|
||||
)
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the PyJWT instance."""
|
||||
# We require exp and iat claims to be present
|
||||
super().__init__(Options(require=["exp", "iat"]))
|
||||
# Override the _jws instance with our cached version
|
||||
self._jws = _PyJWSWithLoadCache()
|
||||
|
||||
def verify_and_decode(
|
||||
self,
|
||||
@@ -79,37 +75,70 @@ class _PyJWTWithVerify(PyJWT):
|
||||
algorithms: list[str],
|
||||
issuer: str | None = None,
|
||||
leeway: float | timedelta = 0,
|
||||
options: dict[str, Any] | None = None,
|
||||
options: Options | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Verify a JWT's signature and claims."""
|
||||
merged_options = {**_VERIFY_OPTIONS, **(options or {})}
|
||||
payload = self.decode_payload(
|
||||
return self.decode(
|
||||
jwt=jwt,
|
||||
key=key,
|
||||
options=merged_options,
|
||||
algorithms=algorithms,
|
||||
)
|
||||
# These should never be missing since we verify them
|
||||
# but this is an additional safeguard to make sure
|
||||
# nothing slips through.
|
||||
assert "exp" in payload, "exp claim is required"
|
||||
assert "iat" in payload, "iat claim is required"
|
||||
self._validate_claims(
|
||||
payload=payload,
|
||||
options=merged_options,
|
||||
issuer=issuer,
|
||||
leeway=leeway,
|
||||
options=options,
|
||||
)
|
||||
return payload
|
||||
|
||||
@override
|
||||
def decode(
|
||||
self,
|
||||
jwt: str | bytes,
|
||||
key: AllowedPublicKeys | PyJWK | str | bytes = "",
|
||||
algorithms: Sequence[str] | None = None,
|
||||
options: Options | None = None,
|
||||
verify: bool | None = None,
|
||||
detached_payload: bytes | None = None,
|
||||
audience: str | Iterable[str] | None = None,
|
||||
subject: str | None = None,
|
||||
issuer: str | Container[str] | None = None,
|
||||
leeway: float | timedelta = 0,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Decode a JWT, verifying the signature and claims."""
|
||||
if len(jwt) > MAX_TOKEN_SIZE:
|
||||
# Avoid caching impossible tokens
|
||||
raise DecodeError("Token too large")
|
||||
return super().decode(
|
||||
jwt=jwt,
|
||||
key=key,
|
||||
algorithms=algorithms,
|
||||
options=options,
|
||||
verify=verify,
|
||||
detached_payload=detached_payload,
|
||||
audience=audience,
|
||||
subject=subject,
|
||||
issuer=issuer,
|
||||
leeway=leeway,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@override
|
||||
def _decode_payload(self, decoded: dict[str, Any]) -> dict[str, Any]:
|
||||
return _decode_payload(decoded["payload"])
|
||||
|
||||
|
||||
_jwt = _PyJWTWithVerify()
|
||||
verify_and_decode = _jwt.verify_and_decode
|
||||
unverified_hs256_token_decode = lru_cache(maxsize=JWT_TOKEN_CACHE_SIZE)(
|
||||
partial(
|
||||
_jwt.decode_payload, key="", algorithms=["HS256"], options=_NO_VERIFY_OPTIONS
|
||||
|
||||
|
||||
@lru_cache(maxsize=JWT_TOKEN_CACHE_SIZE)
|
||||
def unverified_hs256_token_decode(jwt: str) -> dict[str, Any]:
|
||||
"""Decode a JWT without verifying the signature."""
|
||||
return _jwt.decode(
|
||||
jwt=jwt,
|
||||
key="",
|
||||
algorithms=["HS256"],
|
||||
options=_NO_VERIFY_OPTIONS,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"unverified_hs256_token_decode",
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/camera",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["PyTurboJPEG==1.8.3"]
|
||||
"requirements": ["PyTurboJPEG==2.2.0"]
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["fritzconnection"],
|
||||
"quality_scale": "gold",
|
||||
"requirements": ["fritzconnection[qr]==1.15.1", "xmltodict==1.0.2"],
|
||||
"requirements": ["fritzconnection[qr]==1.15.1", "xmltodict==1.0.4"],
|
||||
"ssdp": [
|
||||
{
|
||||
"st": "urn:schemas-upnp-org:device:fritzbox:1"
|
||||
|
||||
@@ -72,7 +72,7 @@ SPH_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
key="mix_export_to_grid",
|
||||
translation_key="mix_export_to_grid",
|
||||
api_key="pacToGridTotal",
|
||||
native_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
@@ -80,7 +80,7 @@ SPH_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
key="mix_import_from_grid",
|
||||
translation_key="mix_import_from_grid",
|
||||
api_key="pacToUserR",
|
||||
native_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["imgw_pib==2.0.2"]
|
||||
"requirements": ["imgw_pib==2.1.0"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyaxencoapi==1.0.6"]
|
||||
"requirements": ["pyaxencoapi==1.0.7"]
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/rest",
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["jsonpath==0.82.2", "xmltodict==1.0.2"]
|
||||
"requirements": ["jsonpath==0.82.2", "xmltodict==1.0.4"]
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ from collections.abc import Iterator
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from soco import SoCo
|
||||
from soco import SoCo, SoCoException
|
||||
from soco.alarms import Alarm, Alarms
|
||||
from soco.events_base import Event as SonosEvent
|
||||
|
||||
@@ -30,6 +30,7 @@ class SonosAlarms(SonosHouseholdCoordinator):
|
||||
super().__init__(*args)
|
||||
self.alarms: Alarms = Alarms()
|
||||
self.created_alarm_ids: set[str] = set()
|
||||
self._household_mismatch_logged = False
|
||||
|
||||
def __iter__(self) -> Iterator:
|
||||
"""Return an iterator for the known alarms."""
|
||||
@@ -76,21 +77,40 @@ class SonosAlarms(SonosHouseholdCoordinator):
|
||||
await self.async_update_entities(speaker.soco, event_id)
|
||||
|
||||
@soco_error()
|
||||
def update_cache(self, soco: SoCo, update_id: int | None = None) -> bool:
|
||||
"""Update cache of known alarms and return if cache has changed."""
|
||||
self.alarms.update(soco)
|
||||
def update_cache(
|
||||
self,
|
||||
soco: SoCo,
|
||||
update_id: int | None = None,
|
||||
) -> bool:
|
||||
"""Update cache of known alarms and return whether any were seen."""
|
||||
try:
|
||||
self.alarms.update(soco)
|
||||
except SoCoException as err:
|
||||
err_msg = str(err)
|
||||
# Only catch the specific household mismatch error
|
||||
if "Alarm list UID" in err_msg and "does not match" in err_msg:
|
||||
if not self._household_mismatch_logged:
|
||||
_LOGGER.warning(
|
||||
"Sonos alarms for %s cannot be updated due to a household mismatch. "
|
||||
"This is a known limitation in setups with multiple households. "
|
||||
"You can safely ignore this warning, or to silence it, remove the "
|
||||
"affected household from your Sonos system. Error: %s",
|
||||
soco.player_name,
|
||||
err_msg,
|
||||
)
|
||||
self._household_mismatch_logged = True
|
||||
return False
|
||||
# Let all other exceptions bubble up to be handled by @soco_error()
|
||||
raise
|
||||
|
||||
if update_id and self.alarms.last_id < update_id:
|
||||
# Skip updates if latest query result is outdated or lagging
|
||||
return False
|
||||
|
||||
if (
|
||||
self.last_processed_event_id
|
||||
and self.alarms.last_id <= self.last_processed_event_id
|
||||
):
|
||||
# Skip updates already processed
|
||||
return False
|
||||
|
||||
_LOGGER.debug(
|
||||
"Updating processed event %s from %s (was %s)",
|
||||
self.alarms.last_id,
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/startca",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["xmltodict==1.0.2"]
|
||||
"requirements": ["xmltodict==1.0.4"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["PyTurboJPEG==1.8.3", "av==16.0.1", "numpy==2.3.2"]
|
||||
"requirements": ["PyTurboJPEG==2.2.0", "av==16.0.1", "numpy==2.3.2"]
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ted5000",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["xmltodict==1.0.2"]
|
||||
"requirements": ["xmltodict==1.0.4"]
|
||||
}
|
||||
|
||||
@@ -150,11 +150,6 @@ class PreRecordMessageProtocol(RtpDatagramProtocol):
|
||||
if self.transport is None:
|
||||
return
|
||||
|
||||
if self._audio_bytes is None:
|
||||
# 16Khz, 16-bit mono audio message
|
||||
file_path = Path(__file__).parent / self.file_name
|
||||
self._audio_bytes = file_path.read_bytes()
|
||||
|
||||
if self._audio_task is None:
|
||||
self._audio_task = self.hass.async_create_background_task(
|
||||
self._play_message(),
|
||||
@@ -162,6 +157,11 @@ class PreRecordMessageProtocol(RtpDatagramProtocol):
|
||||
)
|
||||
|
||||
async def _play_message(self) -> None:
|
||||
if self._audio_bytes is None:
|
||||
_LOGGER.debug("Loading audio from file %s", self.file_name)
|
||||
self._audio_bytes = await self._load_audio()
|
||||
_LOGGER.debug("Read %s bytes", len(self._audio_bytes))
|
||||
|
||||
await self.hass.async_add_executor_job(
|
||||
partial(
|
||||
self.send_audio,
|
||||
@@ -175,3 +175,8 @@ class PreRecordMessageProtocol(RtpDatagramProtocol):
|
||||
|
||||
# Allow message to play again
|
||||
self._audio_task = None
|
||||
|
||||
async def _load_audio(self) -> bytes:
|
||||
# 16Khz, 16-bit mono audio message
|
||||
file_path = Path(__file__).parent / self.file_name
|
||||
return await self.hass.async_add_executor_job(file_path.read_bytes)
|
||||
|
||||
@@ -245,6 +245,14 @@ ENERGY_SENSORS = [
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
value_fn=lambda status: status.energy_in_defrost,
|
||||
),
|
||||
WeHeatSensorEntityDescription(
|
||||
translation_key="electricity_used_standby",
|
||||
key="electricity_used_standby",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
value_fn=lambda status: status.energy_in_standby,
|
||||
),
|
||||
WeHeatSensorEntityDescription(
|
||||
translation_key="energy_output_heating",
|
||||
key="energy_output_heating",
|
||||
|
||||
@@ -96,6 +96,9 @@
|
||||
"electricity_used_heating": {
|
||||
"name": "Electricity used heating"
|
||||
},
|
||||
"electricity_used_standby": {
|
||||
"name": "Electricity used standby"
|
||||
},
|
||||
"energy_output": {
|
||||
"name": "Total energy output"
|
||||
},
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/zestimate",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["xmltodict==1.0.2"]
|
||||
"requirements": ["xmltodict==1.0.4"]
|
||||
}
|
||||
|
||||
@@ -804,6 +804,6 @@ def _decode_jwt(hass: HomeAssistant, encoded: str) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return jwt.decode(encoded, secret, algorithms=["HS256"]) # type: ignore[no-any-return]
|
||||
return jwt.decode(encoded, secret, algorithms=["HS256"])
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
|
||||
@@ -38,13 +38,13 @@ ha-ffmpeg==3.2.2
|
||||
habluetooth==6.0.0
|
||||
hass-nabucasa==2.2.0
|
||||
hassil==3.5.0
|
||||
home-assistant-bluetooth==1.13.1
|
||||
home-assistant-bluetooth==2.0.0
|
||||
home-assistant-frontend==20260325.7
|
||||
home-assistant-intents==2026.3.24
|
||||
httpx==0.28.1
|
||||
ifaddr==0.2.0
|
||||
Jinja2==3.1.6
|
||||
lru-dict==1.3.0
|
||||
lru-dict==1.4.1
|
||||
mutagen==1.47.0
|
||||
openai==2.21.0
|
||||
orjson==3.11.8
|
||||
@@ -53,13 +53,13 @@ paho-mqtt==2.1.0
|
||||
Pillow==12.2.0
|
||||
propcache==0.4.1
|
||||
psutil-home-assistant==0.0.1
|
||||
PyJWT==2.10.1
|
||||
PyJWT==2.12.1
|
||||
pymicro-vad==1.0.1
|
||||
PyNaCl==1.6.2
|
||||
pyOpenSSL==26.0.0
|
||||
pyspeex-noise==1.0.2
|
||||
python-slugify==8.0.4
|
||||
PyTurboJPEG==1.8.3
|
||||
PyTurboJPEG==2.2.0
|
||||
PyYAML==6.0.3
|
||||
requests==2.33.1
|
||||
securetar==2026.4.1
|
||||
@@ -134,7 +134,7 @@ backoff>=2.0
|
||||
Brotli>=1.2.0
|
||||
|
||||
# ensure pydantic version does not float since it might have breaking changes
|
||||
pydantic==2.13.0
|
||||
pydantic==2.13.1
|
||||
|
||||
# Required for Python 3.14.0 compatibility (#119223).
|
||||
mashumaro>=3.17.0
|
||||
|
||||
@@ -51,11 +51,11 @@ dependencies = [
|
||||
# When bumping httpx, please check the version pins of
|
||||
# httpcore, anyio, and h11 in gen_requirements_all
|
||||
"httpx==0.28.1",
|
||||
"home-assistant-bluetooth==1.13.1",
|
||||
"home-assistant-bluetooth==2.0.0",
|
||||
"ifaddr==0.2.0",
|
||||
"Jinja2==3.1.6",
|
||||
"lru-dict==1.3.0",
|
||||
"PyJWT==2.10.1",
|
||||
"lru-dict==1.4.1",
|
||||
"PyJWT==2.12.1",
|
||||
# PyJWT has loose dependency. We want the latest one.
|
||||
"cryptography==46.0.7",
|
||||
"Pillow==12.2.0",
|
||||
|
||||
8
requirements.txt
generated
8
requirements.txt
generated
@@ -26,25 +26,25 @@ fnv-hash-fast==2.0.2
|
||||
ha-ffmpeg==3.2.2
|
||||
hass-nabucasa==2.2.0
|
||||
hassil==3.5.0
|
||||
home-assistant-bluetooth==1.13.1
|
||||
home-assistant-bluetooth==2.0.0
|
||||
home-assistant-intents==2026.3.24
|
||||
httpx==0.28.1
|
||||
ifaddr==0.2.0
|
||||
infrared-protocols==1.2.0
|
||||
Jinja2==3.1.6
|
||||
lru-dict==1.3.0
|
||||
lru-dict==1.4.1
|
||||
mutagen==1.47.0
|
||||
orjson==3.11.8
|
||||
packaging>=23.1
|
||||
Pillow==12.2.0
|
||||
propcache==0.4.1
|
||||
psutil-home-assistant==0.0.1
|
||||
PyJWT==2.10.1
|
||||
PyJWT==2.12.1
|
||||
pymicro-vad==1.0.1
|
||||
pyOpenSSL==26.0.0
|
||||
pyspeex-noise==1.0.2
|
||||
python-slugify==8.0.4
|
||||
PyTurboJPEG==1.8.3
|
||||
PyTurboJPEG==2.2.0
|
||||
PyYAML==6.0.3
|
||||
requests==2.33.1
|
||||
securetar==2026.4.1
|
||||
|
||||
8
requirements_all.txt
generated
8
requirements_all.txt
generated
@@ -96,7 +96,7 @@ PyTransportNSW==0.1.1
|
||||
|
||||
# homeassistant.components.camera
|
||||
# homeassistant.components.stream
|
||||
PyTurboJPEG==1.8.3
|
||||
PyTurboJPEG==2.2.0
|
||||
|
||||
# homeassistant.components.vicare
|
||||
PyViCare==2.59.0
|
||||
@@ -1313,7 +1313,7 @@ ihcsdk==2.8.5
|
||||
imeon_inverter_api==0.4.0
|
||||
|
||||
# homeassistant.components.imgw_pib
|
||||
imgw_pib==2.0.2
|
||||
imgw_pib==2.1.0
|
||||
|
||||
# homeassistant.components.incomfort
|
||||
incomfort-client==0.7.0
|
||||
@@ -1980,7 +1980,7 @@ pyatv==0.17.0
|
||||
pyaussiebb==0.1.5
|
||||
|
||||
# homeassistant.components.myneomitis
|
||||
pyaxencoapi==1.0.6
|
||||
pyaxencoapi==1.0.7
|
||||
|
||||
# homeassistant.components.balboa
|
||||
pybalboa==1.1.3
|
||||
@@ -3334,7 +3334,7 @@ xknxproject==3.8.2
|
||||
# homeassistant.components.startca
|
||||
# homeassistant.components.ted5000
|
||||
# homeassistant.components.zestimate
|
||||
xmltodict==1.0.2
|
||||
xmltodict==1.0.4
|
||||
|
||||
# homeassistant.components.xs1
|
||||
xs1-api-client==3.0.0
|
||||
|
||||
@@ -16,7 +16,7 @@ license-expression==30.4.3
|
||||
mock-open==1.4.0
|
||||
mypy==1.20.1
|
||||
prek==0.2.28
|
||||
pydantic==2.13.0
|
||||
pydantic==2.13.1
|
||||
pylint==4.0.5
|
||||
pylint-per-file-ignores==3.2.1
|
||||
pipdeptree==2.26.1
|
||||
|
||||
8
requirements_test_all.txt
generated
8
requirements_test_all.txt
generated
@@ -93,7 +93,7 @@ PyTransportNSW==0.1.1
|
||||
|
||||
# homeassistant.components.camera
|
||||
# homeassistant.components.stream
|
||||
PyTurboJPEG==1.8.3
|
||||
PyTurboJPEG==2.2.0
|
||||
|
||||
# homeassistant.components.vicare
|
||||
PyViCare==2.59.0
|
||||
@@ -1165,7 +1165,7 @@ igloohome-api==0.1.1
|
||||
imeon_inverter_api==0.4.0
|
||||
|
||||
# homeassistant.components.imgw_pib
|
||||
imgw_pib==2.0.2
|
||||
imgw_pib==2.1.0
|
||||
|
||||
# homeassistant.components.incomfort
|
||||
incomfort-client==0.7.0
|
||||
@@ -1717,7 +1717,7 @@ pyatv==0.17.0
|
||||
pyaussiebb==0.1.5
|
||||
|
||||
# homeassistant.components.myneomitis
|
||||
pyaxencoapi==1.0.6
|
||||
pyaxencoapi==1.0.7
|
||||
|
||||
# homeassistant.components.balboa
|
||||
pybalboa==1.1.3
|
||||
@@ -2828,7 +2828,7 @@ xknxproject==3.8.2
|
||||
# homeassistant.components.startca
|
||||
# homeassistant.components.ted5000
|
||||
# homeassistant.components.zestimate
|
||||
xmltodict==1.0.2
|
||||
xmltodict==1.0.4
|
||||
|
||||
# homeassistant.components.yale_smart_alarm
|
||||
yalesmartalarmclient==0.4.3
|
||||
|
||||
@@ -124,7 +124,7 @@ backoff>=2.0
|
||||
Brotli>=1.2.0
|
||||
|
||||
# ensure pydantic version does not float since it might have breaking changes
|
||||
pydantic==2.13.0
|
||||
pydantic==2.13.1
|
||||
|
||||
# Required for Python 3.14.0 compatibility (#119223).
|
||||
mashumaro>=3.17.0
|
||||
|
||||
@@ -7,7 +7,6 @@ from homeassistant import core
|
||||
from homeassistant.util import executor, thread
|
||||
|
||||
from .model import Config, Integration
|
||||
from .requirements import PACKAGE_REGEX, PIP_VERSION_RANGE_SEPARATOR
|
||||
|
||||
_GO2RTC_SHA = (
|
||||
"675c318b23c06fd862a61d262240c9a63436b4050d177ffc68a32710d9e05bae" # 1.9.14
|
||||
@@ -43,8 +42,7 @@ COPY rootfs /
|
||||
COPY --from=ghcr.io/alexxit/go2rtc@sha256:{go2rtc} /usr/local/bin/go2rtc /bin/go2rtc
|
||||
|
||||
## Setup Home Assistant Core dependencies
|
||||
COPY requirements.txt homeassistant/
|
||||
COPY homeassistant/package_constraints.txt homeassistant/homeassistant/
|
||||
COPY --parents requirements.txt homeassistant/package_constraints.txt homeassistant/
|
||||
RUN \
|
||||
# Verify go2rtc can be executed
|
||||
go2rtc --version \
|
||||
@@ -64,7 +62,7 @@ RUN \
|
||||
-r homeassistant/requirements_all.txt
|
||||
|
||||
## Setup Home Assistant Core
|
||||
COPY . homeassistant/
|
||||
COPY --parents LICENSE* README* homeassistant/ pyproject.toml homeassistant/
|
||||
RUN \
|
||||
uv pip install \
|
||||
-e ./homeassistant \
|
||||
@@ -139,10 +137,12 @@ SHELL ["/bin/sh", "-o", "pipefail", "-c"]
|
||||
ENTRYPOINT ["/usr/src/homeassistant/script/hassfest/docker/entrypoint.sh"]
|
||||
WORKDIR "/github/workspace"
|
||||
|
||||
COPY . /usr/src/homeassistant
|
||||
COPY --parents requirements.txt homeassistant/ script /usr/src/homeassistant/
|
||||
|
||||
# Uv creates a lock file in /tmp
|
||||
RUN --mount=type=tmpfs,target=/tmp \
|
||||
--mount=type=bind,source=requirements_test.txt,target=/tmp/requirements_test.txt,readonly \
|
||||
--mount=type=bind,source=requirements_test_pre_commit.txt,target=/tmp/requirements_test_pre_commit.txt,readonly \
|
||||
# Required for PyTurboJPEG
|
||||
apk add --no-cache libturbojpeg \
|
||||
# Install uv at the version pinned in the requirements file
|
||||
@@ -152,9 +152,9 @@ RUN --mount=type=tmpfs,target=/tmp \
|
||||
--no-cache \
|
||||
-c /usr/src/homeassistant/homeassistant/package_constraints.txt \
|
||||
-r /usr/src/homeassistant/requirements.txt \
|
||||
pipdeptree=={pipdeptree} \
|
||||
tqdm=={tqdm} \
|
||||
ruff=={ruff}
|
||||
"pipdeptree==$(awk -F'==' '/^pipdeptree==/{{print $2}}' /tmp/requirements_test.txt)" \
|
||||
"tqdm==$(awk -F'==' '/^tqdm==/{{print $2}}' /tmp/requirements_test.txt)" \
|
||||
"ruff==$(awk -F'==' '/^ruff==/{{print $2}}' /tmp/requirements_test_pre_commit.txt)"
|
||||
|
||||
LABEL "name"="hassfest"
|
||||
LABEL "maintainer"="Home Assistant <hello@home-assistant.io>"
|
||||
@@ -166,36 +166,6 @@ LABEL "com.github.actions.color"="gray-dark"
|
||||
"""
|
||||
|
||||
|
||||
def _get_package_versions(file: Path, packages: set[str]) -> dict[str, str]:
|
||||
package_versions: dict[str, str] = {}
|
||||
with file.open(encoding="UTF-8") as fp:
|
||||
for _, line in enumerate(fp):
|
||||
if package_versions.keys() == packages:
|
||||
return package_versions
|
||||
|
||||
if match := PACKAGE_REGEX.match(line):
|
||||
pkg, sep, version = match.groups()
|
||||
|
||||
if pkg not in packages:
|
||||
continue
|
||||
|
||||
if sep != "==" or not version:
|
||||
raise RuntimeError(
|
||||
f'Requirement {pkg} need to be pinned "{pkg}==<version>".'
|
||||
)
|
||||
|
||||
for part in version.split(";", 1)[0].split(","):
|
||||
version_part = PIP_VERSION_RANGE_SEPARATOR.match(part)
|
||||
if version_part:
|
||||
package_versions[pkg] = version_part.group(2)
|
||||
break
|
||||
|
||||
if package_versions.keys() == packages:
|
||||
return package_versions
|
||||
|
||||
raise RuntimeError("At least one package was not found in the requirements file.")
|
||||
|
||||
|
||||
@dataclass
|
||||
class File:
|
||||
"""File."""
|
||||
@@ -215,27 +185,16 @@ def _generate_files(config: Config) -> list[File]:
|
||||
+ 10
|
||||
) * 1000
|
||||
|
||||
package_versions = _get_package_versions(
|
||||
config.root / "requirements_test.txt", {"pipdeptree", "tqdm"}
|
||||
)
|
||||
package_versions |= _get_package_versions(
|
||||
config.root / "requirements_test_pre_commit.txt", {"ruff"}
|
||||
)
|
||||
|
||||
files = [
|
||||
File(
|
||||
DOCKERFILE_TEMPLATE.format(
|
||||
timeout=timeout,
|
||||
**package_versions,
|
||||
go2rtc=_GO2RTC_SHA,
|
||||
),
|
||||
config.root / "Dockerfile",
|
||||
),
|
||||
File(
|
||||
_HASSFEST_TEMPLATE.format(
|
||||
timeout=timeout,
|
||||
**package_versions,
|
||||
),
|
||||
_HASSFEST_TEMPLATE.format(),
|
||||
config.root / "script/hassfest/docker/Dockerfile",
|
||||
),
|
||||
]
|
||||
|
||||
10
script/hassfest/docker/Dockerfile
generated
10
script/hassfest/docker/Dockerfile
generated
@@ -11,10 +11,12 @@ SHELL ["/bin/sh", "-o", "pipefail", "-c"]
|
||||
ENTRYPOINT ["/usr/src/homeassistant/script/hassfest/docker/entrypoint.sh"]
|
||||
WORKDIR "/github/workspace"
|
||||
|
||||
COPY . /usr/src/homeassistant
|
||||
COPY --parents requirements.txt homeassistant/ script /usr/src/homeassistant/
|
||||
|
||||
# Uv creates a lock file in /tmp
|
||||
RUN --mount=type=tmpfs,target=/tmp \
|
||||
--mount=type=bind,source=requirements_test.txt,target=/tmp/requirements_test.txt,readonly \
|
||||
--mount=type=bind,source=requirements_test_pre_commit.txt,target=/tmp/requirements_test_pre_commit.txt,readonly \
|
||||
# Required for PyTurboJPEG
|
||||
apk add --no-cache libturbojpeg \
|
||||
# Install uv at the version pinned in the requirements file
|
||||
@@ -24,9 +26,9 @@ RUN --mount=type=tmpfs,target=/tmp \
|
||||
--no-cache \
|
||||
-c /usr/src/homeassistant/homeassistant/package_constraints.txt \
|
||||
-r /usr/src/homeassistant/requirements.txt \
|
||||
pipdeptree==2.26.1 \
|
||||
tqdm==4.67.1 \
|
||||
ruff==0.15.1
|
||||
"pipdeptree==$(awk -F'==' '/^pipdeptree==/{print $2}' /tmp/requirements_test.txt)" \
|
||||
"tqdm==$(awk -F'==' '/^tqdm==/{print $2}' /tmp/requirements_test.txt)" \
|
||||
"ruff==$(awk -F'==' '/^ruff==/{print $2}' /tmp/requirements_test_pre_commit.txt)"
|
||||
|
||||
LABEL "name"="hassfest"
|
||||
LABEL "maintainer"="Home Assistant <hello@home-assistant.io>"
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
# Ignore everything except the specified files
|
||||
*
|
||||
|
||||
!homeassistant/
|
||||
!requirements.txt
|
||||
!script/
|
||||
script/hassfest/docker/
|
||||
!script/hassfest/docker/entrypoint.sh
|
||||
# No need to include the Dockerfile
|
||||
script/hassfest/docker/Dockerfile*
|
||||
|
||||
# Temporary files
|
||||
**/__pycache__
|
||||
@@ -6,12 +6,6 @@ import pytest
|
||||
from homeassistant.auth import jwt_wrapper
|
||||
|
||||
|
||||
async def test_all_default_options_are_in_verify_options() -> None:
|
||||
"""Test that all default options in _VERIFY_OPTIONS."""
|
||||
for option in jwt_wrapper._PyJWTWithVerify._get_default_options():
|
||||
assert option in jwt_wrapper._VERIFY_OPTIONS
|
||||
|
||||
|
||||
async def test_reject_access_token_with_impossible_large_size() -> None:
|
||||
"""Test rejecting access tokens with impossible sizes."""
|
||||
with pytest.raises(jwt.DecodeError):
|
||||
|
||||
@@ -14143,7 +14143,7 @@
|
||||
'object_id_base': 'Export to grid',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 2,
|
||||
'suggested_display_precision': 0,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.POWER: 'power'>,
|
||||
@@ -14155,7 +14155,7 @@
|
||||
'supported_features': 0,
|
||||
'translation_key': 'mix_export_to_grid',
|
||||
'unique_id': 'SPH123456-mix_export_to_grid',
|
||||
'unit_of_measurement': <UnitOfPower.KILO_WATT: 'kW'>,
|
||||
'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_sph_sensors_v1_api[sensor.sph123456_export_to_grid-state]
|
||||
@@ -14164,7 +14164,7 @@
|
||||
'device_class': 'power',
|
||||
'friendly_name': 'SPH123456 Export to grid',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfPower.KILO_WATT: 'kW'>,
|
||||
'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.sph123456_export_to_grid',
|
||||
@@ -14314,7 +14314,7 @@
|
||||
'object_id_base': 'Import from grid',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 2,
|
||||
'suggested_display_precision': 0,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.POWER: 'power'>,
|
||||
@@ -14326,7 +14326,7 @@
|
||||
'supported_features': 0,
|
||||
'translation_key': 'mix_import_from_grid',
|
||||
'unique_id': 'SPH123456-mix_import_from_grid',
|
||||
'unit_of_measurement': <UnitOfPower.KILO_WATT: 'kW'>,
|
||||
'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_sph_sensors_v1_api[sensor.sph123456_import_from_grid-state]
|
||||
@@ -14335,7 +14335,7 @@
|
||||
'device_class': 'power',
|
||||
'friendly_name': 'SPH123456 Import from grid',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfPower.KILO_WATT: 'kW'>,
|
||||
'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.sph123456_import_from_grid',
|
||||
|
||||
@@ -21,6 +21,8 @@ HYDROLOGICAL_DATA = HydrologicalData(
|
||||
water_temperature=SensorData(name="Water Temperature", value=10.8),
|
||||
flood_alarm=None,
|
||||
flood_warning=None,
|
||||
ice_phenomenon=SensorData(name="Ice Phenomenon", value=20),
|
||||
ice_phenomenon_measurement_date=datetime(2024, 4, 27, 10, 0, tzinfo=UTC),
|
||||
water_level_measurement_date=datetime(2024, 4, 27, 10, 0, tzinfo=UTC),
|
||||
water_temperature_measurement_date=datetime(2024, 4, 27, 10, 10, tzinfo=UTC),
|
||||
water_flow=SensorData(name="Water Flow", value=123.45),
|
||||
|
||||
@@ -41,6 +41,12 @@
|
||||
'valid_to': '2024-04-28T11:00:00+00:00',
|
||||
'value': 'rapid_water_level_rise',
|
||||
}),
|
||||
'ice_phenomenon': dict({
|
||||
'name': 'Ice Phenomenon',
|
||||
'unit': None,
|
||||
'value': 20,
|
||||
}),
|
||||
'ice_phenomenon_measurement_date': '2024-04-27T10:00:00+00:00',
|
||||
'latitude': None,
|
||||
'longitude': None,
|
||||
'river': 'River Name',
|
||||
|
||||
@@ -5,6 +5,7 @@ from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from soco.exceptions import SoCoException
|
||||
|
||||
from homeassistant.components.sonos import DOMAIN
|
||||
from homeassistant.components.sonos.const import (
|
||||
@@ -341,6 +342,28 @@ async def test_alarm_change_device(
|
||||
assert device.name == soco_br.get_speaker_info()["zone_name"]
|
||||
|
||||
|
||||
async def test_alarm_update_exception_logs_warning(
|
||||
hass: HomeAssistant,
|
||||
async_setup_sonos,
|
||||
entity_registry: er.EntityRegistry,
|
||||
soco: MockSoCo,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test household mismatch logs warning and alarm update/setup is skipped."""
|
||||
with patch(
|
||||
"homeassistant.components.sonos.alarms.Alarms.update",
|
||||
side_effect=SoCoException(
|
||||
"Alarm list UID RINCON_0001234567890:31 does not match RINCON_000E987654321:0"
|
||||
),
|
||||
):
|
||||
await async_setup_sonos()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Alarm should not be set up due to household mismatch
|
||||
assert "switch.sonos_alarm_14" not in entity_registry.entities
|
||||
assert "cannot be updated due to a household mismatch" in caplog.text
|
||||
|
||||
|
||||
async def test_alarm_setup_for_undiscovered_speaker(
|
||||
hass: HomeAssistant,
|
||||
async_setup_sonos,
|
||||
|
||||
@@ -11,6 +11,7 @@ from homeassistant.components.synology_dsm.const import (
|
||||
DOMAIN,
|
||||
SERVICES,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_MAC,
|
||||
@@ -22,7 +23,6 @@ from homeassistant.const import (
|
||||
CONF_VERIFY_SSL,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from .consts import HOST, MACS, PASSWORD, PORT, USE_SSL, USERNAME
|
||||
|
||||
@@ -57,19 +57,9 @@ async def test_services_registered(hass: HomeAssistant, mock_dsm: MagicMock) ->
|
||||
|
||||
async def test_reauth_triggered(hass: HomeAssistant) -> None:
|
||||
"""Test if reauthentication flow is triggered."""
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.synology_dsm.SynoApi.async_setup",
|
||||
side_effect=SynologyDSMLoginInvalidException(USERNAME),
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.synology_dsm.config_flow.SynologyDSMFlowHandler.async_step_reauth",
|
||||
return_value={
|
||||
"type": FlowResultType.FORM,
|
||||
"flow_id": "mock_flow",
|
||||
"step_id": "reauth_confirm",
|
||||
},
|
||||
) as mock_async_step_reauth,
|
||||
with patch(
|
||||
"homeassistant.components.synology_dsm.SynoApi.async_setup",
|
||||
side_effect=SynologyDSMLoginInvalidException(USERNAME),
|
||||
):
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
@@ -85,7 +75,8 @@ async def test_reauth_triggered(hass: HomeAssistant) -> None:
|
||||
entry.add_to_hass(hass)
|
||||
assert not await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
mock_async_step_reauth.assert_called_once()
|
||||
assert entry.state is ConfigEntryState.SETUP_ERROR
|
||||
assert any(entry.async_get_active_flows(hass, {SOURCE_REAUTH}))
|
||||
|
||||
|
||||
async def test_config_entry_migrations(
|
||||
|
||||
@@ -124,6 +124,7 @@ def mock_weheat_heat_pump_instance() -> MagicMock:
|
||||
mock_heat_pump_instance.energy_in_dhw = 6789
|
||||
mock_heat_pump_instance.energy_in_defrost = 555
|
||||
mock_heat_pump_instance.energy_in_cooling = 9000
|
||||
mock_heat_pump_instance.energy_in_standby = 684
|
||||
mock_heat_pump_instance.energy_total = 28689
|
||||
mock_heat_pump_instance.energy_out_heating = 10000
|
||||
mock_heat_pump_instance.energy_out_dhw = 6677
|
||||
|
||||
@@ -877,6 +877,64 @@
|
||||
'state': '12345',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[sensor.test_model_electricity_used_standby-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
|
||||
}),
|
||||
'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.test_model_electricity_used_standby',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Electricity used standby',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 2,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Electricity used standby',
|
||||
'platform': 'weheat',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'electricity_used_standby',
|
||||
'unique_id': '0000-1111-2222-3333_electricity_used_standby',
|
||||
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[sensor.test_model_electricity_used_standby-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'energy',
|
||||
'friendly_name': 'Test Model Electricity used standby',
|
||||
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
|
||||
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.test_model_electricity_used_standby',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '684',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[sensor.test_model_energy_output_cooling-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
|
||||
@@ -33,7 +33,7 @@ async def test_all_entities(
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("has_dhw", "nr_of_entities"), [(False, 22), (True, 27)])
|
||||
@pytest.mark.parametrize(("has_dhw", "nr_of_entities"), [(False, 23), (True, 28)])
|
||||
async def test_create_entities(
|
||||
hass: HomeAssistant,
|
||||
mock_weheat_discover: AsyncMock,
|
||||
|
||||
@@ -28,6 +28,8 @@ from homeassistant.const import (
|
||||
CONF_ENTITY_ID,
|
||||
CONF_OPTIONS,
|
||||
CONF_TARGET,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
UnitOfTemperature,
|
||||
@@ -56,6 +58,7 @@ from homeassistant.helpers.condition import (
|
||||
async_validate_condition_config,
|
||||
make_entity_numerical_condition,
|
||||
make_entity_numerical_condition_with_unit,
|
||||
make_entity_state_condition,
|
||||
)
|
||||
from homeassistant.helpers.template import Template
|
||||
from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType
|
||||
@@ -3939,3 +3942,188 @@ async def test_numerical_condition_with_unit_behavior(
|
||||
{ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS},
|
||||
)
|
||||
assert test(hass) is False
|
||||
|
||||
|
||||
async def _setup_state_condition(
|
||||
hass: HomeAssistant,
|
||||
entity_ids: str | list[str],
|
||||
states: str | bool | set[str | bool],
|
||||
condition_options: dict[str, Any] | None = None,
|
||||
domain_specs: Mapping[str, DomainSpec] | None = None,
|
||||
) -> condition.ConditionCheckerType:
|
||||
"""Set up a state condition via a mock platform and return the checker."""
|
||||
condition_cls = make_entity_state_condition(
|
||||
domain_specs or _DEFAULT_DOMAIN_SPECS,
|
||||
states,
|
||||
)
|
||||
|
||||
async def async_get_conditions(
|
||||
hass: HomeAssistant,
|
||||
) -> dict[str, type[Condition]]:
|
||||
return {"_": condition_cls}
|
||||
|
||||
mock_integration(hass, MockModule("test"))
|
||||
mock_platform(
|
||||
hass, "test.condition", Mock(async_get_conditions=async_get_conditions)
|
||||
)
|
||||
|
||||
if isinstance(entity_ids, str):
|
||||
entity_ids = [entity_ids]
|
||||
|
||||
config: dict[str, Any] = {
|
||||
CONF_CONDITION: "test",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: entity_ids},
|
||||
CONF_OPTIONS: condition_options or {},
|
||||
}
|
||||
|
||||
config = await async_validate_condition_config(hass, config)
|
||||
test = await condition.async_from_config(hass, config)
|
||||
assert test is not None
|
||||
return test
|
||||
|
||||
|
||||
async def test_state_condition_single_entity(hass: HomeAssistant) -> None:
|
||||
"""Test state condition with a single entity."""
|
||||
test = await _setup_state_condition(
|
||||
hass, entity_ids="test.entity_1", states=STATE_ON
|
||||
)
|
||||
|
||||
hass.states.async_set("test.entity_1", STATE_ON)
|
||||
assert test(hass) is True
|
||||
|
||||
hass.states.async_set("test.entity_1", STATE_OFF)
|
||||
assert test(hass) is False
|
||||
|
||||
|
||||
async def test_state_condition_multiple_target_states(hass: HomeAssistant) -> None:
|
||||
"""Test state condition matching any of multiple target states."""
|
||||
test = await _setup_state_condition(
|
||||
hass, entity_ids="test.entity_1", states={"on", "heat"}
|
||||
)
|
||||
|
||||
hass.states.async_set("test.entity_1", "on")
|
||||
assert test(hass) is True
|
||||
|
||||
hass.states.async_set("test.entity_1", "heat")
|
||||
assert test(hass) is True
|
||||
|
||||
hass.states.async_set("test.entity_1", "off")
|
||||
assert test(hass) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"state_value",
|
||||
[STATE_UNAVAILABLE, STATE_UNKNOWN],
|
||||
)
|
||||
async def test_state_condition_unavailable_unknown(
|
||||
hass: HomeAssistant, state_value: str
|
||||
) -> None:
|
||||
"""Test state condition with unavailable/unknown entities.
|
||||
|
||||
Uses three entities: entity_1 is on, entity_2 is unavailable/unknown,
|
||||
entity_3 varies. Unavailable/unknown entities are excluded from
|
||||
evaluation, so:
|
||||
- behavior any: passes if at least one *available* entity matches
|
||||
- behavior all: passes if all *available* entities match
|
||||
"""
|
||||
# Single entity: unavailable/unknown → False
|
||||
test_single = await _setup_state_condition(
|
||||
hass, entity_ids="test.entity_1", states=STATE_ON
|
||||
)
|
||||
hass.states.async_set("test.entity_1", state_value)
|
||||
assert test_single(hass) is False
|
||||
|
||||
# behavior any: entity_1=on, entity_2=unavailable, entity_3=off
|
||||
# → True (entity_1 matches, entity_2 is skipped)
|
||||
test_any = await _setup_state_condition(
|
||||
hass,
|
||||
entity_ids=["test.entity_1", "test.entity_2", "test.entity_3"],
|
||||
states=STATE_ON,
|
||||
condition_options={ATTR_BEHAVIOR: BEHAVIOR_ANY},
|
||||
)
|
||||
hass.states.async_set("test.entity_1", STATE_ON)
|
||||
hass.states.async_set("test.entity_2", state_value)
|
||||
hass.states.async_set("test.entity_3", STATE_OFF)
|
||||
assert test_any(hass) is True
|
||||
|
||||
# behavior any: entity_1=off, entity_2=unavailable, entity_3=off
|
||||
# → False (no available entity matches)
|
||||
hass.states.async_set("test.entity_1", STATE_OFF)
|
||||
assert test_any(hass) is False
|
||||
|
||||
# behavior all: entity_1=on, entity_2=unavailable, entity_3=on
|
||||
# → True (all *available* entities match, entity_2 is skipped)
|
||||
test_all = await _setup_state_condition(
|
||||
hass,
|
||||
entity_ids=["test.entity_1", "test.entity_2", "test.entity_3"],
|
||||
states=STATE_ON,
|
||||
condition_options={ATTR_BEHAVIOR: BEHAVIOR_ALL},
|
||||
)
|
||||
hass.states.async_set("test.entity_1", STATE_ON)
|
||||
hass.states.async_set("test.entity_2", state_value)
|
||||
hass.states.async_set("test.entity_3", STATE_ON)
|
||||
assert test_all(hass) is True
|
||||
|
||||
# behavior all: entity_1=on, entity_2=unavailable, entity_3=off
|
||||
# → False (entity_3 is available and doesn't match)
|
||||
hass.states.async_set("test.entity_3", STATE_OFF)
|
||||
assert test_all(hass) is False
|
||||
|
||||
|
||||
async def test_state_condition_entity_not_found(hass: HomeAssistant) -> None:
|
||||
"""Test state condition when entity does not exist."""
|
||||
test = await _setup_state_condition(
|
||||
hass, entity_ids="test.nonexistent", states=STATE_ON
|
||||
)
|
||||
|
||||
# Entity doesn't exist — condition should be false
|
||||
assert test(hass) is False
|
||||
|
||||
|
||||
async def test_state_condition_attribute_value_source(hass: HomeAssistant) -> None:
|
||||
"""Test state condition reads from attribute when value_source is set."""
|
||||
test = await _setup_state_condition(
|
||||
hass,
|
||||
entity_ids="test.entity_1",
|
||||
states="heat",
|
||||
domain_specs={"test": DomainSpec(value_source="hvac_action")},
|
||||
)
|
||||
|
||||
hass.states.async_set("test.entity_1", "on", {"hvac_action": "heat"})
|
||||
assert test(hass) is True
|
||||
|
||||
hass.states.async_set("test.entity_1", "on", {"hvac_action": "idle"})
|
||||
assert test(hass) is False
|
||||
|
||||
# Missing attribute
|
||||
hass.states.async_set("test.entity_1", "on", {})
|
||||
assert test(hass) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("behavior", "one_match_expected"),
|
||||
[(BEHAVIOR_ANY, True), (BEHAVIOR_ALL, False)],
|
||||
)
|
||||
async def test_state_condition_behavior(
|
||||
hass: HomeAssistant, behavior: str, one_match_expected: bool
|
||||
) -> None:
|
||||
"""Test state condition with behavior any/all."""
|
||||
test = await _setup_state_condition(
|
||||
hass,
|
||||
entity_ids=["test.entity_1", "test.entity_2"],
|
||||
states=STATE_ON,
|
||||
condition_options={ATTR_BEHAVIOR: behavior},
|
||||
)
|
||||
|
||||
# Both on → True for any and all
|
||||
hass.states.async_set("test.entity_1", STATE_ON)
|
||||
hass.states.async_set("test.entity_2", STATE_ON)
|
||||
assert test(hass) is True
|
||||
|
||||
# Only one on → depends on behavior
|
||||
hass.states.async_set("test.entity_2", STATE_OFF)
|
||||
assert test(hass) is one_match_expected
|
||||
|
||||
# Neither on → False for any and all
|
||||
hass.states.async_set("test.entity_1", STATE_OFF)
|
||||
assert test(hass) is False
|
||||
|
||||
@@ -124,25 +124,23 @@ async def test_setup_does_discovery(
|
||||
async def test_set_scan_interval_via_config(hass: HomeAssistant) -> None:
|
||||
"""Test the setting of the scan interval via configuration."""
|
||||
|
||||
async def async_platform_setup(
|
||||
def platform_setup(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
add_entities: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Test the platform setup."""
|
||||
async_add_entities([MockEntity(should_poll=True)])
|
||||
add_entities([MockEntity(should_poll=True)])
|
||||
|
||||
mock_platform(
|
||||
hass,
|
||||
"platform.test_domain",
|
||||
MockPlatform(async_setup_platform=async_platform_setup),
|
||||
hass, "platform.test_domain", MockPlatform(setup_platform=platform_setup)
|
||||
)
|
||||
|
||||
component = EntityComponent(_LOGGER, DOMAIN, hass)
|
||||
|
||||
with patch.object(hass.loop, "call_later") as mock_track:
|
||||
await component.async_setup(
|
||||
component.setup(
|
||||
{DOMAIN: {"platform": "platform", "scan_interval": timedelta(seconds=30)}}
|
||||
)
|
||||
|
||||
@@ -154,24 +152,22 @@ async def test_set_scan_interval_via_config(hass: HomeAssistant) -> None:
|
||||
async def test_set_entity_namespace_via_config(hass: HomeAssistant) -> None:
|
||||
"""Test setting an entity namespace."""
|
||||
|
||||
async def async_platform_setup(
|
||||
def platform_setup(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
add_entities: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Test the platform setup."""
|
||||
async_add_entities([MockEntity(name="beer"), MockEntity(name=None)])
|
||||
add_entities([MockEntity(name="beer"), MockEntity(name=None)])
|
||||
|
||||
platform = MockPlatform(async_setup_platform=async_platform_setup)
|
||||
platform = MockPlatform(setup_platform=platform_setup)
|
||||
|
||||
mock_platform(hass, "platform.test_domain", platform)
|
||||
|
||||
component = EntityComponent(_LOGGER, DOMAIN, hass)
|
||||
|
||||
await component.async_setup(
|
||||
{DOMAIN: {"platform": "platform", "entity_namespace": "yummy"}}
|
||||
)
|
||||
component.setup({DOMAIN: {"platform": "platform", "entity_namespace": "yummy"}})
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
@@ -270,8 +270,8 @@ async def test_adding_entities_with_generator_and_thread_callback(
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("disable_translations_once")
|
||||
async def test_platform_slow_setup_cancel_warning(hass: HomeAssistant) -> None:
|
||||
"""Test slow setup warning timer is scheduled and cancelled on success."""
|
||||
async def test_platform_warn_slow_setup(hass: HomeAssistant) -> None:
|
||||
"""Warn we log when platform setup takes a long time."""
|
||||
platform = MockPlatform()
|
||||
|
||||
mock_platform(hass, "platform.test_domain", platform)
|
||||
@@ -283,26 +283,22 @@ async def test_platform_slow_setup_cancel_warning(hass: HomeAssistant) -> None:
|
||||
await hass.async_block_till_done()
|
||||
assert mock_call.called
|
||||
|
||||
# Find the platform setup warning by matching the exact format string
|
||||
platform_warn_call = next(
|
||||
call
|
||||
for call in mock_call.call_args_list
|
||||
if len(call[0]) >= 3
|
||||
and call[0][1] == _LOGGER.warning
|
||||
and call[0][2] == "Setup of %s platform %s is taking over %s seconds."
|
||||
)
|
||||
scheduled_time = platform_warn_call[0][0]
|
||||
# mock_calls[3] is the warning message for component setup
|
||||
# mock_calls[10] is the warning message for platform setup
|
||||
timeout, logger_method = mock_call.mock_calls[10][1][:2]
|
||||
|
||||
assert scheduled_time - hass.loop.time() == pytest.approx(
|
||||
assert timeout - hass.loop.time() == pytest.approx(
|
||||
entity_platform.SLOW_SETUP_WARNING, 0.5
|
||||
)
|
||||
assert mock_call.return_value.cancel.called
|
||||
assert logger_method == _LOGGER.warning
|
||||
|
||||
assert mock_call().cancel.called
|
||||
|
||||
|
||||
async def test_platform_slow_setup_timeout(
|
||||
async def test_platform_error_slow_setup(
|
||||
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Test that platform setup is aborted after SLOW_SETUP_MAX_WAIT."""
|
||||
"""Don't block startup more than SLOW_SETUP_MAX_WAIT."""
|
||||
with patch.object(entity_platform, "SLOW_SETUP_MAX_WAIT", 0):
|
||||
called = []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user