mirror of
https://github.com/home-assistant/core.git
synced 2026-03-14 15:02:02 +01:00
Compare commits
39 Commits
mqtt-block
...
python-3.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1d105a3a2 | ||
|
|
3102fad342 | ||
|
|
6962288e85 | ||
|
|
fab4355cc8 | ||
|
|
e39d84e8fc | ||
|
|
35f597223a | ||
|
|
9d61c8336d | ||
|
|
6fd3603b7b | ||
|
|
49ac5c42ee | ||
|
|
df0db5853c | ||
|
|
7afc5b777c | ||
|
|
595aeea8cc | ||
|
|
02abba02d1 | ||
|
|
4ca1ad96f1 | ||
|
|
9f3beba97a | ||
|
|
9f86006328 | ||
|
|
4ac651d0b4 | ||
|
|
9e54abbcb5 | ||
|
|
d5915c8811 | ||
|
|
0c2887df9e | ||
|
|
3767bac850 | ||
|
|
9d962d3815 | ||
|
|
786fd40ae8 | ||
|
|
5ec65dbd58 | ||
|
|
35878bb203 | ||
|
|
e14d88ff55 | ||
|
|
d04efbfe48 | ||
|
|
3f35cd5cd2 | ||
|
|
86ffd58665 | ||
|
|
6206392b28 | ||
|
|
b7c36c707f | ||
|
|
973c32b99d | ||
|
|
951775bea6 | ||
|
|
0f2dbdf4f4 | ||
|
|
443ff7efe1 | ||
|
|
0ee6b954df | ||
|
|
5681acf0e1 | ||
|
|
a94458b8bc | ||
|
|
f3c38ba2d3 |
5
.github/copilot-instructions.md
vendored
5
.github/copilot-instructions.md
vendored
@@ -18,6 +18,11 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
|
||||
- Python 3.14 explicitly allows `except TypeA, TypeB:` without parentheses.
|
||||
|
||||
## Testing
|
||||
|
||||
When writing or modifying tests, ensure all test function parameters have type annotations.
|
||||
Prefer concrete types (for example, `HomeAssistant`, `MockConfigEntry`, etc.) over `Any`.
|
||||
|
||||
## Good practices
|
||||
|
||||
Integrations with Platinum or Gold level in the Integration Quality Scale reflect a high standard of code quality and maintainability. When looking for examples of something, these are good places to start. The level is indicated in the manifest.json of the integration.
|
||||
|
||||
14
.github/workflows/builder.yml
vendored
14
.github/workflows/builder.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -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"
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.14.2
|
||||
3.14.3
|
||||
|
||||
@@ -15,6 +15,11 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
|
||||
- Python 3.14 explicitly allows `except TypeA, TypeB:` without parentheses.
|
||||
|
||||
## Testing
|
||||
|
||||
When writing or modifying tests, ensure all test function parameters have type annotations.
|
||||
Prefer concrete types (for example, `HomeAssistant`, `MockConfigEntry`, etc.) over `Any`.
|
||||
|
||||
## Good practices
|
||||
|
||||
Integrations with Platinum or Gold level in the Integration Quality Scale reflect a high standard of code quality and maintainability. When looking for examples of something, these are good places to start. The level is indicated in the manifest.json of the integration.
|
||||
|
||||
6
CODEOWNERS
generated
6
CODEOWNERS
generated
@@ -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
|
||||
@@ -1905,6 +1909,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/wiffi/ @mampfes
|
||||
/homeassistant/components/wilight/ @leofig-rj
|
||||
/tests/components/wilight/ @leofig-rj
|
||||
/homeassistant/components/window/ @home-assistant/core
|
||||
/tests/components/window/ @home-assistant/core
|
||||
/homeassistant/components/wirelesstag/ @sergeymaysak
|
||||
/homeassistant/components/withings/ @joostlek
|
||||
/tests/components/withings/ @joostlek
|
||||
|
||||
@@ -245,6 +245,9 @@ DEFAULT_INTEGRATIONS = {
|
||||
"garage_door",
|
||||
"gate",
|
||||
"humidity",
|
||||
"motion",
|
||||
"occupancy",
|
||||
"window",
|
||||
}
|
||||
DEFAULT_INTEGRATIONS_RECOVERY_MODE = {
|
||||
# These integrations are set up if recovery mode is activated.
|
||||
|
||||
@@ -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)
|
||||
|
||||
20
homeassistant/components/arcam_fmj/entity.py
Normal file
20
homeassistant/components/arcam_fmj/entity.py
Normal 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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -152,6 +152,8 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"light",
|
||||
"lock",
|
||||
"media_player",
|
||||
"motion",
|
||||
"occupancy",
|
||||
"person",
|
||||
"remote",
|
||||
"scene",
|
||||
@@ -161,6 +163,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"text",
|
||||
"update",
|
||||
"vacuum",
|
||||
"window",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -144,6 +144,7 @@ class CreateBackupStage(StrEnum):
|
||||
ADDONS = "addons"
|
||||
AWAIT_ADDON_RESTARTS = "await_addon_restarts"
|
||||
DOCKER_CONFIG = "docker_config"
|
||||
CLEANING_UP = "cleaning_up"
|
||||
FINISHING_FILE = "finishing_file"
|
||||
FOLDERS = "folders"
|
||||
HOME_ASSISTANT = "home_assistant"
|
||||
@@ -1290,6 +1291,13 @@ class BackupManager:
|
||||
)
|
||||
# delete old backups more numerous than copies
|
||||
# try this regardless of agent errors above
|
||||
self.async_on_backup_event(
|
||||
CreateBackupEvent(
|
||||
reason=None,
|
||||
stage=CreateBackupStage.CLEANING_UP,
|
||||
state=CreateBackupState.IN_PROGRESS,
|
||||
)
|
||||
)
|
||||
await delete_backups_exceeding_configured_count(self)
|
||||
|
||||
finally:
|
||||
|
||||
@@ -516,6 +516,8 @@ class DownloadSupportPackageView(HomeAssistantView):
|
||||
hass_info: dict[str, Any],
|
||||
domains_info: dict[str, dict[str, str]],
|
||||
) -> str:
|
||||
cloud = hass.data[DATA_CLOUD]
|
||||
|
||||
def get_domain_table_markdown(domain_info: dict[str, Any]) -> str:
|
||||
if len(domain_info) == 0:
|
||||
return "No information available\n"
|
||||
@@ -572,6 +574,15 @@ class DownloadSupportPackageView(HomeAssistantView):
|
||||
"</details>\n\n"
|
||||
)
|
||||
|
||||
# Add stored latency response if available
|
||||
if locations := cloud.remote.latency_by_location:
|
||||
markdown += "## Latency by location\n\n"
|
||||
markdown += "Location | Latency (ms)\n"
|
||||
markdown += "--- | ---\n"
|
||||
for location in sorted(locations):
|
||||
markdown += f"{location} | {locations[location]['avg'] or 'N/A'}\n"
|
||||
markdown += "\n"
|
||||
|
||||
# Add installed packages section
|
||||
try:
|
||||
installed_packages = await async_get_installed_packages()
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyenphase"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pyenphase==2.4.5"],
|
||||
"requirements": ["pyenphase==2.4.6"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_enphase-envoy._tcp.local."
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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%]"
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,22 +40,32 @@
|
||||
"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"
|
||||
},
|
||||
"token_auth": {
|
||||
"data": {
|
||||
"region": "[%key:component::growatt_server::config::step::password_auth::data::region%]",
|
||||
"token": "API Token"
|
||||
"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"
|
||||
},
|
||||
"user": {
|
||||
"description": "Note: API Token authentication is currently only supported for MIN/TLX devices. For other device types, please use Username & Password authentication.",
|
||||
"description": "Note: Token authentication is currently only supported for MIN/TLX devices. For other device types, please use username/password authentication.",
|
||||
"menu_options": {
|
||||
"password_auth": "Username & Password",
|
||||
"token_auth": "API Token (MIN/TLX only)"
|
||||
"password_auth": "Username/password",
|
||||
"token_auth": "API token (MIN/TLX only)"
|
||||
},
|
||||
"title": "Choose authentication method"
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from .const import LOGGER
|
||||
from .coordinator import IntelliClimaConfigEntry, IntelliClimaCoordinator
|
||||
|
||||
PLATFORMS = [Platform.FAN, Platform.SELECT]
|
||||
PLATFORMS = [Platform.FAN, Platform.SELECT, Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
|
||||
101
homeassistant/components/intelliclima/sensor.py
Normal file
101
homeassistant/components/intelliclima/sensor.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Sensor platform for IntelliClima VMC."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pyintelliclima.intelliclima_types import IntelliClimaECO
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
PERCENTAGE,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import IntelliClimaConfigEntry, IntelliClimaCoordinator
|
||||
from .entity import IntelliClimaECOEntity
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class IntelliClimaSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes a sensor entity."""
|
||||
|
||||
value_fn: Callable[[IntelliClimaECO], int | float | str | None]
|
||||
|
||||
|
||||
INTELLICLIMA_SENSORS: tuple[IntelliClimaSensorEntityDescription, ...] = (
|
||||
IntelliClimaSensorEntityDescription(
|
||||
key="temperature",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
value_fn=lambda device_data: float(device_data.tamb),
|
||||
),
|
||||
IntelliClimaSensorEntityDescription(
|
||||
key="humidity",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
value_fn=lambda device_data: float(device_data.rh),
|
||||
),
|
||||
IntelliClimaSensorEntityDescription(
|
||||
key="voc",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
||||
value_fn=lambda device_data: float(device_data.voc_state),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: IntelliClimaConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a IntelliClima Sensors."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
entities: list[IntelliClimaSensor] = [
|
||||
IntelliClimaSensor(
|
||||
coordinator=coordinator, device=ecocomfort2, description=description
|
||||
)
|
||||
for ecocomfort2 in coordinator.data.ecocomfort2_devices.values()
|
||||
for description in INTELLICLIMA_SENSORS
|
||||
]
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class IntelliClimaSensor(IntelliClimaECOEntity, SensorEntity):
|
||||
"""Extends IntelliClimaEntity with Sensor specific logic."""
|
||||
|
||||
entity_description: IntelliClimaSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: IntelliClimaCoordinator,
|
||||
device: IntelliClimaECO,
|
||||
description: IntelliClimaSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Class initializer."""
|
||||
super().__init__(coordinator, device)
|
||||
|
||||
self.entity_description = description
|
||||
|
||||
self._attr_unique_id = f"{device.id}_{description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | float | str | None:
|
||||
"""Use this to get the correct value."""
|
||||
return self.entity_description.value_fn(self._device_data)
|
||||
@@ -12,5 +12,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["intellifire4py"],
|
||||
"requirements": ["intellifire4py==4.3.1"]
|
||||
"requirements": ["intellifire4py==4.4.0"]
|
||||
}
|
||||
|
||||
17
homeassistant/components/motion/__init__.py
Normal file
17
homeassistant/components/motion/__init__.py
Normal 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
|
||||
10
homeassistant/components/motion/icons.json
Normal file
10
homeassistant/components/motion/icons.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"triggers": {
|
||||
"cleared": {
|
||||
"trigger": "mdi:motion-sensor-off"
|
||||
},
|
||||
"detected": {
|
||||
"trigger": "mdi:motion-sensor"
|
||||
}
|
||||
}
|
||||
}
|
||||
8
homeassistant/components/motion/manifest.json
Normal file
8
homeassistant/components/motion/manifest.json
Normal 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"
|
||||
}
|
||||
38
homeassistant/components/motion/strings.json
Normal file
38
homeassistant/components/motion/strings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
53
homeassistant/components/motion/trigger.py
Normal file
53
homeassistant/components/motion/trigger.py
Normal 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
|
||||
25
homeassistant/components/motion/triggers.yaml
Normal file
25
homeassistant/components/motion/triggers.yaml
Normal 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
|
||||
@@ -140,7 +140,6 @@ MQTT_ATTRIBUTES_BLOCKED = {
|
||||
"entity_registry_enabled_default",
|
||||
"extra_state_attributes",
|
||||
"force_update",
|
||||
"group_entities",
|
||||
"icon",
|
||||
"friendly_name",
|
||||
"should_poll",
|
||||
|
||||
17
homeassistant/components/occupancy/__init__.py
Normal file
17
homeassistant/components/occupancy/__init__.py
Normal 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
|
||||
10
homeassistant/components/occupancy/icons.json
Normal file
10
homeassistant/components/occupancy/icons.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"triggers": {
|
||||
"cleared": {
|
||||
"trigger": "mdi:home-outline"
|
||||
},
|
||||
"detected": {
|
||||
"trigger": "mdi:home-account"
|
||||
}
|
||||
}
|
||||
}
|
||||
8
homeassistant/components/occupancy/manifest.json
Normal file
8
homeassistant/components/occupancy/manifest.json
Normal 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"
|
||||
}
|
||||
38
homeassistant/components/occupancy/strings.json
Normal file
38
homeassistant/components/occupancy/strings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
57
homeassistant/components/occupancy/trigger.py
Normal file
57
homeassistant/components/occupancy/trigger.py
Normal 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
|
||||
25
homeassistant/components/occupancy/triggers.yaml
Normal file
25
homeassistant/components/occupancy/triggers.yaml
Normal 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
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/portainer",
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pyportainer==1.0.33"]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Any
|
||||
|
||||
from prana_local_api_client.exceptions import PranaApiCommunicationError
|
||||
from prana_local_api_client.models.prana_device_info import PranaDeviceInfo
|
||||
from prana_local_api_client.prana_api_client import PranaLocalApiClient
|
||||
from prana_local_api_client.prana_local_api_client import PranaLocalApiClient
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
|
||||
@@ -12,7 +12,7 @@ from prana_local_api_client.exceptions import (
|
||||
)
|
||||
from prana_local_api_client.models.prana_device_info import PranaDeviceInfo
|
||||
from prana_local_api_client.models.prana_state import PranaState
|
||||
from prana_local_api_client.prana_api_client import PranaLocalApiClient
|
||||
from prana_local_api_client.prana_local_api_client import PranaLocalApiClient
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["prana-api-client==0.10.0"],
|
||||
"requirements": ["prana-api-client==0.12.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_prana._tcp.local."
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
"period": {
|
||||
"default": "mdi:sine-wave"
|
||||
},
|
||||
"spring_status": {
|
||||
"default": "mdi:feather"
|
||||
},
|
||||
"swing_count": {
|
||||
"default": "mdi:counter"
|
||||
},
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pysmarlaapi", "pysignalr"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pysmarlaapi==1.0.1"]
|
||||
"requirements": ["pysmarlaapi==1.0.2"]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -12,6 +12,7 @@ PLATFORMS = [
|
||||
Platform.CLIMATE,
|
||||
Platform.FAN,
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.WATER_HEATER,
|
||||
]
|
||||
|
||||
117
homeassistant/components/vicare/select.py
Normal file
117
homeassistant/components/vicare/select.py
Normal 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()
|
||||
@@ -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"
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["waterfurnace"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["waterfurnace==1.5.1"]
|
||||
"requirements": ["waterfurnace==1.6.2"]
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aiowebdav2"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["aiowebdav2==0.6.1"]
|
||||
"requirements": ["aiowebdav2==0.6.2"]
|
||||
}
|
||||
|
||||
17
homeassistant/components/window/__init__.py
Normal file
17
homeassistant/components/window/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""Integration for window 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 = "window"
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
__all__ = []
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the component."""
|
||||
return True
|
||||
10
homeassistant/components/window/icons.json
Normal file
10
homeassistant/components/window/icons.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"triggers": {
|
||||
"closed": {
|
||||
"trigger": "mdi:window-closed"
|
||||
},
|
||||
"opened": {
|
||||
"trigger": "mdi:window-open"
|
||||
}
|
||||
}
|
||||
}
|
||||
8
homeassistant/components/window/manifest.json
Normal file
8
homeassistant/components/window/manifest.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"domain": "window",
|
||||
"name": "Window",
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/window",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal"
|
||||
}
|
||||
38
homeassistant/components/window/strings.json
Normal file
38
homeassistant/components/window/strings.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"common": {
|
||||
"trigger_behavior_description": "The behavior of the targeted windows to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"selector": {
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
"first": "First",
|
||||
"last": "Last"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Window",
|
||||
"triggers": {
|
||||
"closed": {
|
||||
"description": "Triggers after one or more windows close.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::window::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::window::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Window closed"
|
||||
},
|
||||
"opened": {
|
||||
"description": "Triggers after one or more windows open.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::window::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::window::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Window opened"
|
||||
}
|
||||
}
|
||||
}
|
||||
36
homeassistant/components/window/trigger.py
Normal file
36
homeassistant/components/window/trigger.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Provides triggers for windows."""
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
DOMAIN as BINARY_SENSOR_DOMAIN,
|
||||
BinarySensorDeviceClass,
|
||||
)
|
||||
from homeassistant.components.cover import (
|
||||
DOMAIN as COVER_DOMAIN,
|
||||
CoverDeviceClass,
|
||||
make_cover_closed_trigger,
|
||||
make_cover_opened_trigger,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.trigger import Trigger
|
||||
|
||||
DEVICE_CLASSES_WINDOW: dict[str, str] = {
|
||||
BINARY_SENSOR_DOMAIN: BinarySensorDeviceClass.WINDOW,
|
||||
COVER_DOMAIN: CoverDeviceClass.WINDOW,
|
||||
}
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"opened": make_cover_opened_trigger(
|
||||
device_classes=DEVICE_CLASSES_WINDOW,
|
||||
domains={BINARY_SENSOR_DOMAIN, COVER_DOMAIN},
|
||||
),
|
||||
"closed": make_cover_closed_trigger(
|
||||
device_classes=DEVICE_CLASSES_WINDOW,
|
||||
domains={BINARY_SENSOR_DOMAIN, COVER_DOMAIN},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for windows."""
|
||||
return TRIGGERS
|
||||
29
homeassistant/components/window/triggers.yaml
Normal file
29
homeassistant/components/window/triggers.yaml
Normal file
@@ -0,0 +1,29 @@
|
||||
.trigger_common_fields: &trigger_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: trigger_behavior
|
||||
options:
|
||||
- first
|
||||
- last
|
||||
- any
|
||||
|
||||
closed:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: binary_sensor
|
||||
device_class: window
|
||||
- domain: cover
|
||||
device_class: window
|
||||
|
||||
opened:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: binary_sensor
|
||||
device_class: window
|
||||
- domain: cover
|
||||
device_class: window
|
||||
@@ -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
|
||||
|
||||
@@ -9,7 +9,6 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from awesomeversion import AwesomeVersion
|
||||
import voluptuous as vol
|
||||
from zwave_js_server.client import Client as ZwaveClient
|
||||
from zwave_js_server.const import CommandClass, RemoveNodeReason
|
||||
from zwave_js_server.exceptions import (
|
||||
@@ -94,7 +93,6 @@ from .const import (
|
||||
CONF_ADDON_S2_UNAUTHENTICATED_KEY,
|
||||
CONF_ADDON_SOCKET,
|
||||
CONF_DATA_COLLECTION_OPTED_IN,
|
||||
CONF_INSTALLER_MODE,
|
||||
CONF_INTEGRATION_CREATED_ADDON,
|
||||
CONF_KEEP_OLD_DEVICES,
|
||||
CONF_LR_S2_ACCESS_CONTROL_KEY,
|
||||
@@ -138,16 +136,8 @@ from .services import async_setup_services
|
||||
CONNECT_TIMEOUT = 10
|
||||
DRIVER_READY_TIMEOUT = 60
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
DOMAIN: vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_INSTALLER_MODE, default=False): cv.boolean,
|
||||
}
|
||||
)
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
MIN_CONTROLLER_FIRMWARE_SDK_VERSION = AwesomeVersion("6.50.0")
|
||||
|
||||
PLATFORMS = [
|
||||
@@ -171,7 +161,6 @@ PLATFORMS = [
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Z-Wave JS component."""
|
||||
hass.data[DOMAIN] = config.get(DOMAIN, {})
|
||||
for entry in hass.config_entries.async_entries(DOMAIN):
|
||||
if not isinstance(entry.unique_id, str):
|
||||
hass.config_entries.async_update_entry(
|
||||
|
||||
@@ -84,7 +84,6 @@ from .const import (
|
||||
ATTR_PARAMETERS,
|
||||
ATTR_WAIT_FOR_RESULT,
|
||||
CONF_DATA_COLLECTION_OPTED_IN,
|
||||
CONF_INSTALLER_MODE,
|
||||
DOMAIN,
|
||||
EVENT_DEVICE_ADDED_TO_REGISTRY,
|
||||
LOGGER,
|
||||
@@ -476,7 +475,6 @@ def async_register_api(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, websocket_hard_reset_controller)
|
||||
websocket_api.async_register_command(hass, websocket_node_capabilities)
|
||||
websocket_api.async_register_command(hass, websocket_invoke_cc_api)
|
||||
websocket_api.async_register_command(hass, websocket_get_integration_settings)
|
||||
websocket_api.async_register_command(hass, websocket_backup_nvm)
|
||||
websocket_api.async_register_command(hass, websocket_restore_nvm)
|
||||
hass.http.register_view(FirmwareUploadView(dr.async_get(hass)))
|
||||
@@ -2965,28 +2963,6 @@ async def websocket_invoke_cc_api(
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required(TYPE): "zwave_js/get_integration_settings",
|
||||
}
|
||||
)
|
||||
def websocket_get_integration_settings(
|
||||
hass: HomeAssistant,
|
||||
connection: ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Get Z-Wave JS integration wide configuration."""
|
||||
connection.send_result(
|
||||
msg[ID],
|
||||
{
|
||||
# list explicitly to avoid leaking other keys and to set default
|
||||
CONF_INSTALLER_MODE: hass.data[DOMAIN].get(CONF_INSTALLER_MODE, False),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
|
||||
@@ -25,7 +25,6 @@ CONF_ADDON_S2_UNAUTHENTICATED_KEY = "s2_unauthenticated_key"
|
||||
CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY = "lr_s2_access_control_key"
|
||||
CONF_ADDON_LR_S2_AUTHENTICATED_KEY = "lr_s2_authenticated_key"
|
||||
CONF_ADDON_SOCKET = "socket"
|
||||
CONF_INSTALLER_MODE = "installer_mode"
|
||||
CONF_INTEGRATION_CREATED_ADDON = "integration_created_addon"
|
||||
CONF_KEEP_OLD_DEVICES = "keep_old_devices"
|
||||
CONF_NETWORK_KEY = "network_key"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -782,6 +782,8 @@ async def entity_service_call(
|
||||
all_referenced,
|
||||
)
|
||||
|
||||
entity_candidates = [e for e in entity_candidates if e.available]
|
||||
|
||||
if not target_all_entities:
|
||||
assert referenced is not None
|
||||
# Only report on explicit referenced entities
|
||||
@@ -792,9 +794,6 @@ async def entity_service_call(
|
||||
|
||||
entities: list[Entity] = []
|
||||
for entity in entity_candidates:
|
||||
if not entity.available:
|
||||
continue
|
||||
|
||||
# Skip entities that don't have the required device class.
|
||||
if (
|
||||
entity_device_classes is not None
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
2
requirements.txt
generated
@@ -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
|
||||
|
||||
18
requirements_all.txt
generated
18
requirements_all.txt
generated
@@ -443,7 +443,7 @@ aiowaqi==3.1.0
|
||||
aiowatttime==0.1.1
|
||||
|
||||
# homeassistant.components.webdav
|
||||
aiowebdav2==0.6.1
|
||||
aiowebdav2==0.6.2
|
||||
|
||||
# homeassistant.components.webostv
|
||||
aiowebostv==0.7.5
|
||||
@@ -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
|
||||
@@ -1322,7 +1322,7 @@ inkbird-ble==1.1.1
|
||||
insteon-frontend-home-assistant==0.6.1
|
||||
|
||||
# homeassistant.components.intellifire
|
||||
intellifire4py==4.3.1
|
||||
intellifire4py==4.4.0
|
||||
|
||||
# homeassistant.components.iometer
|
||||
iometer==0.4.0
|
||||
@@ -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
|
||||
@@ -1794,7 +1794,7 @@ poolsense==0.0.8
|
||||
powerfox==2.1.1
|
||||
|
||||
# homeassistant.components.prana
|
||||
prana-api-client==0.10.0
|
||||
prana-api-client==0.12.0
|
||||
|
||||
# homeassistant.components.reddit
|
||||
praw==7.5.0
|
||||
@@ -2071,7 +2071,7 @@ pyegps==0.2.5
|
||||
pyemoncms==0.1.3
|
||||
|
||||
# homeassistant.components.enphase_envoy
|
||||
pyenphase==2.4.5
|
||||
pyenphase==2.4.6
|
||||
|
||||
# homeassistant.components.envisalink
|
||||
pyenvisalink==4.7
|
||||
@@ -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
|
||||
@@ -3247,7 +3247,7 @@ wallbox==0.9.0
|
||||
watchdog==6.0.0
|
||||
|
||||
# homeassistant.components.waterfurnace
|
||||
waterfurnace==1.5.1
|
||||
waterfurnace==1.6.2
|
||||
|
||||
# homeassistant.components.watergate
|
||||
watergate-local-api==2025.1.0
|
||||
|
||||
18
requirements_test_all.txt
generated
18
requirements_test_all.txt
generated
@@ -428,7 +428,7 @@ aiowaqi==3.1.0
|
||||
aiowatttime==0.1.1
|
||||
|
||||
# homeassistant.components.webdav
|
||||
aiowebdav2==0.6.1
|
||||
aiowebdav2==0.6.2
|
||||
|
||||
# homeassistant.components.webostv
|
||||
aiowebostv==0.7.5
|
||||
@@ -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
|
||||
@@ -1171,7 +1171,7 @@ inkbird-ble==1.1.1
|
||||
insteon-frontend-home-assistant==0.6.1
|
||||
|
||||
# homeassistant.components.intellifire
|
||||
intellifire4py==4.3.1
|
||||
intellifire4py==4.4.0
|
||||
|
||||
# homeassistant.components.iometer
|
||||
iometer==0.4.0
|
||||
@@ -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
|
||||
@@ -1552,7 +1552,7 @@ poolsense==0.0.8
|
||||
powerfox==2.1.1
|
||||
|
||||
# homeassistant.components.prana
|
||||
prana-api-client==0.10.0
|
||||
prana-api-client==0.12.0
|
||||
|
||||
# homeassistant.components.reddit
|
||||
praw==7.5.0
|
||||
@@ -1775,7 +1775,7 @@ pyegps==0.2.5
|
||||
pyemoncms==0.1.3
|
||||
|
||||
# homeassistant.components.enphase_envoy
|
||||
pyenphase==2.4.5
|
||||
pyenphase==2.4.6
|
||||
|
||||
# homeassistant.components.everlights
|
||||
pyeverlights==0.1.0
|
||||
@@ -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
|
||||
@@ -2735,7 +2735,7 @@ wallbox==0.9.0
|
||||
watchdog==6.0.0
|
||||
|
||||
# homeassistant.components.waterfurnace
|
||||
waterfurnace==1.5.1
|
||||
waterfurnace==1.6.2
|
||||
|
||||
# homeassistant.components.watergate
|
||||
watergate-local-api==2025.1.0
|
||||
|
||||
@@ -102,7 +102,9 @@ NO_IOT_CLASS = [
|
||||
"logger",
|
||||
"lovelace",
|
||||
"media_source",
|
||||
"motion",
|
||||
"my",
|
||||
"occupancy",
|
||||
"onboarding",
|
||||
"panel_custom",
|
||||
"plant",
|
||||
@@ -123,6 +125,7 @@ NO_IOT_CLASS = [
|
||||
"web_rtc",
|
||||
"webhook",
|
||||
"websocket_api",
|
||||
"window",
|
||||
"zone",
|
||||
]
|
||||
|
||||
|
||||
@@ -2137,7 +2137,9 @@ NO_QUALITY_SCALE = [
|
||||
"logger",
|
||||
"lovelace",
|
||||
"media_source",
|
||||
"motion",
|
||||
"my",
|
||||
"occupancy",
|
||||
"onboarding",
|
||||
"panel_custom",
|
||||
"proxy",
|
||||
@@ -2157,6 +2159,7 @@ NO_QUALITY_SCALE = [
|
||||
"web_rtc",
|
||||
"webhook",
|
||||
"websocket_api",
|
||||
"window",
|
||||
"zone",
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
105
tests/components/arcam_fmj/snapshots/test_media_player.ambr
Normal file
105
tests/components/arcam_fmj/snapshots/test_media_player.ambr
Normal 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',
|
||||
})
|
||||
# ---
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -604,6 +604,14 @@ async def test_initiate_backup(
|
||||
result = await ws_client.receive_json()
|
||||
while "uploaded_bytes" in result["event"]:
|
||||
result = await ws_client.receive_json()
|
||||
assert result["event"] == {
|
||||
"manager_state": BackupManagerState.CREATE_BACKUP,
|
||||
"reason": None,
|
||||
"stage": CreateBackupStage.CLEANING_UP,
|
||||
"state": CreateBackupState.IN_PROGRESS,
|
||||
}
|
||||
|
||||
result = await ws_client.receive_json()
|
||||
assert result["event"] == {
|
||||
"manager_state": BackupManagerState.CREATE_BACKUP,
|
||||
"reason": None,
|
||||
@@ -854,6 +862,14 @@ async def test_initiate_backup_with_agent_error(
|
||||
result = await ws_client.receive_json()
|
||||
while "uploaded_bytes" in result["event"]:
|
||||
result = await ws_client.receive_json()
|
||||
assert result["event"] == {
|
||||
"manager_state": BackupManagerState.CREATE_BACKUP,
|
||||
"reason": None,
|
||||
"stage": CreateBackupStage.CLEANING_UP,
|
||||
"state": CreateBackupState.IN_PROGRESS,
|
||||
}
|
||||
|
||||
result = await ws_client.receive_json()
|
||||
assert result["event"] == {
|
||||
"manager_state": BackupManagerState.CREATE_BACKUP,
|
||||
"reason": "upload_failed",
|
||||
@@ -3549,6 +3565,14 @@ async def test_initiate_backup_per_agent_encryption(
|
||||
result = await ws_client.receive_json()
|
||||
while "uploaded_bytes" in result["event"]:
|
||||
result = await ws_client.receive_json()
|
||||
assert result["event"] == {
|
||||
"manager_state": BackupManagerState.CREATE_BACKUP,
|
||||
"reason": None,
|
||||
"stage": CreateBackupStage.CLEANING_UP,
|
||||
"state": CreateBackupState.IN_PROGRESS,
|
||||
}
|
||||
|
||||
result = await ws_client.receive_json()
|
||||
assert result["event"] == {
|
||||
"manager_state": BackupManagerState.CREATE_BACKUP,
|
||||
"reason": None,
|
||||
@@ -3783,7 +3807,7 @@ async def test_upload_progress_event(
|
||||
result = await ws_client.receive_json()
|
||||
assert result["event"]["stage"] == CreateBackupStage.UPLOAD_TO_AGENTS
|
||||
|
||||
# Collect all upload progress events until the final state event
|
||||
# Collect all upload progress events until the finishing backup stage event
|
||||
progress_events = []
|
||||
result = await ws_client.receive_json()
|
||||
while "uploaded_bytes" in result["event"]:
|
||||
@@ -3801,6 +3825,9 @@ async def test_upload_progress_event(
|
||||
assert len(local_progress) == 1
|
||||
assert local_progress[0]["uploaded_bytes"] == local_progress[0]["total_bytes"]
|
||||
|
||||
assert result["event"]["stage"] == CreateBackupStage.CLEANING_UP
|
||||
|
||||
result = await ws_client.receive_json()
|
||||
assert result["event"]["state"] == CreateBackupState.COMPLETED
|
||||
|
||||
result = await ws_client.receive_json()
|
||||
|
||||
@@ -66,6 +66,7 @@ async def cloud_fixture() -> AsyncGenerator[MagicMock]:
|
||||
certificate_status=None,
|
||||
instance_domain=None,
|
||||
is_connected=False,
|
||||
latency_by_location={},
|
||||
)
|
||||
mock_cloud.auth = MagicMock(spec=CognitoAuth)
|
||||
mock_cloud.iot = MagicMock(
|
||||
|
||||
@@ -87,6 +87,13 @@
|
||||
|
||||
</details>
|
||||
|
||||
## Latency by location
|
||||
|
||||
Location | Latency (ms)
|
||||
--- | ---
|
||||
Earth | 13.37
|
||||
Moon | N/A
|
||||
|
||||
## Installed packages
|
||||
|
||||
<details><summary>Installed packages</summary>
|
||||
|
||||
@@ -1907,6 +1907,10 @@ async def test_download_support_package(
|
||||
|
||||
cloud.remote.snitun_server = "us-west-1"
|
||||
cloud.remote.certificate_status = CertificateStatus.READY
|
||||
cloud.remote.latency_by_location = {
|
||||
"Earth": {"avg": 13.37},
|
||||
"Moon": {"avg": None},
|
||||
}
|
||||
cloud.expiration_date = dt_util.parse_datetime("2025-01-17T11:19:31.0+00:00")
|
||||
|
||||
await cloud.client.async_system_message({"region": "xx-earth-616"})
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -984,6 +984,14 @@ async def test_reader_writer_create(
|
||||
response = await client.receive_json()
|
||||
while "uploaded_bytes" in response["event"]:
|
||||
response = await client.receive_json()
|
||||
assert response["event"] == {
|
||||
"manager_state": "create_backup",
|
||||
"reason": None,
|
||||
"stage": "cleaning_up",
|
||||
"state": "in_progress",
|
||||
}
|
||||
|
||||
response = await client.receive_json()
|
||||
assert response["event"] == {
|
||||
"manager_state": "create_backup",
|
||||
"reason": None,
|
||||
@@ -1101,6 +1109,14 @@ async def test_reader_writer_create_addon_folder_error(
|
||||
response = await client.receive_json()
|
||||
while "uploaded_bytes" in response["event"]:
|
||||
response = await client.receive_json()
|
||||
assert response["event"] == {
|
||||
"manager_state": "create_backup",
|
||||
"reason": None,
|
||||
"stage": "cleaning_up",
|
||||
"state": "in_progress",
|
||||
}
|
||||
|
||||
response = await client.receive_json()
|
||||
assert response["event"] == {
|
||||
"manager_state": "create_backup",
|
||||
"reason": None,
|
||||
@@ -1221,6 +1237,14 @@ async def test_reader_writer_create_report_progress(
|
||||
response = await client.receive_json()
|
||||
while "uploaded_bytes" in response["event"]:
|
||||
response = await client.receive_json()
|
||||
assert response["event"] == {
|
||||
"manager_state": "create_backup",
|
||||
"reason": None,
|
||||
"stage": "cleaning_up",
|
||||
"state": "in_progress",
|
||||
}
|
||||
|
||||
response = await client.receive_json()
|
||||
assert response["event"] == {
|
||||
"manager_state": "create_backup",
|
||||
"reason": None,
|
||||
@@ -1286,6 +1310,14 @@ async def test_reader_writer_create_job_done(
|
||||
response = await client.receive_json()
|
||||
while "uploaded_bytes" in response["event"]:
|
||||
response = await client.receive_json()
|
||||
assert response["event"] == {
|
||||
"manager_state": "create_backup",
|
||||
"reason": None,
|
||||
"stage": "cleaning_up",
|
||||
"state": "in_progress",
|
||||
}
|
||||
|
||||
response = await client.receive_json()
|
||||
assert response["event"] == {
|
||||
"manager_state": "create_backup",
|
||||
"reason": None,
|
||||
@@ -1552,6 +1584,14 @@ async def test_reader_writer_create_per_agent_encryption(
|
||||
response = await client.receive_json()
|
||||
while "uploaded_bytes" in response["event"]:
|
||||
response = await client.receive_json()
|
||||
assert response["event"] == {
|
||||
"manager_state": "create_backup",
|
||||
"reason": None,
|
||||
"stage": "cleaning_up",
|
||||
"state": "in_progress",
|
||||
}
|
||||
|
||||
response = await client.receive_json()
|
||||
assert response["event"] == {
|
||||
"manager_state": "create_backup",
|
||||
"reason": None,
|
||||
@@ -1728,10 +1768,51 @@ async def test_reader_writer_create_missing_reference_error(
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("hassio_client", "setup_backup_integration")
|
||||
@pytest.mark.parametrize("exception", [SupervisorError("Boom!"), Exception("Boom!")])
|
||||
@pytest.mark.parametrize(
|
||||
("method", "download_call_count", "remove_call_count"),
|
||||
[("download_backup", 1, 1), ("remove_backup", 1, 1)],
|
||||
(
|
||||
"exception",
|
||||
"method",
|
||||
"download_call_count",
|
||||
"remove_call_count",
|
||||
"expected_events_before_failed",
|
||||
),
|
||||
[
|
||||
(
|
||||
SupervisorError("Boom!"),
|
||||
"download_backup",
|
||||
1,
|
||||
1,
|
||||
[],
|
||||
),
|
||||
(
|
||||
Exception("Boom!"),
|
||||
"download_backup",
|
||||
1,
|
||||
1,
|
||||
[
|
||||
{
|
||||
"manager_state": "create_backup",
|
||||
"reason": None,
|
||||
"stage": "cleaning_up",
|
||||
"state": "in_progress",
|
||||
}
|
||||
],
|
||||
),
|
||||
(
|
||||
SupervisorError("Boom!"),
|
||||
"remove_backup",
|
||||
1,
|
||||
1,
|
||||
[],
|
||||
),
|
||||
(
|
||||
Exception("Boom!"),
|
||||
"remove_backup",
|
||||
1,
|
||||
1,
|
||||
[],
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_reader_writer_create_download_remove_error(
|
||||
hass: HomeAssistant,
|
||||
@@ -1741,6 +1822,7 @@ async def test_reader_writer_create_download_remove_error(
|
||||
method: str,
|
||||
download_call_count: int,
|
||||
remove_call_count: int,
|
||||
expected_events_before_failed: list[dict[str, str]],
|
||||
) -> None:
|
||||
"""Test download and remove error when generating a backup."""
|
||||
client = await hass_ws_client(hass)
|
||||
@@ -1807,6 +1889,9 @@ async def test_reader_writer_create_download_remove_error(
|
||||
response = await client.receive_json()
|
||||
while "uploaded_bytes" in response["event"]:
|
||||
response = await client.receive_json()
|
||||
for expected_event in expected_events_before_failed:
|
||||
assert response["event"] == expected_event
|
||||
response = await client.receive_json()
|
||||
assert response["event"] == {
|
||||
"manager_state": "create_backup",
|
||||
"reason": "upload_failed",
|
||||
@@ -1974,6 +2059,14 @@ async def test_reader_writer_create_remote_backup(
|
||||
response = await client.receive_json()
|
||||
while "uploaded_bytes" in response["event"]:
|
||||
response = await client.receive_json()
|
||||
assert response["event"] == {
|
||||
"manager_state": "create_backup",
|
||||
"reason": None,
|
||||
"stage": "cleaning_up",
|
||||
"state": "in_progress",
|
||||
}
|
||||
|
||||
response = await client.receive_json()
|
||||
assert response["event"] == {
|
||||
"manager_state": "create_backup",
|
||||
"reason": None,
|
||||
|
||||
205
tests/components/intelliclima/snapshots/test_sensor.ambr
Normal file
205
tests/components/intelliclima/snapshots/test_sensor.ambr
Normal file
@@ -0,0 +1,205 @@
|
||||
# serializer version: 1
|
||||
# name: test_all_sensor_entities.6
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
tuple(
|
||||
'bluetooth',
|
||||
'00:11:22:33:44:55',
|
||||
),
|
||||
tuple(
|
||||
'mac',
|
||||
'00:11:22:33:44:55',
|
||||
),
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'intelliclima',
|
||||
'56789',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Fantini Cosmi',
|
||||
'model': 'ECOCOMFORT 2.0',
|
||||
'model_id': None,
|
||||
'name': 'Test VMC',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': '11223344',
|
||||
'sw_version': '0.6.8',
|
||||
'via_device_id': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_sensor_entities[sensor.test_vmc_humidity-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'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_vmc_humidity',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Humidity',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.HUMIDITY: 'humidity'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Humidity',
|
||||
'platform': 'intelliclima',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': '56789_humidity',
|
||||
'unit_of_measurement': '%',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_sensor_entities[sensor.test_vmc_humidity-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'humidity',
|
||||
'friendly_name': 'Test VMC Humidity',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': '%',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.test_vmc_humidity',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '65.0',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_sensor_entities[sensor.test_vmc_temperature-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'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_vmc_temperature',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Temperature',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 1,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Temperature',
|
||||
'platform': 'intelliclima',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': '56789_temperature',
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_sensor_entities[sensor.test_vmc_temperature-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'temperature',
|
||||
'friendly_name': 'Test VMC Temperature',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.test_vmc_temperature',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '16.2',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_sensor_entities[sensor.test_vmc_volatile_organic_compounds_parts-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'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_vmc_volatile_organic_compounds_parts',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Volatile organic compounds parts',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: 'volatile_organic_compounds_parts'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Volatile organic compounds parts',
|
||||
'platform': 'intelliclima',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': '56789_voc',
|
||||
'unit_of_measurement': 'ppm',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_sensor_entities[sensor.test_vmc_volatile_organic_compounds_parts-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'volatile_organic_compounds_parts',
|
||||
'friendly_name': 'Test VMC Volatile organic compounds parts',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': 'ppm',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.test_vmc_volatile_organic_compounds_parts',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '89.0',
|
||||
})
|
||||
# ---
|
||||
58
tests/components/intelliclima/test_sensor.py
Normal file
58
tests/components/intelliclima/test_sensor.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Test IntelliClima Sensors."""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
|
||||
from . import setup_integration
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def setup_intelliclima_sensor_only(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_cloud_interface: AsyncMock,
|
||||
) -> AsyncGenerator[None]:
|
||||
"""Set up IntelliClima integration with only the sensor platform."""
|
||||
with (
|
||||
patch("homeassistant.components.intelliclima.PLATFORMS", [Platform.SENSOR]),
|
||||
):
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
# Let tests run against this initialized state
|
||||
yield
|
||||
|
||||
|
||||
async def test_all_sensor_entities(
|
||||
hass: HomeAssistant,
|
||||
snapshot: SnapshotAssertion,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
mock_cloud_interface: AsyncMock,
|
||||
) -> None:
|
||||
"""Test all entities."""
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
# There should be exactly three sensor entities
|
||||
sensor_entries = [
|
||||
entry
|
||||
for entry in entity_registry.entities.values()
|
||||
if entry.platform == "intelliclima" and entry.domain == SENSOR_DOMAIN
|
||||
]
|
||||
assert len(sensor_entries) == 3
|
||||
|
||||
entity_entry = sensor_entries[0]
|
||||
# Device should exist and match snapshot
|
||||
assert entity_entry.device_id
|
||||
assert (device_entry := device_registry.async_get(entity_entry.device_id))
|
||||
assert device_entry == snapshot
|
||||
1
tests/components/motion/__init__.py
Normal file
1
tests/components/motion/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for the motion integration."""
|
||||
327
tests/components/motion/test_trigger.py
Normal file
327
tests/components/motion/test_trigger.py
Normal 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
|
||||
1
tests/components/occupancy/__init__.py
Normal file
1
tests/components/occupancy/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for the occupancy integration."""
|
||||
327
tests/components/occupancy/test_trigger.py
Normal file
327
tests/components/occupancy/test_trigger.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user