mirror of
https://github.com/home-assistant/core.git
synced 2026-03-13 14:32:07 +01:00
Compare commits
27 Commits
knx-name-r
...
state_attr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9537f7357 | ||
|
|
2eb65ab314 | ||
|
|
402a37b435 | ||
|
|
aa66e8ef0c | ||
|
|
f1a1e284b7 | ||
|
|
08594f4e0c | ||
|
|
8d810588f8 | ||
|
|
70faad15d5 | ||
|
|
d447843687 | ||
|
|
83b64e29fa | ||
|
|
4558a10e05 | ||
|
|
5ad9e81082 | ||
|
|
ba00a14772 | ||
|
|
49f4d07eeb | ||
|
|
5d271a0d30 | ||
|
|
474b683d3c | ||
|
|
d37106a360 | ||
|
|
57a33dd34d | ||
|
|
e115c90719 | ||
|
|
6ad3adf0c3 | ||
|
|
2a8d59be4c | ||
|
|
6e6e35bc3b | ||
|
|
795b4c8414 | ||
|
|
16389dc18e | ||
|
|
e7a1c8d001 | ||
|
|
4efb10dae1 | ||
|
|
f163576e78 |
10
.github/workflows/builder.yml
vendored
10
.github/workflows/builder.yml
vendored
@@ -196,7 +196,7 @@ jobs:
|
||||
echo "${GITHUB_SHA};${GITHUB_REF};${GITHUB_EVENT_NAME};${GITHUB_ACTOR}" > rootfs/OFFICIAL_IMAGE
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -328,7 +328,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -406,13 +406,13 @@ jobs:
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -585,7 +585,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
|
||||
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -609,7 +609,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Dependency review
|
||||
uses: actions/dependency-review-action@05fe4576374b728f0c523d6a13d64c25081e0803 # v4.8.3
|
||||
uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0
|
||||
with:
|
||||
license-check: false # We use our own license audit checks
|
||||
|
||||
|
||||
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@@ -577,6 +577,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/garages_amsterdam/ @klaasnicolaas
|
||||
/homeassistant/components/gardena_bluetooth/ @elupus
|
||||
/tests/components/gardena_bluetooth/ @elupus
|
||||
/homeassistant/components/gate/ @home-assistant/core
|
||||
/tests/components/gate/ @home-assistant/core
|
||||
/homeassistant/components/gdacs/ @exxamalte
|
||||
/tests/components/gdacs/ @exxamalte
|
||||
/homeassistant/components/generic/ @davet2001
|
||||
|
||||
@@ -243,6 +243,7 @@ DEFAULT_INTEGRATIONS = {
|
||||
# Integrations providing triggers and conditions for base platforms:
|
||||
"door",
|
||||
"garage_door",
|
||||
"gate",
|
||||
"humidity",
|
||||
}
|
||||
DEFAULT_INTEGRATIONS_RECOVERY_MODE = {
|
||||
|
||||
@@ -11,8 +11,11 @@ from arcam.fmj.state import State
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -50,6 +53,23 @@ class ArcamFmjCoordinator(DataUpdateCoordinator[None]):
|
||||
self.state = State(client, zone)
|
||||
self.last_update_success = False
|
||||
|
||||
name = config_entry.title
|
||||
unique_id = config_entry.unique_id or config_entry.entry_id
|
||||
unique_id_device = unique_id
|
||||
if zone != 1:
|
||||
unique_id_device += f"-{zone}"
|
||||
name += f" Zone {zone}"
|
||||
|
||||
self.device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, unique_id_device)},
|
||||
manufacturer="Arcam",
|
||||
model="Arcam FMJ AVR",
|
||||
name=name,
|
||||
)
|
||||
|
||||
if zone != 1:
|
||||
self.device_info["via_device"] = (DOMAIN, unique_id)
|
||||
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Fetch data for manual refresh."""
|
||||
try:
|
||||
|
||||
@@ -21,11 +21,10 @@ from homeassistant.components.media_player import (
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, EVENT_TURN_ON
|
||||
from .const import EVENT_TURN_ON
|
||||
from .coordinator import ArcamFmjConfigEntry, ArcamFmjCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -97,14 +96,7 @@ class ArcamFmj(CoordinatorEntity[ArcamFmjCoordinator], MediaPlayerEntity):
|
||||
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 = DeviceInfo(
|
||||
identifiers={
|
||||
(DOMAIN, uuid),
|
||||
},
|
||||
manufacturer="Arcam",
|
||||
model="Arcam FMJ AVR",
|
||||
name=device_name,
|
||||
)
|
||||
self._attr_device_info = coordinator.device_info
|
||||
|
||||
@property
|
||||
def state(self) -> MediaPlayerState:
|
||||
|
||||
@@ -137,7 +137,6 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
|
||||
_EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"alarm_control_panel",
|
||||
"assist_satellite",
|
||||
"binary_sensor",
|
||||
"button",
|
||||
"climate",
|
||||
"cover",
|
||||
@@ -145,6 +144,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"door",
|
||||
"fan",
|
||||
"garage_door",
|
||||
"gate",
|
||||
"humidifier",
|
||||
"humidity",
|
||||
"input_boolean",
|
||||
|
||||
@@ -32,6 +32,7 @@ from homeassistant.helpers import (
|
||||
issue_registry as ir,
|
||||
start,
|
||||
)
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.json import json_bytes
|
||||
from homeassistant.util import dt as dt_util, json as json_util
|
||||
from homeassistant.util.async_iterator import AsyncIteratorReader
|
||||
@@ -78,6 +79,8 @@ from .util import (
|
||||
validate_password_stream,
|
||||
)
|
||||
|
||||
UPLOAD_PROGRESS_DEBOUNCE_SECONDS = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True, slots=True)
|
||||
class NewBackup:
|
||||
@@ -590,23 +593,49 @@ class BackupManager:
|
||||
)
|
||||
agent = self.backup_agents[agent_id]
|
||||
|
||||
latest_uploaded_bytes = 0
|
||||
|
||||
@callback
|
||||
def on_upload_progress(*, bytes_uploaded: int, **kwargs: Any) -> None:
|
||||
"""Handle upload progress."""
|
||||
def _emit_upload_progress() -> None:
|
||||
"""Emit the latest upload progress event."""
|
||||
self.async_on_backup_event(
|
||||
UploadBackupEvent(
|
||||
manager_state=self.state,
|
||||
agent_id=agent_id,
|
||||
uploaded_bytes=bytes_uploaded,
|
||||
uploaded_bytes=latest_uploaded_bytes,
|
||||
total_bytes=_backup.size,
|
||||
)
|
||||
)
|
||||
|
||||
upload_progress_debouncer: Debouncer[None] = Debouncer(
|
||||
self.hass,
|
||||
LOGGER,
|
||||
cooldown=UPLOAD_PROGRESS_DEBOUNCE_SECONDS,
|
||||
immediate=True,
|
||||
function=_emit_upload_progress,
|
||||
)
|
||||
|
||||
@callback
|
||||
def on_upload_progress(*, bytes_uploaded: int, **kwargs: Any) -> None:
|
||||
"""Handle upload progress."""
|
||||
nonlocal latest_uploaded_bytes
|
||||
latest_uploaded_bytes = bytes_uploaded
|
||||
upload_progress_debouncer.async_schedule_call()
|
||||
|
||||
await agent.async_upload_backup(
|
||||
open_stream=open_stream_func,
|
||||
backup=_backup,
|
||||
on_progress=on_upload_progress,
|
||||
)
|
||||
upload_progress_debouncer.async_cancel()
|
||||
self.async_on_backup_event(
|
||||
UploadBackupEvent(
|
||||
manager_state=self.state,
|
||||
agent_id=agent_id,
|
||||
uploaded_bytes=_backup.size,
|
||||
total_bytes=_backup.size,
|
||||
)
|
||||
)
|
||||
if streamer:
|
||||
await streamer.wait()
|
||||
|
||||
|
||||
@@ -174,13 +174,5 @@
|
||||
"on": "mdi:window-open"
|
||||
}
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"occupancy_cleared": {
|
||||
"trigger": "mdi:home-outline"
|
||||
},
|
||||
"occupancy_detected": {
|
||||
"trigger": "mdi:home"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
{
|
||||
"common": {
|
||||
"trigger_behavior_description_occupancy": "The behavior of the targeted occupancy sensors to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"device_automation": {
|
||||
"condition_type": {
|
||||
"is_bat_low": "{entity_name} battery is low",
|
||||
@@ -321,36 +317,5 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
"first": "First",
|
||||
"last": "Last"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Binary sensor",
|
||||
"triggers": {
|
||||
"occupancy_cleared": {
|
||||
"description": "Triggers after one or more occupancy sensors stop detecting occupancy.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::binary_sensor::common::trigger_behavior_description_occupancy%]",
|
||||
"name": "[%key:component::binary_sensor::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Occupancy cleared"
|
||||
},
|
||||
"occupancy_detected": {
|
||||
"description": "Triggers after one or more occupancy sensors start detecting occupancy.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::binary_sensor::common::trigger_behavior_description_occupancy%]",
|
||||
"name": "[%key:component::binary_sensor::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Occupancy detected"
|
||||
}
|
||||
}
|
||||
"title": "Binary sensor"
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
"""Provides triggers for binary sensors."""
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity import get_device_class
|
||||
from homeassistant.helpers.trigger import EntityTargetStateTriggerBase, Trigger
|
||||
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
|
||||
|
||||
from . import DOMAIN, BinarySensorDeviceClass
|
||||
|
||||
|
||||
def get_device_class_or_undefined(
|
||||
hass: HomeAssistant, entity_id: str
|
||||
) -> str | None | UndefinedType:
|
||||
"""Get the device class of an entity or UNDEFINED if not found."""
|
||||
try:
|
||||
return get_device_class(hass, entity_id)
|
||||
except HomeAssistantError:
|
||||
return UNDEFINED
|
||||
|
||||
|
||||
class BinarySensorOnOffTrigger(EntityTargetStateTriggerBase):
|
||||
"""Class for binary sensor on/off triggers."""
|
||||
|
||||
_device_class: BinarySensorDeviceClass | None
|
||||
_domains = {DOMAIN}
|
||||
|
||||
def entity_filter(self, entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
entities = super().entity_filter(entities)
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if get_device_class_or_undefined(self._hass, entity_id)
|
||||
== self._device_class
|
||||
}
|
||||
|
||||
|
||||
def make_binary_sensor_trigger(
|
||||
device_class: BinarySensorDeviceClass | None,
|
||||
to_state: str,
|
||||
) -> type[BinarySensorOnOffTrigger]:
|
||||
"""Create an entity state trigger class."""
|
||||
|
||||
class CustomTrigger(BinarySensorOnOffTrigger):
|
||||
"""Trigger for entity state changes."""
|
||||
|
||||
_device_class = device_class
|
||||
_to_states = {to_state}
|
||||
|
||||
return CustomTrigger
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"occupancy_detected": make_binary_sensor_trigger(
|
||||
BinarySensorDeviceClass.OCCUPANCY, STATE_ON
|
||||
),
|
||||
"occupancy_cleared": make_binary_sensor_trigger(
|
||||
BinarySensorDeviceClass.OCCUPANCY, STATE_OFF
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for binary sensors."""
|
||||
return TRIGGERS
|
||||
@@ -10,6 +10,7 @@ from .const import DOMAIN
|
||||
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.CLIMATE,
|
||||
Platform.LIGHT,
|
||||
Platform.NUMBER,
|
||||
|
||||
101
homeassistant/components/eheimdigital/binary_sensor.py
Normal file
101
homeassistant/components/eheimdigital/binary_sensor.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""EHEIM Digital binary sensors."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from eheimdigital.device import EheimDigitalDevice
|
||||
from eheimdigital.reeflex import EheimDigitalReeflexUV
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
|
||||
from .entity import EheimDigitalEntity
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class EheimDigitalBinarySensorDescription[_DeviceT: EheimDigitalDevice](
|
||||
BinarySensorEntityDescription
|
||||
):
|
||||
"""Class describing EHEIM Digital binary sensor entities."""
|
||||
|
||||
value_fn: Callable[[_DeviceT], bool | None]
|
||||
|
||||
|
||||
REEFLEX_DESCRIPTIONS: tuple[
|
||||
EheimDigitalBinarySensorDescription[EheimDigitalReeflexUV], ...
|
||||
] = (
|
||||
EheimDigitalBinarySensorDescription[EheimDigitalReeflexUV](
|
||||
key="is_lighting",
|
||||
translation_key="is_lighting",
|
||||
value_fn=lambda device: device.is_lighting,
|
||||
device_class=BinarySensorDeviceClass.LIGHT,
|
||||
),
|
||||
EheimDigitalBinarySensorDescription[EheimDigitalReeflexUV](
|
||||
key="is_uvc_connected",
|
||||
translation_key="is_uvc_connected",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda device: device.is_uvc_connected,
|
||||
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: EheimDigitalConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the callbacks for the coordinator so binary sensors can be added as devices are found."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
def async_setup_device_entities(
|
||||
device_address: dict[str, EheimDigitalDevice],
|
||||
) -> None:
|
||||
"""Set up the binary sensor entities for one or multiple devices."""
|
||||
entities: list[EheimDigitalBinarySensor[Any]] = []
|
||||
for device in device_address.values():
|
||||
if isinstance(device, EheimDigitalReeflexUV):
|
||||
entities += [
|
||||
EheimDigitalBinarySensor[EheimDigitalReeflexUV](
|
||||
coordinator, device, description
|
||||
)
|
||||
for description in REEFLEX_DESCRIPTIONS
|
||||
]
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
coordinator.add_platform_callback(async_setup_device_entities)
|
||||
async_setup_device_entities(coordinator.hub.devices)
|
||||
|
||||
|
||||
class EheimDigitalBinarySensor[_DeviceT: EheimDigitalDevice](
|
||||
EheimDigitalEntity[_DeviceT], BinarySensorEntity
|
||||
):
|
||||
"""Represent an EHEIM Digital binary sensor entity."""
|
||||
|
||||
entity_description: EheimDigitalBinarySensorDescription[_DeviceT]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: EheimDigitalUpdateCoordinator,
|
||||
device: _DeviceT,
|
||||
description: EheimDigitalBinarySensorDescription[_DeviceT],
|
||||
) -> None:
|
||||
"""Initialize an EHEIM Digital binary sensor entity."""
|
||||
super().__init__(coordinator, device)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{self._device_address}_{description.key}"
|
||||
|
||||
def _async_update_attrs(self) -> None:
|
||||
self._attr_is_on = self.entity_description.value_fn(self._device)
|
||||
@@ -1,5 +1,19 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"is_lighting": {
|
||||
"default": "mdi:lightbulb-outline",
|
||||
"state": {
|
||||
"on": "mdi:lightbulb-on"
|
||||
}
|
||||
},
|
||||
"is_uvc_connected": {
|
||||
"default": "mdi:lightbulb-off",
|
||||
"state": {
|
||||
"on": "mdi:lightbulb-outline"
|
||||
}
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"day_speed": {
|
||||
"default": "mdi:weather-sunny"
|
||||
|
||||
@@ -33,6 +33,17 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"is_lighting": {
|
||||
"state": {
|
||||
"off": "[%key:common::state::off%]",
|
||||
"on": "[%key:common::state::on%]"
|
||||
}
|
||||
},
|
||||
"is_uvc_connected": {
|
||||
"name": "UVC lamp connected"
|
||||
}
|
||||
},
|
||||
"climate": {
|
||||
"heater": {
|
||||
"state_attributes": {
|
||||
|
||||
@@ -111,7 +111,7 @@ def get_model_selection_schema(
|
||||
),
|
||||
vol.Required(
|
||||
CONF_BACKEND,
|
||||
default=options.get(CONF_BACKEND, "s1"),
|
||||
default=options.get(CONF_BACKEND, "s2-pro"),
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
|
||||
@@ -31,7 +31,7 @@ TTS_SUPPORTED_LANGUAGES = [
|
||||
]
|
||||
|
||||
|
||||
BACKEND_MODELS = ["s1", "speech-1.5", "speech-1.6"]
|
||||
BACKEND_MODELS = ["s2-pro", "s1", "speech-1.5", "speech-1.6"]
|
||||
SORT_BY_OPTIONS = ["task_count", "score", "created_at"]
|
||||
LATENCY_OPTIONS = ["normal", "balanced"]
|
||||
|
||||
|
||||
@@ -2,12 +2,18 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from bleak.backends.device import BLEDevice
|
||||
from gardena_bluetooth.client import CachedConnection, Client
|
||||
from gardena_bluetooth.const import DeviceConfiguration, DeviceInformation
|
||||
from gardena_bluetooth.exceptions import CommunicationFailure
|
||||
from gardena_bluetooth.exceptions import (
|
||||
CharacteristicNoAccess,
|
||||
CharacteristicNotFound,
|
||||
CommunicationFailure,
|
||||
)
|
||||
from gardena_bluetooth.parse import CharacteristicTime
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.const import CONF_ADDRESS, Platform
|
||||
@@ -23,6 +29,7 @@ from .coordinator import (
|
||||
GardenaBluetoothConfigEntry,
|
||||
GardenaBluetoothCoordinator,
|
||||
)
|
||||
from .util import async_get_product_type
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BINARY_SENSOR,
|
||||
@@ -51,22 +58,41 @@ def get_connection(hass: HomeAssistant, address: str) -> CachedConnection:
|
||||
return CachedConnection(DISCONNECT_DELAY, _device_lookup)
|
||||
|
||||
|
||||
async def _update_timestamp(client: Client, characteristics: CharacteristicTime):
|
||||
try:
|
||||
await client.update_timestamp(characteristics, dt_util.now())
|
||||
except CharacteristicNotFound:
|
||||
pass
|
||||
except CharacteristicNoAccess:
|
||||
LOGGER.debug("No access to update internal time")
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: GardenaBluetoothConfigEntry
|
||||
) -> bool:
|
||||
"""Set up Gardena Bluetooth from a config entry."""
|
||||
|
||||
address = entry.data[CONF_ADDRESS]
|
||||
client = Client(get_connection(hass, address))
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(TIMEOUT):
|
||||
product_type = await async_get_product_type(hass, address)
|
||||
except TimeoutError as exception:
|
||||
raise ConfigEntryNotReady("Unable to find product type") from exception
|
||||
|
||||
client = Client(get_connection(hass, address), product_type)
|
||||
try:
|
||||
chars = await client.get_all_characteristics()
|
||||
|
||||
sw_version = await client.read_char(DeviceInformation.firmware_version, None)
|
||||
manufacturer = await client.read_char(DeviceInformation.manufacturer_name, None)
|
||||
model = await client.read_char(DeviceInformation.model_number, None)
|
||||
name = await client.read_char(
|
||||
DeviceConfiguration.custom_device_name, entry.title
|
||||
)
|
||||
uuids = await client.get_all_characteristics_uuid()
|
||||
await client.update_timestamp(dt_util.now())
|
||||
|
||||
name = entry.title
|
||||
name = await client.read_char(DeviceConfiguration.custom_device_name, name)
|
||||
|
||||
await _update_timestamp(client, DeviceConfiguration.unix_timestamp)
|
||||
|
||||
except (TimeoutError, CommunicationFailure, DeviceUnavailable) as exception:
|
||||
await client.disconnect()
|
||||
raise ConfigEntryNotReady(
|
||||
@@ -83,7 +109,7 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
coordinator = GardenaBluetoothCoordinator(
|
||||
hass, entry, LOGGER, client, uuids, device, address
|
||||
hass, entry, LOGGER, client, set(chars.keys()), device, address
|
||||
)
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
@@ -34,14 +34,14 @@ class GardenaBluetoothBinarySensorEntityDescription(BinarySensorEntityDescriptio
|
||||
|
||||
DESCRIPTIONS = (
|
||||
GardenaBluetoothBinarySensorEntityDescription(
|
||||
key=Valve.connected_state.uuid,
|
||||
key=Valve.connected_state.unique_id,
|
||||
translation_key="valve_connected_state",
|
||||
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
char=Valve.connected_state,
|
||||
),
|
||||
GardenaBluetoothBinarySensorEntityDescription(
|
||||
key=Sensor.connected_state.uuid,
|
||||
key=Sensor.connected_state.unique_id,
|
||||
translation_key="sensor_connected_state",
|
||||
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -60,7 +60,7 @@ async def async_setup_entry(
|
||||
entities = [
|
||||
GardenaBluetoothBinarySensor(coordinator, description, description.context)
|
||||
for description in DESCRIPTIONS
|
||||
if description.key in coordinator.characteristics
|
||||
if description.char.unique_id in coordinator.characteristics
|
||||
]
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ class GardenaBluetoothButtonEntityDescription(ButtonEntityDescription):
|
||||
|
||||
DESCRIPTIONS = (
|
||||
GardenaBluetoothButtonEntityDescription(
|
||||
key=Reset.factory_reset.uuid,
|
||||
key=Reset.factory_reset.unique_id,
|
||||
translation_key="factory_reset",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
@@ -49,7 +49,7 @@ async def async_setup_entry(
|
||||
entities = [
|
||||
GardenaBluetoothButton(coordinator, description, description.context)
|
||||
for description in DESCRIPTIONS
|
||||
if description.key in coordinator.characteristics
|
||||
if description.char.unique_id in coordinator.characteristics
|
||||
]
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["bleak", "bleak_esphome", "gardena_bluetooth"],
|
||||
"requirements": ["gardena-bluetooth==1.6.0"]
|
||||
"requirements": ["gardena-bluetooth==2.1.0"]
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ class GardenaBluetoothNumberEntityDescription(NumberEntityDescription):
|
||||
|
||||
DESCRIPTIONS = (
|
||||
GardenaBluetoothNumberEntityDescription(
|
||||
key=Valve.manual_watering_time.uuid,
|
||||
key=Valve.manual_watering_time.unique_id,
|
||||
translation_key="manual_watering_time",
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
mode=NumberMode.BOX,
|
||||
@@ -58,7 +58,7 @@ DESCRIPTIONS = (
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
),
|
||||
GardenaBluetoothNumberEntityDescription(
|
||||
key=Valve.remaining_open_time.uuid,
|
||||
key=Valve.remaining_open_time.unique_id,
|
||||
translation_key="remaining_open_time",
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
native_min_value=0.0,
|
||||
@@ -69,7 +69,7 @@ DESCRIPTIONS = (
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
),
|
||||
GardenaBluetoothNumberEntityDescription(
|
||||
key=DeviceConfiguration.rain_pause.uuid,
|
||||
key=DeviceConfiguration.rain_pause.unique_id,
|
||||
translation_key="rain_pause",
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
mode=NumberMode.BOX,
|
||||
@@ -81,7 +81,7 @@ DESCRIPTIONS = (
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
),
|
||||
GardenaBluetoothNumberEntityDescription(
|
||||
key=DeviceConfiguration.seasonal_adjust.uuid,
|
||||
key=DeviceConfiguration.seasonal_adjust.unique_id,
|
||||
translation_key="seasonal_adjust",
|
||||
native_unit_of_measurement=UnitOfTime.DAYS,
|
||||
mode=NumberMode.BOX,
|
||||
@@ -93,7 +93,7 @@ DESCRIPTIONS = (
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
),
|
||||
GardenaBluetoothNumberEntityDescription(
|
||||
key=Sensor.threshold.uuid,
|
||||
key=Sensor.threshold.unique_id,
|
||||
translation_key="sensor_threshold",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
mode=NumberMode.BOX,
|
||||
@@ -117,9 +117,9 @@ async def async_setup_entry(
|
||||
entities: list[NumberEntity] = [
|
||||
GardenaBluetoothNumber(coordinator, description, description.context)
|
||||
for description in DESCRIPTIONS
|
||||
if description.key in coordinator.characteristics
|
||||
if description.char.unique_id in coordinator.characteristics
|
||||
]
|
||||
if Valve.remaining_open_time.uuid in coordinator.characteristics:
|
||||
if Valve.remaining_open_time.unique_id in coordinator.characteristics:
|
||||
entities.append(GardenaBluetoothRemainingOpenSetNumber(coordinator))
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ class GardenaBluetoothSensorEntityDescription(SensorEntityDescription):
|
||||
|
||||
DESCRIPTIONS = (
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Valve.activation_reason.uuid,
|
||||
key=Valve.activation_reason.unique_id,
|
||||
translation_key="activation_reason",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -49,7 +49,7 @@ DESCRIPTIONS = (
|
||||
char=Valve.activation_reason,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Battery.battery_level.uuid,
|
||||
key=Battery.battery_level.unique_id,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -57,7 +57,7 @@ DESCRIPTIONS = (
|
||||
char=Battery.battery_level,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Sensor.battery_level.uuid,
|
||||
key=Sensor.battery_level.unique_id,
|
||||
translation_key="sensor_battery_level",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
@@ -67,7 +67,7 @@ DESCRIPTIONS = (
|
||||
connected_state=Sensor.connected_state,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Sensor.value.uuid,
|
||||
key=Sensor.value.unique_id,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.MOISTURE,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
@@ -75,14 +75,14 @@ DESCRIPTIONS = (
|
||||
connected_state=Sensor.connected_state,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Sensor.type.uuid,
|
||||
key=Sensor.type.unique_id,
|
||||
translation_key="sensor_type",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
char=Sensor.type,
|
||||
connected_state=Sensor.connected_state,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Sensor.measurement_timestamp.uuid,
|
||||
key=Sensor.measurement_timestamp.unique_id,
|
||||
translation_key="sensor_measurement_timestamp",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -102,9 +102,9 @@ async def async_setup_entry(
|
||||
entities: list[GardenaBluetoothEntity] = [
|
||||
GardenaBluetoothSensor(coordinator, description, description.context)
|
||||
for description in DESCRIPTIONS
|
||||
if description.key in coordinator.characteristics
|
||||
if description.char.unique_id in coordinator.characteristics
|
||||
]
|
||||
if Valve.remaining_open_time.uuid in coordinator.characteristics:
|
||||
if Valve.remaining_open_time.unique_id in coordinator.characteristics:
|
||||
entities.append(GardenaBluetoothRemainSensor(coordinator))
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -35,9 +35,9 @@ class GardenaBluetoothValveSwitch(GardenaBluetoothEntity, SwitchEntity):
|
||||
"""Representation of a valve switch."""
|
||||
|
||||
characteristics = {
|
||||
Valve.state.uuid,
|
||||
Valve.manual_watering_time.uuid,
|
||||
Valve.remaining_open_time.uuid,
|
||||
Valve.state.unique_id,
|
||||
Valve.manual_watering_time.unique_id,
|
||||
Valve.remaining_open_time.unique_id,
|
||||
}
|
||||
|
||||
def __init__(
|
||||
@@ -48,7 +48,7 @@ class GardenaBluetoothValveSwitch(GardenaBluetoothEntity, SwitchEntity):
|
||||
super().__init__(
|
||||
coordinator, {Valve.state.uuid, Valve.manual_watering_time.uuid}
|
||||
)
|
||||
self._attr_unique_id = f"{coordinator.address}-{Valve.state.uuid}"
|
||||
self._attr_unique_id = f"{coordinator.address}-{Valve.state.unique_id}"
|
||||
self._attr_translation_key = "state"
|
||||
self._attr_is_on = None
|
||||
self._attr_entity_registry_enabled_default = False
|
||||
|
||||
51
homeassistant/components/gardena_bluetooth/util.py
Normal file
51
homeassistant/components/gardena_bluetooth/util.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Utility functions for Gardena Bluetooth integration."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from gardena_bluetooth.parse import ManufacturerData, ProductType
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
|
||||
|
||||
async def _async_service_info(
|
||||
hass, address
|
||||
) -> AsyncIterator[bluetooth.BluetoothServiceInfoBleak]:
|
||||
queue = asyncio.Queue[bluetooth.BluetoothServiceInfoBleak]()
|
||||
|
||||
def _callback(
|
||||
service_info: bluetooth.BluetoothServiceInfoBleak,
|
||||
change: bluetooth.BluetoothChange,
|
||||
) -> None:
|
||||
if change != bluetooth.BluetoothChange.ADVERTISEMENT:
|
||||
return
|
||||
|
||||
queue.put_nowait(service_info)
|
||||
|
||||
service_info = bluetooth.async_last_service_info(hass, address, True)
|
||||
if service_info:
|
||||
yield service_info
|
||||
|
||||
cancel = bluetooth.async_register_callback(
|
||||
hass,
|
||||
_callback,
|
||||
{bluetooth.match.ADDRESS: address},
|
||||
bluetooth.BluetoothScanningMode.ACTIVE,
|
||||
)
|
||||
try:
|
||||
while True:
|
||||
yield await queue.get()
|
||||
finally:
|
||||
cancel()
|
||||
|
||||
|
||||
async def async_get_product_type(hass, address: str) -> ProductType:
|
||||
"""Wait for enough packets of manufacturer data to get the product type."""
|
||||
data = ManufacturerData()
|
||||
|
||||
async for service_info in _async_service_info(hass, address):
|
||||
data.update(service_info.manufacturer_data.get(ManufacturerData.company, b""))
|
||||
product_type = ProductType.from_manufacturer_data(data)
|
||||
if product_type is not ProductType.UNKNOWN:
|
||||
return product_type
|
||||
raise AssertionError("Iterator should have been infinite")
|
||||
@@ -44,9 +44,9 @@ class GardenaBluetoothValve(GardenaBluetoothEntity, ValveEntity):
|
||||
_attr_device_class = ValveDeviceClass.WATER
|
||||
|
||||
characteristics = {
|
||||
Valve.state.uuid,
|
||||
Valve.manual_watering_time.uuid,
|
||||
Valve.remaining_open_time.uuid,
|
||||
Valve.state.unique_id,
|
||||
Valve.manual_watering_time.unique_id,
|
||||
Valve.remaining_open_time.unique_id,
|
||||
}
|
||||
|
||||
def __init__(
|
||||
@@ -57,7 +57,7 @@ class GardenaBluetoothValve(GardenaBluetoothEntity, ValveEntity):
|
||||
super().__init__(
|
||||
coordinator, {Valve.state.uuid, Valve.manual_watering_time.uuid}
|
||||
)
|
||||
self._attr_unique_id = f"{coordinator.address}-{Valve.state.uuid}"
|
||||
self._attr_unique_id = f"{coordinator.address}-{Valve.state.unique_id}"
|
||||
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
self._attr_is_closed = not self.coordinator.get_cached(Valve.state)
|
||||
|
||||
17
homeassistant/components/gate/__init__.py
Normal file
17
homeassistant/components/gate/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""Integration for gate 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 = "gate"
|
||||
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/gate/icons.json
Normal file
10
homeassistant/components/gate/icons.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"triggers": {
|
||||
"closed": {
|
||||
"trigger": "mdi:gate"
|
||||
},
|
||||
"opened": {
|
||||
"trigger": "mdi:gate-open"
|
||||
}
|
||||
}
|
||||
}
|
||||
8
homeassistant/components/gate/manifest.json
Normal file
8
homeassistant/components/gate/manifest.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"domain": "gate",
|
||||
"name": "Gate",
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/gate",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal"
|
||||
}
|
||||
38
homeassistant/components/gate/strings.json
Normal file
38
homeassistant/components/gate/strings.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"common": {
|
||||
"trigger_behavior_description": "The behavior of the targeted gates to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"selector": {
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
"first": "First",
|
||||
"last": "Last"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Gate",
|
||||
"triggers": {
|
||||
"closed": {
|
||||
"description": "Triggers after one or more gates close.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::gate::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::gate::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Gate closed"
|
||||
},
|
||||
"opened": {
|
||||
"description": "Triggers after one or more gates open.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::gate::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::gate::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Gate opened"
|
||||
}
|
||||
}
|
||||
}
|
||||
25
homeassistant/components/gate/trigger.py
Normal file
25
homeassistant/components/gate/trigger.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""Provides triggers for gates."""
|
||||
|
||||
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_GATE: dict[str, str] = {
|
||||
COVER_DOMAIN: CoverDeviceClass.GATE,
|
||||
}
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_GATE),
|
||||
"closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_GATE),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for gates."""
|
||||
return TRIGGERS
|
||||
@@ -10,16 +10,16 @@
|
||||
- last
|
||||
- any
|
||||
|
||||
occupancy_cleared:
|
||||
closed:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: binary_sensor
|
||||
device_class: occupancy
|
||||
- domain: cover
|
||||
device_class: gate
|
||||
|
||||
occupancy_detected:
|
||||
opened:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: binary_sensor
|
||||
device_class: occupancy
|
||||
- domain: cover
|
||||
device_class: gate
|
||||
@@ -1,4 +1,28 @@
|
||||
"""The Growatt server PV inverter sensor integration."""
|
||||
"""The Growatt server PV inverter sensor integration.
|
||||
|
||||
This integration supports two distinct Growatt APIs with different auth models:
|
||||
|
||||
Classic API (username/password):
|
||||
- Authenticates via api.login(), which returns a dict with a "success" key.
|
||||
- Auth failure is signalled by success=False and msg="502" (LOGIN_INVALID_AUTH_CODE).
|
||||
- A failed login does NOT raise an exception — the return value must be checked.
|
||||
- The coordinator calls api.login() on every update cycle to maintain the session.
|
||||
|
||||
Open API V1 (API token):
|
||||
- Stateless — no login call, token is sent as a Bearer header on every request.
|
||||
- Auth failure is signalled by raising GrowattV1ApiError with error_code=10011
|
||||
(V1_API_ERROR_NO_PRIVILEGE). The library NEVER returns a failure silently;
|
||||
any non-zero error_code raises an exception via _process_response().
|
||||
- Because the library always raises on error, return-value validation after a
|
||||
successful V1 API call is unnecessary — if it returned, the token was valid.
|
||||
|
||||
Error handling pattern for reauth:
|
||||
- Classic API: check NOT login_response["success"] and msg == LOGIN_INVALID_AUTH_CODE
|
||||
→ raise ConfigEntryAuthFailed
|
||||
- V1 API: catch GrowattV1ApiError with error_code == V1_API_ERROR_NO_PRIVILEGE
|
||||
→ raise ConfigEntryAuthFailed
|
||||
- All other errors → ConfigEntryError (setup) or UpdateFailed (coordinator)
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from json import JSONDecodeError
|
||||
@@ -25,6 +49,7 @@ from .const import (
|
||||
DOMAIN,
|
||||
LOGIN_INVALID_AUTH_CODE,
|
||||
PLATFORMS,
|
||||
V1_API_ERROR_NO_PRIVILEGE,
|
||||
)
|
||||
from .coordinator import GrowattConfigEntry, GrowattCoordinator
|
||||
from .models import GrowattRuntimeData
|
||||
@@ -227,8 +252,12 @@ def get_device_list_v1(
|
||||
try:
|
||||
devices_dict = api.device_list(plant_id)
|
||||
except growattServer.GrowattV1ApiError as e:
|
||||
if e.error_code == V1_API_ERROR_NO_PRIVILEGE:
|
||||
raise ConfigEntryAuthFailed(
|
||||
f"Authentication failed for Growatt API: {e.error_msg or str(e)}"
|
||||
) from e
|
||||
raise ConfigEntryError(
|
||||
f"API error during device list: {e} (Code: {getattr(e, 'error_code', None)}, Message: {getattr(e, 'error_msg', None)})"
|
||||
f"API error during device list: {e.error_msg or str(e)} (Code: {e.error_code})"
|
||||
) from e
|
||||
devices = devices_dict.get("devices", [])
|
||||
# Only MIN device (type = 7) support implemented in current V1 API
|
||||
@@ -272,6 +301,7 @@ async def async_setup_entry(
|
||||
# V1 API (token-based, no login needed)
|
||||
token = config[CONF_TOKEN]
|
||||
api = growattServer.OpenApiV1(token=token)
|
||||
api.server_url = url
|
||||
devices, plant_id = await hass.async_add_executor_job(
|
||||
get_device_list_v1, api, config
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Config flow for growatt server integration."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -31,8 +32,11 @@ from .const import (
|
||||
ERROR_INVALID_AUTH,
|
||||
LOGIN_INVALID_AUTH_CODE,
|
||||
SERVER_URLS_NAMES,
|
||||
V1_API_ERROR_NO_PRIVILEGE,
|
||||
)
|
||||
|
||||
_URL_TO_REGION = {v: k for k, v in SERVER_URLS_NAMES.items()}
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -60,6 +64,137 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
menu_options=["password_auth", "token_auth"],
|
||||
)
|
||||
|
||||
async def async_step_reauth(self, _: Mapping[str, Any]) -> ConfigFlowResult:
|
||||
"""Handle reauth."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauth confirmation."""
|
||||
errors: dict[str, str] = {}
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
|
||||
if user_input is not None:
|
||||
auth_type = reauth_entry.data.get(CONF_AUTH_TYPE)
|
||||
|
||||
if auth_type == AUTH_PASSWORD:
|
||||
server_url = SERVER_URLS_NAMES[user_input[CONF_REGION]]
|
||||
api = growattServer.GrowattApi(
|
||||
add_random_user_id=True,
|
||||
agent_identifier=user_input[CONF_USERNAME],
|
||||
)
|
||||
api.server_url = server_url
|
||||
|
||||
try:
|
||||
login_response = await self.hass.async_add_executor_job(
|
||||
api.login, user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
|
||||
)
|
||||
except requests.exceptions.RequestException as ex:
|
||||
_LOGGER.debug("Network error during reauth login: %s", ex)
|
||||
errors["base"] = ERROR_CANNOT_CONNECT
|
||||
except (ValueError, KeyError, TypeError, AttributeError) as ex:
|
||||
_LOGGER.debug("Invalid response format during reauth login: %s", ex)
|
||||
errors["base"] = ERROR_CANNOT_CONNECT
|
||||
else:
|
||||
if not isinstance(login_response, dict):
|
||||
errors["base"] = ERROR_CANNOT_CONNECT
|
||||
elif login_response.get("success"):
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry,
|
||||
data_updates={
|
||||
CONF_USERNAME: user_input[CONF_USERNAME],
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
CONF_URL: server_url,
|
||||
},
|
||||
)
|
||||
elif login_response.get("msg") == LOGIN_INVALID_AUTH_CODE:
|
||||
errors["base"] = ERROR_INVALID_AUTH
|
||||
else:
|
||||
errors["base"] = ERROR_CANNOT_CONNECT
|
||||
|
||||
elif auth_type == AUTH_API_TOKEN:
|
||||
server_url = SERVER_URLS_NAMES[user_input[CONF_REGION]]
|
||||
api = growattServer.OpenApiV1(token=user_input[CONF_TOKEN])
|
||||
api.server_url = server_url
|
||||
|
||||
try:
|
||||
await self.hass.async_add_executor_job(api.plant_list)
|
||||
except requests.exceptions.RequestException as ex:
|
||||
_LOGGER.debug(
|
||||
"Network error during reauth token validation: %s", ex
|
||||
)
|
||||
errors["base"] = ERROR_CANNOT_CONNECT
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
|
||||
errors["base"] = ERROR_INVALID_AUTH
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Growatt V1 API error during reauth: %s (Code: %s)",
|
||||
err.error_msg or str(err),
|
||||
err.error_code,
|
||||
)
|
||||
errors["base"] = ERROR_CANNOT_CONNECT
|
||||
except (ValueError, KeyError, TypeError, AttributeError) as ex:
|
||||
_LOGGER.debug(
|
||||
"Invalid response format during reauth token validation: %s", ex
|
||||
)
|
||||
errors["base"] = ERROR_CANNOT_CONNECT
|
||||
else:
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry,
|
||||
data_updates={
|
||||
CONF_TOKEN: user_input[CONF_TOKEN],
|
||||
CONF_URL: server_url,
|
||||
},
|
||||
)
|
||||
|
||||
# Determine the current region key from the stored config value.
|
||||
# Legacy entries may store the region key directly; newer entries store the URL.
|
||||
stored_url = reauth_entry.data.get(CONF_URL, "")
|
||||
if stored_url in SERVER_URLS_NAMES:
|
||||
current_region = stored_url
|
||||
else:
|
||||
current_region = _URL_TO_REGION.get(stored_url, DEFAULT_URL)
|
||||
|
||||
auth_type = reauth_entry.data.get(CONF_AUTH_TYPE)
|
||||
if auth_type == AUTH_PASSWORD:
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_USERNAME,
|
||||
default=reauth_entry.data.get(CONF_USERNAME),
|
||||
): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
vol.Required(CONF_REGION, default=current_region): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=list(SERVER_URLS_NAMES.keys()),
|
||||
translation_key="region",
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
elif auth_type == AUTH_API_TOKEN:
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_TOKEN): str,
|
||||
vol.Required(CONF_REGION, default=current_region): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=list(SERVER_URLS_NAMES.keys()),
|
||||
translation_key="region",
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
else:
|
||||
return self.async_abort(reason=ERROR_CANNOT_CONNECT)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=data_schema,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_password_auth(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -129,9 +264,11 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.error(
|
||||
"Growatt V1 API error: %s (Code: %s)",
|
||||
e.error_msg or str(e),
|
||||
getattr(e, "error_code", None),
|
||||
e.error_code,
|
||||
)
|
||||
return self._async_show_token_form({"base": ERROR_INVALID_AUTH})
|
||||
if e.error_code == V1_API_ERROR_NO_PRIVILEGE:
|
||||
return self._async_show_token_form({"base": ERROR_INVALID_AUTH})
|
||||
return self._async_show_token_form({"base": ERROR_CANNOT_CONNECT})
|
||||
except (ValueError, KeyError, TypeError, AttributeError) as ex:
|
||||
_LOGGER.error(
|
||||
"Invalid response format during Growatt V1 API plant list: %s", ex
|
||||
|
||||
@@ -40,8 +40,17 @@ DOMAIN = "growatt_server"
|
||||
|
||||
PLATFORMS = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH]
|
||||
|
||||
# Growatt Classic API error codes
|
||||
LOGIN_INVALID_AUTH_CODE = "502"
|
||||
|
||||
# Growatt Open API V1 error codes
|
||||
# Reference: https://www.showdoc.com.cn/262556420217021/1494055648380019
|
||||
V1_API_ERROR_WRONG_DOMAIN = -1 # Use correct regional domain
|
||||
V1_API_ERROR_NO_PRIVILEGE = 10011 # No privilege access — invalid or expired token
|
||||
V1_API_ERROR_RATE_LIMITED = 10012 # Access frequency limit (5 minutes per call)
|
||||
V1_API_ERROR_PAGE_SIZE = 10013 # Page size cannot exceed 100
|
||||
V1_API_ERROR_PAGE_COUNT = 10014 # Page count cannot exceed 250
|
||||
|
||||
# Config flow error types (also used as abort reasons)
|
||||
ERROR_CANNOT_CONNECT = "cannot_connect" # Used for both form errors and aborts
|
||||
ERROR_INVALID_AUTH = "invalid_auth"
|
||||
|
||||
@@ -13,7 +13,11 @@ from homeassistant.components.sensor import SensorStateClass
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
HomeAssistantError,
|
||||
ServiceValidationError,
|
||||
)
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
@@ -23,6 +27,8 @@ from .const import (
|
||||
BATT_MODE_LOAD_FIRST,
|
||||
DEFAULT_URL,
|
||||
DOMAIN,
|
||||
LOGIN_INVALID_AUTH_CODE,
|
||||
V1_API_ERROR_NO_PRIVILEGE,
|
||||
)
|
||||
from .models import GrowattRuntimeData
|
||||
|
||||
@@ -63,6 +69,7 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self.url = config_entry.data.get(CONF_URL, DEFAULT_URL)
|
||||
self.token = config_entry.data["token"]
|
||||
self.api = growattServer.OpenApiV1(token=self.token)
|
||||
self.api.server_url = self.url
|
||||
elif self.api_version == "classic":
|
||||
self.username = config_entry.data.get(CONF_USERNAME)
|
||||
self.password = config_entry.data[CONF_PASSWORD]
|
||||
@@ -88,7 +95,14 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
# login only required for classic API
|
||||
if self.api_version == "classic":
|
||||
self.api.login(self.username, self.password)
|
||||
login_response = self.api.login(self.username, self.password)
|
||||
if not login_response.get("success"):
|
||||
msg = login_response.get("msg", "Unknown error")
|
||||
if msg == LOGIN_INVALID_AUTH_CODE:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Username, password, or URL may be incorrect"
|
||||
)
|
||||
raise UpdateFailed(f"Growatt login failed: {msg}")
|
||||
|
||||
if self.device_type == "total":
|
||||
if self.api_version == "v1":
|
||||
@@ -100,7 +114,16 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# todayEnergy -> today_energy
|
||||
# totalEnergy -> total_energy
|
||||
# invTodayPpv -> current_power
|
||||
total_info = self.api.plant_energy_overview(self.plant_id)
|
||||
try:
|
||||
total_info = self.api.plant_energy_overview(self.plant_id)
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
|
||||
raise ConfigEntryAuthFailed(
|
||||
f"Authentication failed for Growatt API: {err.error_msg or str(err)}"
|
||||
) from err
|
||||
raise UpdateFailed(
|
||||
f"Error fetching plant energy overview: {err}"
|
||||
) from err
|
||||
total_info["todayEnergy"] = total_info["today_energy"]
|
||||
total_info["totalEnergy"] = total_info["total_energy"]
|
||||
total_info["invTodayPpv"] = total_info["current_power"]
|
||||
@@ -122,6 +145,10 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
min_settings = self.api.min_settings(self.device_id)
|
||||
min_energy = self.api.min_energy(self.device_id)
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
|
||||
raise ConfigEntryAuthFailed(
|
||||
f"Authentication failed for Growatt API: {err.error_msg or str(err)}"
|
||||
) from err
|
||||
raise UpdateFailed(f"Error fetching min device data: {err}") from err
|
||||
|
||||
min_info = {**min_details, **min_settings, **min_energy}
|
||||
|
||||
@@ -30,7 +30,7 @@ rules:
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"no_plants": "No plants have been found on this account"
|
||||
"no_plants": "No plants have been found on this account",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Cannot connect to Growatt servers. Please check your internet connection and try again.",
|
||||
@@ -13,7 +14,7 @@
|
||||
"password_auth": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"url": "Server region",
|
||||
"region": "Server region",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"title": "Enter your Growatt login credentials"
|
||||
@@ -24,10 +25,20 @@
|
||||
},
|
||||
"title": "Select your plant"
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"region": "[%key:component::growatt_server::config::step::password_auth::data::region%]",
|
||||
"token": "[%key:component::growatt_server::config::step::token_auth::data::token%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"description": "Re-enter your credentials to continue using this integration.",
|
||||
"title": "Re-authenticate with Growatt"
|
||||
},
|
||||
"token_auth": {
|
||||
"data": {
|
||||
"token": "API Token",
|
||||
"url": "Server region"
|
||||
"region": "[%key:component::growatt_server::config::step::password_auth::data::region%]",
|
||||
"token": "API Token"
|
||||
},
|
||||
"description": "Token authentication is only supported for MIN/TLX devices. For other device types, please use username/password authentication.",
|
||||
"title": "Enter your API token"
|
||||
|
||||
@@ -38,6 +38,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.FAN,
|
||||
Platform.LIGHT,
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
|
||||
@@ -19,7 +19,7 @@ from .coordinator import (
|
||||
HomeConnectApplianceData,
|
||||
HomeConnectConfigEntry,
|
||||
)
|
||||
from .entity import HomeConnectEntity, HomeConnectOptionEntity
|
||||
from .entity import HomeConnectEntity
|
||||
|
||||
|
||||
def should_add_option_entity(
|
||||
@@ -48,7 +48,7 @@ def _create_option_entities(
|
||||
known_entity_unique_ids: dict[str, str],
|
||||
get_option_entities_for_appliance: Callable[
|
||||
[HomeConnectApplianceCoordinator, er.EntityRegistry],
|
||||
list[HomeConnectOptionEntity],
|
||||
list[HomeConnectEntity],
|
||||
],
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
@@ -78,7 +78,7 @@ def _handle_paired_or_connected_appliance(
|
||||
],
|
||||
get_option_entities_for_appliance: Callable[
|
||||
[HomeConnectApplianceCoordinator, er.EntityRegistry],
|
||||
list[HomeConnectOptionEntity],
|
||||
list[HomeConnectEntity],
|
||||
]
|
||||
| None,
|
||||
changed_options_listener_remove_callbacks: dict[str, list[Callable[[], None]]],
|
||||
@@ -161,7 +161,7 @@ def setup_home_connect_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
get_option_entities_for_appliance: Callable[
|
||||
[HomeConnectApplianceCoordinator, er.EntityRegistry],
|
||||
list[HomeConnectOptionEntity],
|
||||
list[HomeConnectEntity],
|
||||
]
|
||||
| None = None,
|
||||
) -> None:
|
||||
|
||||
235
homeassistant/components/home_connect/fan.py
Normal file
235
homeassistant/components/home_connect/fan.py
Normal file
@@ -0,0 +1,235 @@
|
||||
"""Provides fan entities for Home Connect."""
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from aiohomeconnect.model import EventKey, OptionKey
|
||||
from aiohomeconnect.model.error import ActiveProgramNotSetError, HomeConnectError
|
||||
|
||||
from homeassistant.components.fan import (
|
||||
FanEntity,
|
||||
FanEntityDescription,
|
||||
FanEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .common import setup_home_connect_entry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
FAN_SPEED_MODE_OPTIONS = {
|
||||
"auto": "HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Automatic",
|
||||
"manual": "HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Manual",
|
||||
}
|
||||
FAN_SPEED_MODE_OPTIONS_INVERTED = {v: k for k, v in FAN_SPEED_MODE_OPTIONS.items()}
|
||||
|
||||
|
||||
AIR_CONDITIONER_ENTITY_DESCRIPTION = FanEntityDescription(
|
||||
key="air_conditioner",
|
||||
translation_key="air_conditioner",
|
||||
name=None,
|
||||
)
|
||||
|
||||
|
||||
def _get_entities_for_appliance(
|
||||
appliance_coordinator: HomeConnectApplianceCoordinator,
|
||||
) -> list[HomeConnectEntity]:
|
||||
"""Get a list of entities."""
|
||||
return (
|
||||
[HomeConnectAirConditioningFanEntity(appliance_coordinator)]
|
||||
if appliance_coordinator.data.options
|
||||
and any(
|
||||
option in appliance_coordinator.data.options
|
||||
for option in (
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE,
|
||||
)
|
||||
)
|
||||
else []
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HomeConnectConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Home Connect fan entities."""
|
||||
setup_home_connect_entry(
|
||||
hass,
|
||||
entry,
|
||||
_get_entities_for_appliance,
|
||||
async_add_entities,
|
||||
lambda appliance_coordinator, _: _get_entities_for_appliance(
|
||||
appliance_coordinator
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class HomeConnectAirConditioningFanEntity(HomeConnectEntity, FanEntity):
|
||||
"""Representation of a Home Connect fan entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: HomeConnectApplianceCoordinator,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
self._attr_preset_modes = list(FAN_SPEED_MODE_OPTIONS.keys())
|
||||
self._original_speed_modes_keys = set(FAN_SPEED_MODE_OPTIONS_INVERTED)
|
||||
super().__init__(
|
||||
coordinator,
|
||||
AIR_CONDITIONER_ENTITY_DESCRIPTION,
|
||||
context_override=(
|
||||
EventKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE
|
||||
),
|
||||
)
|
||||
self.update_preset_mode()
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update_preset_mode(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self.update_preset_mode()
|
||||
self.async_write_ha_state()
|
||||
_LOGGER.debug(
|
||||
"Updated %s (fan mode), new state: %s", self.entity_id, self.preset_mode
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
self.async_on_remove(
|
||||
self.coordinator.async_add_listener(
|
||||
self._handle_coordinator_update_preset_mode,
|
||||
EventKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
)
|
||||
)
|
||||
|
||||
def update_native_value(self) -> None:
|
||||
"""Set the speed percentage and speed mode values."""
|
||||
option_value = None
|
||||
option_key = OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE
|
||||
if event := self.appliance.events.get(EventKey(option_key)):
|
||||
option_value = event.value
|
||||
self._attr_percentage = (
|
||||
cast(int, option_value) if option_value is not None else None
|
||||
)
|
||||
|
||||
@property
|
||||
def supported_features(self) -> FanEntityFeature:
|
||||
"""Return the supported features for this fan entity."""
|
||||
features = FanEntityFeature(0)
|
||||
if (
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE
|
||||
in self.appliance.options
|
||||
):
|
||||
features |= FanEntityFeature.SET_SPEED
|
||||
if (
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
|
||||
in self.appliance.options
|
||||
):
|
||||
features |= FanEntityFeature.PRESET_MODE
|
||||
return features
|
||||
|
||||
def update_preset_mode(self) -> None:
|
||||
"""Set the preset mode value."""
|
||||
option_value = None
|
||||
option_key = OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
|
||||
if event := self.appliance.events.get(EventKey(option_key)):
|
||||
option_value = event.value
|
||||
self._attr_preset_mode = (
|
||||
FAN_SPEED_MODE_OPTIONS_INVERTED.get(cast(str, option_value))
|
||||
if option_value is not None
|
||||
else None
|
||||
)
|
||||
if (
|
||||
(
|
||||
option_definition := self.appliance.options.get(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
|
||||
)
|
||||
)
|
||||
and (option_constraints := option_definition.constraints)
|
||||
and option_constraints.allowed_values
|
||||
and (
|
||||
allowed_values_without_none := {
|
||||
value
|
||||
for value in option_constraints.allowed_values
|
||||
if value is not None
|
||||
}
|
||||
)
|
||||
and self._original_speed_modes_keys != allowed_values_without_none
|
||||
):
|
||||
self._original_speed_modes_keys = allowed_values_without_none
|
||||
self._attr_preset_modes = [
|
||||
key
|
||||
for key, value in FAN_SPEED_MODE_OPTIONS.items()
|
||||
if value in self._original_speed_modes_keys
|
||||
]
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
"""Set the speed of the fan, as a percentage."""
|
||||
await self._async_set_option(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE,
|
||||
percentage,
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Updated %s's speed percentage option, new state: %s",
|
||||
self.entity_id,
|
||||
percentage,
|
||||
)
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set new target fan mode."""
|
||||
await self._async_set_option(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
FAN_SPEED_MODE_OPTIONS[preset_mode],
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Updated %s's speed mode option, new state: %s",
|
||||
self.entity_id,
|
||||
self.state,
|
||||
)
|
||||
|
||||
async def _async_set_option(self, key: OptionKey, value: str | int) -> None:
|
||||
"""Set an option for the entity."""
|
||||
try:
|
||||
# We try to set the active program option first,
|
||||
# if it fails we try to set the selected program option
|
||||
with contextlib.suppress(ActiveProgramNotSetError):
|
||||
await self.coordinator.client.set_active_program_option(
|
||||
self.appliance.info.ha_id,
|
||||
option_key=key,
|
||||
value=value,
|
||||
)
|
||||
return
|
||||
|
||||
await self.coordinator.client.set_selected_program_option(
|
||||
self.appliance.info.ha_id,
|
||||
option_key=key,
|
||||
value=value,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_option",
|
||||
translation_placeholders=get_dict_from_home_connect_error(err),
|
||||
) from err
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return super().available and any(
|
||||
option in self.appliance.options
|
||||
for option in (
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE,
|
||||
)
|
||||
)
|
||||
@@ -136,7 +136,7 @@ def _get_entities_for_appliance(
|
||||
def _get_option_entities_for_appliance(
|
||||
appliance_coordinator: HomeConnectApplianceCoordinator,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> list[HomeConnectOptionEntity]:
|
||||
) -> list[HomeConnectEntity]:
|
||||
"""Get a list of currently available option entities."""
|
||||
return [
|
||||
HomeConnectOptionNumberEntity(appliance_coordinator, description)
|
||||
|
||||
@@ -355,7 +355,7 @@ def _get_entities_for_appliance(
|
||||
def _get_option_entities_for_appliance(
|
||||
appliance_coordinator: HomeConnectApplianceCoordinator,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> list[HomeConnectOptionEntity]:
|
||||
) -> list[HomeConnectEntity]:
|
||||
"""Get a list of entities."""
|
||||
return [
|
||||
HomeConnectSelectOptionEntity(appliance_coordinator, desc)
|
||||
|
||||
@@ -119,6 +119,18 @@
|
||||
"name": "Stop program"
|
||||
}
|
||||
},
|
||||
"fan": {
|
||||
"air_conditioner": {
|
||||
"state_attributes": {
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"auto": "[%key:common::state::auto%]",
|
||||
"manual": "[%key:common::state::manual%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"ambient_light": {
|
||||
"name": "Ambient light"
|
||||
|
||||
@@ -189,7 +189,7 @@ def _get_entities_for_appliance(
|
||||
def _get_option_entities_for_appliance(
|
||||
appliance_coordinator: HomeConnectApplianceCoordinator,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> list[HomeConnectOptionEntity]:
|
||||
) -> list[HomeConnectEntity]:
|
||||
"""Get a list of currently available option entities."""
|
||||
return [
|
||||
HomeConnectSwitchOptionEntity(appliance_coordinator, description)
|
||||
|
||||
@@ -29,21 +29,21 @@
|
||||
"title": "Humidity",
|
||||
"triggers": {
|
||||
"changed": {
|
||||
"description": "Triggers when the humidity changes.",
|
||||
"description": "Triggers when the relative humidity changes.",
|
||||
"fields": {
|
||||
"above": {
|
||||
"description": "Only trigger when humidity is above this value.",
|
||||
"description": "Only trigger when relative humidity is above this value.",
|
||||
"name": "Above"
|
||||
},
|
||||
"below": {
|
||||
"description": "Only trigger when humidity is below this value.",
|
||||
"description": "Only trigger when relative humidity is below this value.",
|
||||
"name": "Below"
|
||||
}
|
||||
},
|
||||
"name": "Humidity changed"
|
||||
"name": "Relative humidity changed"
|
||||
},
|
||||
"crossed_threshold": {
|
||||
"description": "Triggers when the humidity crosses a threshold.",
|
||||
"description": "Triggers when the relative humidity crosses a threshold.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::humidity::common::trigger_behavior_description%]",
|
||||
@@ -62,7 +62,7 @@
|
||||
"name": "Upper limit"
|
||||
}
|
||||
},
|
||||
"name": "Humidity crossed threshold"
|
||||
"name": "Relative humidity crossed threshold"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
unit_of_measurement: "%"
|
||||
entity:
|
||||
selector:
|
||||
entity:
|
||||
|
||||
@@ -58,7 +58,7 @@ def _is_supported(discovery_info: BluetoothServiceInfo):
|
||||
|
||||
# Some mowers only expose the serial number in the manufacturer data
|
||||
# and not the product type, so we allow None here as well.
|
||||
if product_type not in (ProductType.MOWER, None):
|
||||
if product_type not in (ProductType.MOWER, ProductType.UNKNOWN):
|
||||
LOGGER.debug("Unsupported device: %s (%s)", manufacturer_data, discovery_info)
|
||||
return False
|
||||
|
||||
|
||||
@@ -13,5 +13,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/husqvarna_automower_ble",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==1.6.0"]
|
||||
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.1.0"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["jvcprojector"],
|
||||
"requirements": ["pyjvcprojector==2.0.2"]
|
||||
"requirements": ["pyjvcprojector==2.0.3"]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import csv
|
||||
import dataclasses
|
||||
import logging
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Any, Self, cast, final
|
||||
from typing import TYPE_CHECKING, Any, Self, cast, final, override
|
||||
|
||||
from propcache.api import cached_property
|
||||
import voluptuous as vol
|
||||
@@ -272,6 +272,18 @@ def filter_turn_off_params(
|
||||
return {k: v for k, v in params.items() if k in (ATTR_TRANSITION, ATTR_FLASH)}
|
||||
|
||||
|
||||
def process_turn_off_params(
|
||||
hass: HomeAssistant, light: LightEntity, params: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Process light turn off params."""
|
||||
params = dict(params)
|
||||
|
||||
if ATTR_TRANSITION not in params:
|
||||
hass.data[DATA_PROFILES].apply_default(light.entity_id, True, params)
|
||||
|
||||
return params
|
||||
|
||||
|
||||
def filter_turn_on_params(light: LightEntity, params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Filter out params not supported by the light."""
|
||||
supported_features = light.supported_features
|
||||
@@ -306,7 +318,171 @@ def filter_turn_on_params(light: LightEntity, params: dict[str, Any]) -> dict[st
|
||||
return params
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: C901
|
||||
def process_turn_on_params( # noqa: C901
|
||||
hass: HomeAssistant, light: LightEntity, params: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Process light turn on params."""
|
||||
params = dict(params)
|
||||
|
||||
# Only process params once we processed brightness step
|
||||
if params and (
|
||||
ATTR_BRIGHTNESS_STEP in params or ATTR_BRIGHTNESS_STEP_PCT in params
|
||||
):
|
||||
brightness = light.brightness if light.is_on and light.brightness else 0
|
||||
|
||||
if ATTR_BRIGHTNESS_STEP in params:
|
||||
brightness += params.pop(ATTR_BRIGHTNESS_STEP)
|
||||
|
||||
else:
|
||||
brightness_pct = round(brightness / 255 * 100)
|
||||
brightness = round(
|
||||
(brightness_pct + params.pop(ATTR_BRIGHTNESS_STEP_PCT)) / 100 * 255
|
||||
)
|
||||
|
||||
params[ATTR_BRIGHTNESS] = max(0, min(255, brightness))
|
||||
|
||||
preprocess_turn_on_alternatives(hass, params)
|
||||
|
||||
if (not params or not light.is_on) or (params and ATTR_TRANSITION not in params):
|
||||
hass.data[DATA_PROFILES].apply_default(light.entity_id, light.is_on, params)
|
||||
|
||||
supported_color_modes = light._light_internal_supported_color_modes # noqa: SLF001
|
||||
|
||||
# If a color temperature is specified, emulate it if not supported by the light
|
||||
if ATTR_COLOR_TEMP_KELVIN in params:
|
||||
if (
|
||||
ColorMode.COLOR_TEMP not in supported_color_modes
|
||||
and ColorMode.RGBWW in supported_color_modes
|
||||
):
|
||||
color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN)
|
||||
brightness = cast(int, params.get(ATTR_BRIGHTNESS, light.brightness))
|
||||
params[ATTR_RGBWW_COLOR] = color_util.color_temperature_to_rgbww(
|
||||
color_temp,
|
||||
brightness,
|
||||
light.min_color_temp_kelvin,
|
||||
light.max_color_temp_kelvin,
|
||||
)
|
||||
elif ColorMode.COLOR_TEMP not in supported_color_modes:
|
||||
color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN)
|
||||
if color_supported(supported_color_modes):
|
||||
params[ATTR_HS_COLOR] = color_util.color_temperature_to_hs(color_temp)
|
||||
|
||||
# If a color is specified, convert to the color space supported by the light
|
||||
rgb_color: tuple[int, int, int] | None
|
||||
rgbww_color: tuple[int, int, int, int, int] | None
|
||||
if ATTR_HS_COLOR in params and ColorMode.HS not in supported_color_modes:
|
||||
hs_color = params.pop(ATTR_HS_COLOR)
|
||||
if ColorMode.RGB in supported_color_modes:
|
||||
params[ATTR_RGB_COLOR] = color_util.color_hs_to_RGB(*hs_color)
|
||||
elif ColorMode.RGBW in supported_color_modes:
|
||||
rgb_color = color_util.color_hs_to_RGB(*hs_color)
|
||||
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
|
||||
elif ColorMode.RGBWW in supported_color_modes:
|
||||
rgb_color = color_util.color_hs_to_RGB(*hs_color)
|
||||
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
|
||||
*rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
|
||||
)
|
||||
elif ColorMode.XY in supported_color_modes:
|
||||
params[ATTR_XY_COLOR] = color_util.color_hs_to_xy(*hs_color)
|
||||
elif ColorMode.COLOR_TEMP in supported_color_modes:
|
||||
xy_color = color_util.color_hs_to_xy(*hs_color)
|
||||
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
|
||||
*xy_color
|
||||
)
|
||||
elif ATTR_RGB_COLOR in params and ColorMode.RGB not in supported_color_modes:
|
||||
rgb_color = params.pop(ATTR_RGB_COLOR)
|
||||
assert rgb_color is not None
|
||||
if TYPE_CHECKING:
|
||||
rgb_color = cast(tuple[int, int, int], rgb_color)
|
||||
if ColorMode.RGBW in supported_color_modes:
|
||||
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
|
||||
elif ColorMode.RGBWW in supported_color_modes:
|
||||
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
|
||||
*rgb_color,
|
||||
light.min_color_temp_kelvin,
|
||||
light.max_color_temp_kelvin,
|
||||
)
|
||||
elif ColorMode.HS in supported_color_modes:
|
||||
params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color)
|
||||
elif ColorMode.XY in supported_color_modes:
|
||||
params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color)
|
||||
elif ColorMode.COLOR_TEMP in supported_color_modes:
|
||||
xy_color = color_util.color_RGB_to_xy(*rgb_color)
|
||||
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
|
||||
*xy_color
|
||||
)
|
||||
elif ATTR_XY_COLOR in params and ColorMode.XY not in supported_color_modes:
|
||||
xy_color = params.pop(ATTR_XY_COLOR)
|
||||
if ColorMode.HS in supported_color_modes:
|
||||
params[ATTR_HS_COLOR] = color_util.color_xy_to_hs(*xy_color)
|
||||
elif ColorMode.RGB in supported_color_modes:
|
||||
params[ATTR_RGB_COLOR] = color_util.color_xy_to_RGB(*xy_color)
|
||||
elif ColorMode.RGBW in supported_color_modes:
|
||||
rgb_color = color_util.color_xy_to_RGB(*xy_color)
|
||||
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
|
||||
elif ColorMode.RGBWW in supported_color_modes:
|
||||
rgb_color = color_util.color_xy_to_RGB(*xy_color)
|
||||
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
|
||||
*rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
|
||||
)
|
||||
elif ColorMode.COLOR_TEMP in supported_color_modes:
|
||||
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
|
||||
*xy_color
|
||||
)
|
||||
elif ATTR_RGBW_COLOR in params and ColorMode.RGBW not in supported_color_modes:
|
||||
rgbw_color = params.pop(ATTR_RGBW_COLOR)
|
||||
rgb_color = color_util.color_rgbw_to_rgb(*rgbw_color)
|
||||
if ColorMode.RGB in supported_color_modes:
|
||||
params[ATTR_RGB_COLOR] = rgb_color
|
||||
elif ColorMode.RGBWW in supported_color_modes:
|
||||
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
|
||||
*rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
|
||||
)
|
||||
elif ColorMode.HS in supported_color_modes:
|
||||
params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color)
|
||||
elif ColorMode.XY in supported_color_modes:
|
||||
params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color)
|
||||
elif ColorMode.COLOR_TEMP in supported_color_modes:
|
||||
xy_color = color_util.color_RGB_to_xy(*rgb_color)
|
||||
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
|
||||
*xy_color
|
||||
)
|
||||
elif ATTR_RGBWW_COLOR in params and ColorMode.RGBWW not in supported_color_modes:
|
||||
rgbww_color = params.pop(ATTR_RGBWW_COLOR)
|
||||
assert rgbww_color is not None
|
||||
if TYPE_CHECKING:
|
||||
rgbww_color = cast(tuple[int, int, int, int, int], rgbww_color)
|
||||
rgb_color = color_util.color_rgbww_to_rgb(
|
||||
*rgbww_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
|
||||
)
|
||||
if ColorMode.RGB in supported_color_modes:
|
||||
params[ATTR_RGB_COLOR] = rgb_color
|
||||
elif ColorMode.RGBW in supported_color_modes:
|
||||
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
|
||||
elif ColorMode.HS in supported_color_modes:
|
||||
params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color)
|
||||
elif ColorMode.XY in supported_color_modes:
|
||||
params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color)
|
||||
elif ColorMode.COLOR_TEMP in supported_color_modes:
|
||||
xy_color = color_util.color_RGB_to_xy(*rgb_color)
|
||||
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
|
||||
*xy_color
|
||||
)
|
||||
|
||||
# If white is set to True, set it to the light's brightness
|
||||
# Add a warning in Home Assistant Core 2024.3 if the brightness is set to an
|
||||
# integer.
|
||||
if params.get(ATTR_WHITE) is True:
|
||||
params[ATTR_WHITE] = light.brightness
|
||||
|
||||
# If both white and brightness are specified, override white
|
||||
if ATTR_WHITE in params and ColorMode.WHITE in supported_color_modes:
|
||||
params[ATTR_WHITE] = params.pop(ATTR_BRIGHTNESS, params[ATTR_WHITE])
|
||||
|
||||
return params
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Expose light control via state machine and services."""
|
||||
component = hass.data[DATA_COMPONENT] = EntityComponent[LightEntity](
|
||||
_LOGGER, DOMAIN, hass, SCAN_INTERVAL
|
||||
@@ -330,177 +506,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
base["params"] = data
|
||||
return base
|
||||
|
||||
async def async_handle_light_on_service( # noqa: C901
|
||||
async def async_handle_light_on_service(
|
||||
light: LightEntity, call: ServiceCall
|
||||
) -> None:
|
||||
"""Handle turning a light on.
|
||||
|
||||
If brightness is set to 0, this service will turn the light off.
|
||||
"""
|
||||
params: dict[str, Any] = dict(call.data["params"])
|
||||
params = process_turn_on_params(hass, light, call.data["params"])
|
||||
|
||||
# Only process params once we processed brightness step
|
||||
if params and (
|
||||
ATTR_BRIGHTNESS_STEP in params or ATTR_BRIGHTNESS_STEP_PCT in params
|
||||
):
|
||||
brightness = light.brightness if light.is_on and light.brightness else 0
|
||||
|
||||
if ATTR_BRIGHTNESS_STEP in params:
|
||||
brightness += params.pop(ATTR_BRIGHTNESS_STEP)
|
||||
|
||||
else:
|
||||
brightness_pct = round(brightness / 255 * 100)
|
||||
brightness = round(
|
||||
(brightness_pct + params.pop(ATTR_BRIGHTNESS_STEP_PCT)) / 100 * 255
|
||||
)
|
||||
|
||||
params[ATTR_BRIGHTNESS] = max(0, min(255, brightness))
|
||||
|
||||
preprocess_turn_on_alternatives(hass, params)
|
||||
|
||||
if (not params or not light.is_on) or (
|
||||
params and ATTR_TRANSITION not in params
|
||||
):
|
||||
profiles.apply_default(light.entity_id, light.is_on, params)
|
||||
|
||||
supported_color_modes = light._light_internal_supported_color_modes # noqa: SLF001
|
||||
|
||||
# If a color temperature is specified, emulate it if not supported by the light
|
||||
if ATTR_COLOR_TEMP_KELVIN in params:
|
||||
if (
|
||||
ColorMode.COLOR_TEMP not in supported_color_modes
|
||||
and ColorMode.RGBWW in supported_color_modes
|
||||
):
|
||||
color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN)
|
||||
brightness = cast(int, params.get(ATTR_BRIGHTNESS, light.brightness))
|
||||
params[ATTR_RGBWW_COLOR] = color_util.color_temperature_to_rgbww(
|
||||
color_temp,
|
||||
brightness,
|
||||
light.min_color_temp_kelvin,
|
||||
light.max_color_temp_kelvin,
|
||||
)
|
||||
elif ColorMode.COLOR_TEMP not in supported_color_modes:
|
||||
color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN)
|
||||
if color_supported(supported_color_modes):
|
||||
params[ATTR_HS_COLOR] = color_util.color_temperature_to_hs(
|
||||
color_temp
|
||||
)
|
||||
|
||||
# If a color is specified, convert to the color space supported by the light
|
||||
rgb_color: tuple[int, int, int] | None
|
||||
rgbww_color: tuple[int, int, int, int, int] | None
|
||||
if ATTR_HS_COLOR in params and ColorMode.HS not in supported_color_modes:
|
||||
hs_color = params.pop(ATTR_HS_COLOR)
|
||||
if ColorMode.RGB in supported_color_modes:
|
||||
params[ATTR_RGB_COLOR] = color_util.color_hs_to_RGB(*hs_color)
|
||||
elif ColorMode.RGBW in supported_color_modes:
|
||||
rgb_color = color_util.color_hs_to_RGB(*hs_color)
|
||||
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
|
||||
elif ColorMode.RGBWW in supported_color_modes:
|
||||
rgb_color = color_util.color_hs_to_RGB(*hs_color)
|
||||
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
|
||||
*rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
|
||||
)
|
||||
elif ColorMode.XY in supported_color_modes:
|
||||
params[ATTR_XY_COLOR] = color_util.color_hs_to_xy(*hs_color)
|
||||
elif ColorMode.COLOR_TEMP in supported_color_modes:
|
||||
xy_color = color_util.color_hs_to_xy(*hs_color)
|
||||
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
|
||||
*xy_color
|
||||
)
|
||||
elif ATTR_RGB_COLOR in params and ColorMode.RGB not in supported_color_modes:
|
||||
rgb_color = params.pop(ATTR_RGB_COLOR)
|
||||
assert rgb_color is not None
|
||||
if TYPE_CHECKING:
|
||||
rgb_color = cast(tuple[int, int, int], rgb_color)
|
||||
if ColorMode.RGBW in supported_color_modes:
|
||||
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
|
||||
elif ColorMode.RGBWW in supported_color_modes:
|
||||
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
|
||||
*rgb_color,
|
||||
light.min_color_temp_kelvin,
|
||||
light.max_color_temp_kelvin,
|
||||
)
|
||||
elif ColorMode.HS in supported_color_modes:
|
||||
params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color)
|
||||
elif ColorMode.XY in supported_color_modes:
|
||||
params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color)
|
||||
elif ColorMode.COLOR_TEMP in supported_color_modes:
|
||||
xy_color = color_util.color_RGB_to_xy(*rgb_color)
|
||||
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
|
||||
*xy_color
|
||||
)
|
||||
elif ATTR_XY_COLOR in params and ColorMode.XY not in supported_color_modes:
|
||||
xy_color = params.pop(ATTR_XY_COLOR)
|
||||
if ColorMode.HS in supported_color_modes:
|
||||
params[ATTR_HS_COLOR] = color_util.color_xy_to_hs(*xy_color)
|
||||
elif ColorMode.RGB in supported_color_modes:
|
||||
params[ATTR_RGB_COLOR] = color_util.color_xy_to_RGB(*xy_color)
|
||||
elif ColorMode.RGBW in supported_color_modes:
|
||||
rgb_color = color_util.color_xy_to_RGB(*xy_color)
|
||||
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
|
||||
elif ColorMode.RGBWW in supported_color_modes:
|
||||
rgb_color = color_util.color_xy_to_RGB(*xy_color)
|
||||
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
|
||||
*rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
|
||||
)
|
||||
elif ColorMode.COLOR_TEMP in supported_color_modes:
|
||||
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
|
||||
*xy_color
|
||||
)
|
||||
elif ATTR_RGBW_COLOR in params and ColorMode.RGBW not in supported_color_modes:
|
||||
rgbw_color = params.pop(ATTR_RGBW_COLOR)
|
||||
rgb_color = color_util.color_rgbw_to_rgb(*rgbw_color)
|
||||
if ColorMode.RGB in supported_color_modes:
|
||||
params[ATTR_RGB_COLOR] = rgb_color
|
||||
elif ColorMode.RGBWW in supported_color_modes:
|
||||
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
|
||||
*rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
|
||||
)
|
||||
elif ColorMode.HS in supported_color_modes:
|
||||
params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color)
|
||||
elif ColorMode.XY in supported_color_modes:
|
||||
params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color)
|
||||
elif ColorMode.COLOR_TEMP in supported_color_modes:
|
||||
xy_color = color_util.color_RGB_to_xy(*rgb_color)
|
||||
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
|
||||
*xy_color
|
||||
)
|
||||
elif (
|
||||
ATTR_RGBWW_COLOR in params and ColorMode.RGBWW not in supported_color_modes
|
||||
):
|
||||
rgbww_color = params.pop(ATTR_RGBWW_COLOR)
|
||||
assert rgbww_color is not None
|
||||
if TYPE_CHECKING:
|
||||
rgbww_color = cast(tuple[int, int, int, int, int], rgbww_color)
|
||||
rgb_color = color_util.color_rgbww_to_rgb(
|
||||
*rgbww_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
|
||||
)
|
||||
if ColorMode.RGB in supported_color_modes:
|
||||
params[ATTR_RGB_COLOR] = rgb_color
|
||||
elif ColorMode.RGBW in supported_color_modes:
|
||||
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
|
||||
elif ColorMode.HS in supported_color_modes:
|
||||
params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color)
|
||||
elif ColorMode.XY in supported_color_modes:
|
||||
params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color)
|
||||
elif ColorMode.COLOR_TEMP in supported_color_modes:
|
||||
xy_color = color_util.color_RGB_to_xy(*rgb_color)
|
||||
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
|
||||
*xy_color
|
||||
)
|
||||
|
||||
# If white is set to True, set it to the light's brightness
|
||||
# Add a warning in Home Assistant Core 2024.3 if the brightness is set to an
|
||||
# integer.
|
||||
if params.get(ATTR_WHITE) is True:
|
||||
params[ATTR_WHITE] = light.brightness
|
||||
|
||||
# If both white and brightness are specified, override white
|
||||
if ATTR_WHITE in params and ColorMode.WHITE in supported_color_modes:
|
||||
params[ATTR_WHITE] = params.pop(ATTR_BRIGHTNESS, params[ATTR_WHITE])
|
||||
|
||||
# Remove deprecated white value if the light supports color mode
|
||||
if params.get(ATTR_BRIGHTNESS) == 0 or params.get(ATTR_WHITE) == 0:
|
||||
await async_handle_light_off_service(light, call)
|
||||
else:
|
||||
@@ -510,10 +524,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
light: LightEntity, call: ServiceCall
|
||||
) -> None:
|
||||
"""Handle turning off a light."""
|
||||
params = dict(call.data["params"])
|
||||
|
||||
if ATTR_TRANSITION not in params:
|
||||
profiles.apply_default(light.entity_id, True, params)
|
||||
params = process_turn_off_params(hass, light, call.data["params"])
|
||||
|
||||
await light.async_turn_off(**filter_turn_off_params(light, params))
|
||||
|
||||
@@ -521,10 +532,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
light: LightEntity, call: ServiceCall
|
||||
) -> None:
|
||||
"""Handle toggling a light."""
|
||||
if light.is_on:
|
||||
await async_handle_light_off_service(light, call)
|
||||
else:
|
||||
await async_handle_light_on_service(light, call)
|
||||
await light.async_toggle(**call.data["params"])
|
||||
|
||||
# Listen for light on and light off service calls.
|
||||
|
||||
@@ -1046,3 +1054,15 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
def supported_features(self) -> LightEntityFeature:
|
||||
"""Flag supported features."""
|
||||
return self._attr_supported_features
|
||||
|
||||
@override
|
||||
async def async_toggle(self, **kwargs: Any) -> None:
|
||||
"""Toggle the entity."""
|
||||
if not self.is_on:
|
||||
params = process_turn_on_params(self.hass, self, kwargs)
|
||||
if params.get(ATTR_BRIGHTNESS) != 0 and params.get(ATTR_WHITE) != 0:
|
||||
await self.async_turn_on(**filter_turn_on_params(self, params))
|
||||
return
|
||||
|
||||
params = process_turn_off_params(self.hass, self, kwargs)
|
||||
await self.async_turn_off(**filter_turn_off_params(self, params))
|
||||
|
||||
@@ -19,6 +19,5 @@ async def async_get_config_entry_diagnostics(
|
||||
return {
|
||||
"device_info": client.device_info,
|
||||
"vehicles": client.vehicles,
|
||||
"ct_connected": client.ct_connected,
|
||||
"cap_available": client.cap_available,
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["ohme==1.6.0"]
|
||||
"requirements": ["ohme==1.7.0"]
|
||||
}
|
||||
|
||||
@@ -431,6 +431,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, OpowerData]]):
|
||||
for source_id, source_stats in existing_stats.items():
|
||||
_LOGGER.debug("Found %d statistics for %s", len(source_stats), source_id)
|
||||
if not source_stats:
|
||||
need_migration_source_ids.remove(source_id)
|
||||
continue
|
||||
target_id = migration_map[source_id]
|
||||
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["opower"],
|
||||
"quality_scale": "bronze",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["opower==0.17.0"]
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ rules:
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices:
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/otbr",
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["python-otbr-api==2.8.0"]
|
||||
"requirements": ["python-otbr-api==2.9.0"]
|
||||
}
|
||||
|
||||
@@ -218,7 +218,6 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceState]):
|
||||
self.properties_api.smart_wash_params,
|
||||
self.properties_api.sound_volume,
|
||||
self.properties_api.child_lock,
|
||||
self.properties_api.dust_collection_mode,
|
||||
self.properties_api.flow_led_status,
|
||||
self.properties_api.valley_electricity_timer,
|
||||
)
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"loggers": ["roborock"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": [
|
||||
"python-roborock==4.17.2",
|
||||
"python-roborock==4.20.0",
|
||||
"vacuum-map-parser-roborock==0.1.4"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ from homeassistant.components.notify import (
|
||||
BaseNotificationService,
|
||||
)
|
||||
from homeassistant.components.telegram_bot import (
|
||||
ATTR_CHAT_ID,
|
||||
ATTR_DISABLE_NOTIF,
|
||||
ATTR_DISABLE_WEB_PREV,
|
||||
ATTR_MESSAGE_TAG,
|
||||
@@ -58,7 +59,7 @@ async def async_get_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"migrate_notify",
|
||||
breaks_in_ha_version="2026.5.0",
|
||||
breaks_in_ha_version="2026.8.0",
|
||||
is_fixable=False,
|
||||
translation_key="migrate_notify",
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
@@ -80,7 +81,7 @@ class TelegramNotificationService(BaseNotificationService):
|
||||
|
||||
def send_message(self, message: str = "", **kwargs: Any) -> None:
|
||||
"""Send a message to a user."""
|
||||
service_data = {ATTR_TARGET: kwargs.get(ATTR_TARGET, self._chat_id)}
|
||||
service_data = {ATTR_CHAT_ID: kwargs.get(ATTR_TARGET, self._chat_id)}
|
||||
data = kwargs.get(ATTR_DATA)
|
||||
|
||||
# Set message tag
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/thread",
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["python-otbr-api==2.8.0", "pyroute2==0.7.5"],
|
||||
"requirements": ["python-otbr-api==2.9.0", "pyroute2==0.7.5"],
|
||||
"single_config_entry": true,
|
||||
"zeroconf": ["_meshcop._udp.local."]
|
||||
}
|
||||
|
||||
@@ -57,7 +57,10 @@ from homeassistant.core import (
|
||||
from homeassistant.exceptions import TemplateError
|
||||
from homeassistant.helpers import entity_registry as er, location as loc_helper
|
||||
from homeassistant.helpers.singleton import singleton
|
||||
from homeassistant.helpers.translation import async_translate_state
|
||||
from homeassistant.helpers.translation import (
|
||||
async_translate_state,
|
||||
async_translate_state_attr,
|
||||
)
|
||||
from homeassistant.helpers.typing import TemplateVarsType
|
||||
from homeassistant.util import convert, location as location_util
|
||||
from homeassistant.util.async_ import run_callback_threadsafe
|
||||
@@ -807,6 +810,45 @@ class StateTranslated:
|
||||
return "<template StateTranslated>"
|
||||
|
||||
|
||||
class StateAttrTranslated:
|
||||
"""Class to represent a translated state attribute value in a template."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize."""
|
||||
self._hass = hass
|
||||
|
||||
def __call__(self, entity_id: str, attribute: str) -> str | None:
|
||||
"""Retrieve translated state attribute value if available."""
|
||||
state = _get_state_if_valid(self._hass, entity_id)
|
||||
|
||||
if state is None:
|
||||
return None
|
||||
|
||||
attr_value = state.attributes.get(attribute)
|
||||
if attr_value is None:
|
||||
return None
|
||||
|
||||
domain = state.domain
|
||||
device_class = state.attributes.get("device_class")
|
||||
entry = er.async_get(self._hass).async_get(entity_id)
|
||||
platform = None if entry is None else entry.platform
|
||||
translation_key = None if entry is None else entry.translation_key
|
||||
|
||||
return async_translate_state_attr(
|
||||
self._hass,
|
||||
str(attr_value),
|
||||
domain,
|
||||
platform,
|
||||
translation_key,
|
||||
device_class,
|
||||
attribute,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Representation of Translated state attribute."""
|
||||
return "<template StateAttrTranslated>"
|
||||
|
||||
|
||||
class DomainStates:
|
||||
"""Class to expose a specific HA domain as attributes."""
|
||||
|
||||
@@ -1989,6 +2031,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
|
||||
"is_state_attr",
|
||||
"is_state",
|
||||
"state_attr",
|
||||
"state_attr_translated",
|
||||
"state_translated",
|
||||
"states",
|
||||
]
|
||||
@@ -2036,9 +2079,11 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
|
||||
self.globals["is_state_attr"] = hassfunction(is_state_attr)
|
||||
self.globals["is_state"] = hassfunction(is_state)
|
||||
self.globals["state_attr"] = hassfunction(state_attr)
|
||||
self.globals["state_attr_translated"] = StateAttrTranslated(hass)
|
||||
self.globals["state_translated"] = StateTranslated(hass)
|
||||
self.globals["states"] = AllStates(hass)
|
||||
self.filters["state_attr"] = self.globals["state_attr"]
|
||||
self.filters["state_attr_translated"] = self.globals["state_attr_translated"]
|
||||
self.filters["state_translated"] = self.globals["state_translated"]
|
||||
self.filters["states"] = self.globals["states"]
|
||||
self.tests["is_state_attr"] = hassfunction(is_state_attr, pass_eval_context)
|
||||
@@ -2047,7 +2092,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
|
||||
def is_safe_callable(self, obj):
|
||||
"""Test if callback is safe."""
|
||||
return isinstance(
|
||||
obj, (AllStates, StateTranslated)
|
||||
obj, (AllStates, StateAttrTranslated, StateTranslated)
|
||||
) or super().is_safe_callable(obj)
|
||||
|
||||
def is_safe_attribute(self, obj, attr, value):
|
||||
|
||||
@@ -492,3 +492,43 @@ def async_translate_state(
|
||||
return translations[localize_key]
|
||||
|
||||
return state
|
||||
|
||||
|
||||
@callback
|
||||
def async_translate_state_attr(
|
||||
hass: HomeAssistant,
|
||||
attr_value: str,
|
||||
domain: str,
|
||||
platform: str | None,
|
||||
translation_key: str | None,
|
||||
device_class: str | None,
|
||||
attribute_name: str,
|
||||
) -> str:
|
||||
"""Translate provided state attribute value using cached translations for currently selected language."""
|
||||
language = hass.config.language
|
||||
if platform is not None and translation_key is not None:
|
||||
localize_key = (
|
||||
f"component.{platform}.entity.{domain}"
|
||||
f".{translation_key}.state_attributes.{attribute_name}"
|
||||
f".state.{attr_value}"
|
||||
)
|
||||
translations = async_get_cached_translations(hass, language, "entity")
|
||||
if localize_key in translations:
|
||||
return translations[localize_key]
|
||||
|
||||
translations = async_get_cached_translations(hass, language, "entity_component")
|
||||
if device_class is not None:
|
||||
localize_key = (
|
||||
f"component.{domain}.entity_component.{device_class}"
|
||||
f".state_attributes.{attribute_name}.state.{attr_value}"
|
||||
)
|
||||
if localize_key in translations:
|
||||
return translations[localize_key]
|
||||
localize_key = (
|
||||
f"component.{domain}.entity_component._"
|
||||
f".state_attributes.{attribute_name}.state.{attr_value}"
|
||||
)
|
||||
if localize_key in translations:
|
||||
return translations[localize_key]
|
||||
|
||||
return attr_value
|
||||
|
||||
10
requirements_all.txt
generated
10
requirements_all.txt
generated
@@ -1026,7 +1026,7 @@ gTTS==2.5.3
|
||||
|
||||
# homeassistant.components.gardena_bluetooth
|
||||
# homeassistant.components.husqvarna_automower_ble
|
||||
gardena-bluetooth==1.6.0
|
||||
gardena-bluetooth==2.1.0
|
||||
|
||||
# homeassistant.components.google_assistant_sdk
|
||||
gassist-text==0.0.14
|
||||
@@ -1663,7 +1663,7 @@ odp-amsterdam==6.1.2
|
||||
oemthermostat==1.1.1
|
||||
|
||||
# homeassistant.components.ohme
|
||||
ohme==1.6.0
|
||||
ohme==1.7.0
|
||||
|
||||
# homeassistant.components.ollama
|
||||
ollama==0.5.1
|
||||
@@ -2188,7 +2188,7 @@ pyitachip2ir==0.0.7
|
||||
pyituran==0.1.5
|
||||
|
||||
# homeassistant.components.jvc_projector
|
||||
pyjvcprojector==2.0.2
|
||||
pyjvcprojector==2.0.3
|
||||
|
||||
# homeassistant.components.kaleidescape
|
||||
pykaleidescape==1.1.3
|
||||
@@ -2621,7 +2621,7 @@ python-opensky==1.0.1
|
||||
|
||||
# homeassistant.components.otbr
|
||||
# homeassistant.components.thread
|
||||
python-otbr-api==2.8.0
|
||||
python-otbr-api==2.9.0
|
||||
|
||||
# homeassistant.components.overseerr
|
||||
python-overseerr==0.9.0
|
||||
@@ -2639,7 +2639,7 @@ python-rabbitair==0.0.8
|
||||
python-ripple-api==0.0.3
|
||||
|
||||
# homeassistant.components.roborock
|
||||
python-roborock==4.17.2
|
||||
python-roborock==4.20.0
|
||||
|
||||
# homeassistant.components.smarttub
|
||||
python-smarttub==0.0.47
|
||||
|
||||
10
requirements_test_all.txt
generated
10
requirements_test_all.txt
generated
@@ -905,7 +905,7 @@ gTTS==2.5.3
|
||||
|
||||
# homeassistant.components.gardena_bluetooth
|
||||
# homeassistant.components.husqvarna_automower_ble
|
||||
gardena-bluetooth==1.6.0
|
||||
gardena-bluetooth==2.1.0
|
||||
|
||||
# homeassistant.components.google_assistant_sdk
|
||||
gassist-text==0.0.14
|
||||
@@ -1449,7 +1449,7 @@ objgraph==3.5.0
|
||||
odp-amsterdam==6.1.2
|
||||
|
||||
# homeassistant.components.ohme
|
||||
ohme==1.6.0
|
||||
ohme==1.7.0
|
||||
|
||||
# homeassistant.components.ollama
|
||||
ollama==0.5.1
|
||||
@@ -1868,7 +1868,7 @@ pyisy==3.4.1
|
||||
pyituran==0.1.5
|
||||
|
||||
# homeassistant.components.jvc_projector
|
||||
pyjvcprojector==2.0.2
|
||||
pyjvcprojector==2.0.3
|
||||
|
||||
# homeassistant.components.kaleidescape
|
||||
pykaleidescape==1.1.3
|
||||
@@ -2220,7 +2220,7 @@ python-opensky==1.0.1
|
||||
|
||||
# homeassistant.components.otbr
|
||||
# homeassistant.components.thread
|
||||
python-otbr-api==2.8.0
|
||||
python-otbr-api==2.9.0
|
||||
|
||||
# homeassistant.components.overseerr
|
||||
python-overseerr==0.9.0
|
||||
@@ -2235,7 +2235,7 @@ python-pooldose==0.8.2
|
||||
python-rabbitair==0.0.8
|
||||
|
||||
# homeassistant.components.roborock
|
||||
python-roborock==4.17.2
|
||||
python-roborock==4.20.0
|
||||
|
||||
# homeassistant.components.smarttub
|
||||
python-smarttub==0.0.47
|
||||
|
||||
@@ -77,6 +77,7 @@ NO_IOT_CLASS = [
|
||||
"file_upload",
|
||||
"frontend",
|
||||
"garage_door",
|
||||
"gate",
|
||||
"hardkernel",
|
||||
"hardware",
|
||||
"history",
|
||||
|
||||
@@ -2112,6 +2112,7 @@ NO_QUALITY_SCALE = [
|
||||
"file_upload",
|
||||
"frontend",
|
||||
"garage_door",
|
||||
"gate",
|
||||
"hardkernel",
|
||||
"hardware",
|
||||
"history",
|
||||
|
||||
@@ -8,14 +8,11 @@ from arcam.fmj.state import State
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.arcam_fmj.const import DEFAULT_NAME
|
||||
from homeassistant.components.arcam_fmj.coordinator import ArcamFmjCoordinator
|
||||
from homeassistant.components.arcam_fmj.media_player import ArcamFmj
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import EntityPlatformState
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry, MockEntityPlatform
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
MOCK_HOST = "127.0.0.1"
|
||||
MOCK_PORT = 50000
|
||||
@@ -73,12 +70,6 @@ def state_2_fixture(client: Mock) -> State:
|
||||
return state
|
||||
|
||||
|
||||
@pytest.fixture(name="state")
|
||||
def state_fixture(state_1: State) -> State:
|
||||
"""Get a mocked state."""
|
||||
return state_1
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_config_entry")
|
||||
def mock_config_entry_fixture(hass: HomeAssistant) -> MockConfigEntry:
|
||||
"""Get a mock config entry."""
|
||||
@@ -92,29 +83,6 @@ def mock_config_entry_fixture(hass: HomeAssistant) -> MockConfigEntry:
|
||||
return config_entry
|
||||
|
||||
|
||||
@pytest.fixture(name="player")
|
||||
def player_fixture(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
client: Mock,
|
||||
state_1: Mock,
|
||||
) -> ArcamFmj:
|
||||
"""Get standard player.
|
||||
|
||||
This fixture tests internals and should not be used going forward.
|
||||
"""
|
||||
coordinator = ArcamFmjCoordinator(hass, mock_config_entry, client, 1)
|
||||
coordinator.state = state_1
|
||||
coordinator.last_update_success = True
|
||||
player = ArcamFmj(MOCK_NAME, coordinator, MOCK_UUID)
|
||||
player.entity_id = MOCK_ENTITY_ID
|
||||
player.hass = hass
|
||||
player.platform = MockEntityPlatform(hass)
|
||||
player._platform_state = EntityPlatformState.ADDED
|
||||
player.async_write_ha_state = Mock()
|
||||
return player
|
||||
|
||||
|
||||
@pytest.fixture(name="player_setup")
|
||||
async def player_setup_fixture(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""The tests for Arcam FMJ Receiver control device triggers."""
|
||||
|
||||
from arcam.fmj.state import State
|
||||
|
||||
from homeassistant.components import automation
|
||||
from homeassistant.components.arcam_fmj.const import DOMAIN
|
||||
from homeassistant.components.device_automation import DeviceAutomationType
|
||||
@@ -54,12 +56,12 @@ async def test_if_fires_on_turn_on_request(
|
||||
entity_registry: er.EntityRegistry,
|
||||
service_calls: list[ServiceCall],
|
||||
player_setup,
|
||||
state,
|
||||
state_1: State,
|
||||
) -> None:
|
||||
"""Test for turn_on and turn_off triggers firing."""
|
||||
entry = entity_registry.async_get(player_setup)
|
||||
|
||||
state.get_power.return_value = None
|
||||
state_1.get_power.return_value = None
|
||||
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
@@ -104,12 +106,12 @@ async def test_if_fires_on_turn_on_request_legacy(
|
||||
entity_registry: er.EntityRegistry,
|
||||
service_calls: list[ServiceCall],
|
||||
player_setup,
|
||||
state,
|
||||
state_1: State,
|
||||
) -> None:
|
||||
"""Test for turn_on and turn_off triggers firing."""
|
||||
entry = entity_registry.async_get(player_setup)
|
||||
|
||||
state.get_power.return_value = None
|
||||
state_1.get_power.return_value = None
|
||||
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Tests for arcam fmj receivers."""
|
||||
|
||||
from math import isclose
|
||||
from unittest.mock import PropertyMock, patch
|
||||
from unittest.mock import Mock, PropertyMock, patch
|
||||
|
||||
from arcam.fmj import ConnectionFailed, DecodeMode2CH, DecodeModeMCH, SourceCodes
|
||||
from arcam.fmj.state import State
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.arcam_fmj.media_player import ArcamFmj
|
||||
@@ -16,6 +17,7 @@ from homeassistant.components.media_player import (
|
||||
ATTR_MEDIA_VOLUME_LEVEL,
|
||||
ATTR_SOUND_MODE,
|
||||
ATTR_SOUND_MODE_LIST,
|
||||
DATA_COMPONENT,
|
||||
SERVICE_SELECT_SOURCE,
|
||||
SERVICE_VOLUME_SET,
|
||||
MediaType,
|
||||
@@ -30,7 +32,9 @@ from homeassistant.const import (
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .conftest import MOCK_HOST, MOCK_UUID
|
||||
from .conftest import MOCK_ENTITY_ID, MOCK_HOST, MOCK_UUID
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
MOCK_TURN_ON = {
|
||||
"service": "switch.turn_on",
|
||||
@@ -38,13 +42,30 @@ MOCK_TURN_ON = {
|
||||
}
|
||||
|
||||
|
||||
async def update(player, force_refresh=False):
|
||||
@pytest.fixture(name="player")
|
||||
def player_fixture(
|
||||
hass: HomeAssistant,
|
||||
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, state) -> None:
|
||||
async def test_properties(player: ArcamFmj) -> None:
|
||||
"""Test standard properties."""
|
||||
assert player.unique_id == f"{MOCK_UUID}-1"
|
||||
assert player.device_info == {
|
||||
@@ -58,64 +79,67 @@ async def test_properties(player, state) -> None:
|
||||
assert not player.should_poll
|
||||
|
||||
|
||||
async def test_powered_off(hass: HomeAssistant, player, state) -> None:
|
||||
async def test_powered_off(
|
||||
hass: HomeAssistant, player: ArcamFmj, state_1: State
|
||||
) -> None:
|
||||
"""Test properties in powered off state."""
|
||||
state.get_source.return_value = None
|
||||
state.get_power.return_value = None
|
||||
state_1.get_source.return_value = None
|
||||
state_1.get_power.return_value = None
|
||||
|
||||
data = await update(player)
|
||||
assert "source" not in data.attributes
|
||||
assert data.state == "off"
|
||||
|
||||
|
||||
async def test_powered_on(player, state) -> None:
|
||||
async def test_powered_on(player: ArcamFmj, state_1: State) -> None:
|
||||
"""Test properties in powered on state."""
|
||||
state.get_source.return_value = SourceCodes.PVR
|
||||
state.get_power.return_value = True
|
||||
state_1.get_source.return_value = SourceCodes.PVR
|
||||
state_1.get_power.return_value = True
|
||||
|
||||
data = await update(player)
|
||||
assert data.attributes["source"] == "PVR"
|
||||
assert data.state == "on"
|
||||
|
||||
|
||||
async def test_supported_features(player, state) -> None:
|
||||
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, state) -> None:
|
||||
async def test_turn_on(player: ArcamFmj, state_1: State) -> None:
|
||||
"""Test turn on service."""
|
||||
state.get_power.return_value = None
|
||||
state_1.get_power.return_value = None
|
||||
await player.async_turn_on()
|
||||
state.set_power.assert_not_called()
|
||||
state_1.set_power.assert_not_called()
|
||||
|
||||
state.get_power.return_value = False
|
||||
state_1.get_power.return_value = False
|
||||
await player.async_turn_on()
|
||||
state.set_power.assert_called_with(True)
|
||||
state_1.set_power.assert_called_with(True)
|
||||
|
||||
|
||||
async def test_turn_off(player, state) -> None:
|
||||
async def test_turn_off(player: ArcamFmj, state_1: State) -> None:
|
||||
"""Test command to turn off."""
|
||||
await player.async_turn_off()
|
||||
state.set_power.assert_called_with(False)
|
||||
state_1.set_power.assert_called_with(False)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mute", [True, False])
|
||||
async def test_mute_volume(player, state, mute) -> None:
|
||||
async def test_mute_volume(player: ArcamFmj, state_1: State, mute: bool) -> None:
|
||||
"""Test mute functionality."""
|
||||
player.async_write_ha_state.reset_mock()
|
||||
await player.async_mute_volume(mute)
|
||||
state.set_mute.assert_called_with(mute)
|
||||
state_1.set_mute.assert_called_with(mute)
|
||||
player.async_write_ha_state.assert_called_with()
|
||||
|
||||
|
||||
async def test_name(player) -> None:
|
||||
async def test_name(player: ArcamFmj) -> None:
|
||||
"""Test name."""
|
||||
data = await update(player)
|
||||
assert data.attributes["friendly_name"] == "Zone 1"
|
||||
assert data.attributes["friendly_name"] == "Arcam FMJ (127.0.0.1) Zone 1"
|
||||
|
||||
|
||||
async def test_update(hass: HomeAssistant, player_setup: str, state) -> None:
|
||||
async def test_update(hass: HomeAssistant, player_setup: str, state_1: State) -> None:
|
||||
"""Test update."""
|
||||
await hass.services.async_call(
|
||||
HA_DOMAIN,
|
||||
@@ -123,14 +147,17 @@ async def test_update(hass: HomeAssistant, player_setup: str, state) -> None:
|
||||
service_data={ATTR_ENTITY_ID: player_setup},
|
||||
blocking=True,
|
||||
)
|
||||
state.update.assert_called_with()
|
||||
state_1.update.assert_called_with()
|
||||
|
||||
|
||||
async def test_update_lost(
|
||||
hass: HomeAssistant, player_setup: str, state, caplog: pytest.LogCaptureFixture
|
||||
hass: HomeAssistant,
|
||||
player_setup: str,
|
||||
state_1: State,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test update, with connection loss is ignored."""
|
||||
state.update.side_effect = ConnectionFailed()
|
||||
state_1.update.side_effect = ConnectionFailed()
|
||||
|
||||
await hass.services.async_call(
|
||||
HA_DOMAIN,
|
||||
@@ -138,7 +165,7 @@ async def test_update_lost(
|
||||
service_data={ATTR_ENTITY_ID: player_setup},
|
||||
blocking=True,
|
||||
)
|
||||
state.update.assert_called_with()
|
||||
state_1.update.assert_called_with()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -146,7 +173,11 @@ async def test_update_lost(
|
||||
[("PVR", SourceCodes.PVR), ("BD", SourceCodes.BD), ("INVALID", None)],
|
||||
)
|
||||
async def test_select_source(
|
||||
hass: HomeAssistant, player_setup, state, source, value
|
||||
hass: HomeAssistant,
|
||||
player_setup,
|
||||
state_1: State,
|
||||
source: str,
|
||||
value: SourceCodes | None,
|
||||
) -> None:
|
||||
"""Test selection of source."""
|
||||
await hass.services.async_call(
|
||||
@@ -157,14 +188,14 @@ async def test_select_source(
|
||||
)
|
||||
|
||||
if value:
|
||||
state.set_source.assert_called_with(value)
|
||||
state_1.set_source.assert_called_with(value)
|
||||
else:
|
||||
state.set_source.assert_not_called()
|
||||
state_1.set_source.assert_not_called()
|
||||
|
||||
|
||||
async def test_source_list(player, state) -> None:
|
||||
async def test_source_list(player: ArcamFmj, state_1: State) -> None:
|
||||
"""Test source list."""
|
||||
state.get_source_list.return_value = [SourceCodes.BD]
|
||||
state_1.get_source_list.return_value = [SourceCodes.BD]
|
||||
data = await update(player)
|
||||
assert data.attributes["source_list"] == ["BD"]
|
||||
|
||||
@@ -176,23 +207,25 @@ async def test_source_list(player, state) -> None:
|
||||
"DOLBY_PL",
|
||||
],
|
||||
)
|
||||
async def test_select_sound_mode(player, state, mode) -> None:
|
||||
async def test_select_sound_mode(player: ArcamFmj, state_1: State, mode: str) -> None:
|
||||
"""Test selection sound mode."""
|
||||
await player.async_select_sound_mode(mode)
|
||||
state.set_decode_mode.assert_called_with(mode)
|
||||
state_1.set_decode_mode.assert_called_with(mode)
|
||||
|
||||
|
||||
async def test_volume_up(player, state) -> None:
|
||||
async def test_volume_up(player: ArcamFmj, state_1: State) -> None:
|
||||
"""Test mute functionality."""
|
||||
player.async_write_ha_state.reset_mock()
|
||||
await player.async_volume_up()
|
||||
state.inc_volume.assert_called_with()
|
||||
state_1.inc_volume.assert_called_with()
|
||||
player.async_write_ha_state.assert_called_with()
|
||||
|
||||
|
||||
async def test_volume_down(player, state) -> None:
|
||||
async def test_volume_down(player: ArcamFmj, state_1: State) -> None:
|
||||
"""Test mute functionality."""
|
||||
player.async_write_ha_state.reset_mock()
|
||||
await player.async_volume_down()
|
||||
state.dec_volume.assert_called_with()
|
||||
state_1.dec_volume.assert_called_with()
|
||||
player.async_write_ha_state.assert_called_with()
|
||||
|
||||
|
||||
@@ -204,9 +237,9 @@ async def test_volume_down(player, state) -> None:
|
||||
(None, None),
|
||||
],
|
||||
)
|
||||
async def test_sound_mode(player, state, mode, mode_enum) -> None:
|
||||
async def test_sound_mode(player: ArcamFmj, state_1: State, mode, mode_enum) -> None:
|
||||
"""Test selection sound mode."""
|
||||
state.get_decode_mode.return_value = mode_enum
|
||||
state_1.get_decode_mode.return_value = mode_enum
|
||||
data = await update(player)
|
||||
assert data.attributes.get(ATTR_SOUND_MODE) == mode
|
||||
|
||||
@@ -219,38 +252,40 @@ async def test_sound_mode(player, state, mode, mode_enum) -> None:
|
||||
(None, None),
|
||||
],
|
||||
)
|
||||
async def test_sound_mode_list(player, state, modes, modes_enum) -> None:
|
||||
async def test_sound_mode_list(
|
||||
player: ArcamFmj, state_1: State, modes, modes_enum
|
||||
) -> None:
|
||||
"""Test sound mode list."""
|
||||
state.get_decode_modes.return_value = modes_enum
|
||||
state_1.get_decode_modes.return_value = modes_enum
|
||||
data = await update(player)
|
||||
assert data.attributes.get(ATTR_SOUND_MODE_LIST) == modes
|
||||
|
||||
|
||||
async def test_is_volume_muted(player, state) -> None:
|
||||
async def test_is_volume_muted(player: ArcamFmj, state_1: State) -> None:
|
||||
"""Test muted."""
|
||||
state.get_mute.return_value = True
|
||||
state_1.get_mute.return_value = True
|
||||
assert player.is_volume_muted is True
|
||||
state.get_mute.return_value = False
|
||||
state_1.get_mute.return_value = False
|
||||
assert player.is_volume_muted is False
|
||||
state.get_mute.return_value = None
|
||||
state_1.get_mute.return_value = None
|
||||
assert player.is_volume_muted is None
|
||||
|
||||
|
||||
async def test_volume_level(player, state) -> None:
|
||||
async def test_volume_level(player: ArcamFmj, state_1: State) -> None:
|
||||
"""Test volume."""
|
||||
state.get_volume.return_value = 0
|
||||
state_1.get_volume.return_value = 0
|
||||
assert isclose(player.volume_level, 0.0)
|
||||
state.get_volume.return_value = 50
|
||||
state_1.get_volume.return_value = 50
|
||||
assert isclose(player.volume_level, 50.0 / 99)
|
||||
state.get_volume.return_value = 99
|
||||
state_1.get_volume.return_value = 99
|
||||
assert isclose(player.volume_level, 1.0)
|
||||
state.get_volume.return_value = None
|
||||
state_1.get_volume.return_value = None
|
||||
assert player.volume_level is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("volume", "call"), [(0.0, 0), (0.5, 50), (1.0, 99)])
|
||||
async def test_set_volume_level(
|
||||
hass: HomeAssistant, player_setup: str, state, volume, call
|
||||
hass: HomeAssistant, player_setup: str, state_1: State, volume, call
|
||||
) -> None:
|
||||
"""Test setting volume."""
|
||||
|
||||
@@ -261,15 +296,15 @@ async def test_set_volume_level(
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
state.set_volume.assert_called_with(call)
|
||||
state_1.set_volume.assert_called_with(call)
|
||||
|
||||
|
||||
async def test_set_volume_level_lost(
|
||||
hass: HomeAssistant, player_setup: str, state
|
||||
hass: HomeAssistant, player_setup: str, state_1: State
|
||||
) -> None:
|
||||
"""Test setting volume, with a lost connection."""
|
||||
|
||||
state.set_volume.side_effect = ConnectionFailed()
|
||||
state_1.set_volume.side_effect = ConnectionFailed()
|
||||
|
||||
with pytest.raises(HomeAssistantError):
|
||||
await hass.services.async_call(
|
||||
@@ -289,9 +324,11 @@ async def test_set_volume_level_lost(
|
||||
(None, None),
|
||||
],
|
||||
)
|
||||
async def test_media_content_type(player, state, source, media_content_type) -> None:
|
||||
async def test_media_content_type(
|
||||
player: ArcamFmj, state_1: State, source, media_content_type
|
||||
) -> None:
|
||||
"""Test content type deduction."""
|
||||
state.get_source.return_value = source
|
||||
state_1.get_source.return_value = source
|
||||
assert player.media_content_type == media_content_type
|
||||
|
||||
|
||||
@@ -305,11 +342,13 @@ async def test_media_content_type(player, state, source, media_content_type) ->
|
||||
(SourceCodes.PVR, "dab", "rds", None),
|
||||
],
|
||||
)
|
||||
async def test_media_channel(player, state, source, dab, rds, channel) -> None:
|
||||
async def test_media_channel(
|
||||
player: ArcamFmj, state_1: State, source, dab, rds, channel
|
||||
) -> None:
|
||||
"""Test media channel."""
|
||||
state.get_dab_station.return_value = dab
|
||||
state.get_rds_information.return_value = rds
|
||||
state.get_source.return_value = source
|
||||
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
|
||||
|
||||
|
||||
@@ -321,10 +360,12 @@ async def test_media_channel(player, state, source, dab, rds, channel) -> None:
|
||||
(SourceCodes.DAB, None, None),
|
||||
],
|
||||
)
|
||||
async def test_media_artist(player, state, source, dls, artist) -> None:
|
||||
async def test_media_artist(
|
||||
player: ArcamFmj, state_1: State, source, dls, artist
|
||||
) -> None:
|
||||
"""Test media artist."""
|
||||
state.get_dls_pdt.return_value = dls
|
||||
state.get_source.return_value = source
|
||||
state_1.get_dls_pdt.return_value = dls
|
||||
state_1.get_source.return_value = source
|
||||
assert player.media_artist == artist
|
||||
|
||||
|
||||
@@ -336,10 +377,12 @@ async def test_media_artist(player, state, source, dls, artist) -> None:
|
||||
(None, None, None),
|
||||
],
|
||||
)
|
||||
async def test_media_title(player, state, source, channel, title) -> None:
|
||||
async def test_media_title(
|
||||
player: ArcamFmj, state_1: State, source, channel, title
|
||||
) -> None:
|
||||
"""Test media title."""
|
||||
|
||||
state.get_source.return_value = source
|
||||
state_1.get_source.return_value = source
|
||||
with patch.object(
|
||||
ArcamFmj, "media_channel", new_callable=PropertyMock
|
||||
) as media_channel:
|
||||
|
||||
@@ -5619,10 +5619,10 @@
|
||||
# name: test_generate[None].6
|
||||
dict({
|
||||
'event': dict({
|
||||
'agent_id': 'backup.local',
|
||||
'manager_state': 'create_backup',
|
||||
'reason': None,
|
||||
'stage': None,
|
||||
'state': 'completed',
|
||||
'total_bytes': 10240,
|
||||
'uploaded_bytes': 10240,
|
||||
}),
|
||||
'id': 1,
|
||||
'type': 'event',
|
||||
@@ -5694,10 +5694,10 @@
|
||||
# name: test_generate[data1].6
|
||||
dict({
|
||||
'event': dict({
|
||||
'agent_id': 'backup.local',
|
||||
'manager_state': 'create_backup',
|
||||
'reason': None,
|
||||
'stage': None,
|
||||
'state': 'completed',
|
||||
'total_bytes': 10240,
|
||||
'uploaded_bytes': 10240,
|
||||
}),
|
||||
'id': 1,
|
||||
'type': 'event',
|
||||
@@ -5769,10 +5769,10 @@
|
||||
# name: test_generate[data2].6
|
||||
dict({
|
||||
'event': dict({
|
||||
'agent_id': 'backup.local',
|
||||
'manager_state': 'create_backup',
|
||||
'reason': None,
|
||||
'stage': None,
|
||||
'state': 'completed',
|
||||
'total_bytes': 10240,
|
||||
'uploaded_bytes': 10240,
|
||||
}),
|
||||
'id': 1,
|
||||
'type': 'event',
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from collections.abc import Callable, Generator
|
||||
from dataclasses import replace
|
||||
from datetime import timedelta
|
||||
from io import StringIO
|
||||
import json
|
||||
from pathlib import Path
|
||||
@@ -47,12 +48,14 @@ from homeassistant.components.backup.manager import (
|
||||
ReceiveBackupStage,
|
||||
ReceiveBackupState,
|
||||
RestoreBackupState,
|
||||
UploadBackupEvent,
|
||||
WrittenBackup,
|
||||
)
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STARTED
|
||||
from homeassistant.core import CoreState, HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .common import (
|
||||
LOCAL_AGENT_ID,
|
||||
@@ -65,6 +68,7 @@ from .common import (
|
||||
setup_backup_platform,
|
||||
)
|
||||
|
||||
from tests.common import async_fire_time_changed
|
||||
from tests.typing import ClientSessionGenerator, WebSocketGenerator
|
||||
|
||||
_EXPECTED_FILES = [
|
||||
@@ -596,7 +600,10 @@ async def test_initiate_backup(
|
||||
"state": CreateBackupState.IN_PROGRESS,
|
||||
}
|
||||
|
||||
# Consume any upload progress events before the final state event
|
||||
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,
|
||||
@@ -843,7 +850,10 @@ async def test_initiate_backup_with_agent_error(
|
||||
"state": CreateBackupState.IN_PROGRESS,
|
||||
}
|
||||
|
||||
# Consume any upload progress events before the final state event
|
||||
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": "upload_failed",
|
||||
@@ -1401,7 +1411,10 @@ async def test_initiate_backup_non_agent_upload_error(
|
||||
"state": CreateBackupState.IN_PROGRESS,
|
||||
}
|
||||
|
||||
# Consume any upload progress events before the final state event
|
||||
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": "upload_failed",
|
||||
@@ -1594,7 +1607,10 @@ async def test_initiate_backup_file_error_upload_to_agents(
|
||||
"state": CreateBackupState.IN_PROGRESS,
|
||||
}
|
||||
|
||||
# Consume any upload progress events before the final state event
|
||||
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": "upload_failed",
|
||||
@@ -2709,7 +2725,10 @@ async def test_receive_backup_file_read_error(
|
||||
"state": ReceiveBackupState.IN_PROGRESS,
|
||||
}
|
||||
|
||||
# Consume any upload progress events before the final state event
|
||||
result = await ws_client.receive_json()
|
||||
while "uploaded_bytes" in result["event"]:
|
||||
result = await ws_client.receive_json()
|
||||
assert result["event"] == {
|
||||
"manager_state": BackupManagerState.RECEIVE_BACKUP,
|
||||
"reason": final_state_reason,
|
||||
@@ -3526,7 +3545,10 @@ async def test_initiate_backup_per_agent_encryption(
|
||||
"state": CreateBackupState.IN_PROGRESS,
|
||||
}
|
||||
|
||||
# Consume any upload progress events before the final state event
|
||||
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,
|
||||
@@ -3761,25 +3783,114 @@ async def test_upload_progress_event(
|
||||
result = await ws_client.receive_json()
|
||||
assert result["event"]["stage"] == CreateBackupStage.UPLOAD_TO_AGENTS
|
||||
|
||||
# Upload progress events for the remote agent
|
||||
# Collect all upload progress events until the final state event
|
||||
progress_events = []
|
||||
result = await ws_client.receive_json()
|
||||
assert result["event"] == {
|
||||
"manager_state": BackupManagerState.CREATE_BACKUP,
|
||||
"agent_id": "test.remote",
|
||||
"uploaded_bytes": 500,
|
||||
"total_bytes": ANY,
|
||||
}
|
||||
while "uploaded_bytes" in result["event"]:
|
||||
progress_events.append(result["event"])
|
||||
result = await ws_client.receive_json()
|
||||
|
||||
result = await ws_client.receive_json()
|
||||
assert result["event"] == {
|
||||
"manager_state": BackupManagerState.CREATE_BACKUP,
|
||||
"agent_id": "test.remote",
|
||||
"uploaded_bytes": 1000,
|
||||
"total_bytes": ANY,
|
||||
}
|
||||
# Verify progress events from the remote agent (500 from agent + final from manager)
|
||||
remote_progress = [e for e in progress_events if e["agent_id"] == "test.remote"]
|
||||
assert len(remote_progress) == 2
|
||||
assert remote_progress[0]["uploaded_bytes"] == 500
|
||||
assert remote_progress[1]["uploaded_bytes"] == remote_progress[1]["total_bytes"]
|
||||
|
||||
# Verify progress event from the local agent (final from manager)
|
||||
local_progress = [e for e in progress_events if e["agent_id"] == LOCAL_AGENT_ID]
|
||||
assert len(local_progress) == 1
|
||||
assert local_progress[0]["uploaded_bytes"] == local_progress[0]["total_bytes"]
|
||||
|
||||
result = await ws_client.receive_json()
|
||||
assert result["event"]["state"] == CreateBackupState.COMPLETED
|
||||
|
||||
result = await ws_client.receive_json()
|
||||
assert result["event"] == {"manager_state": BackupManagerState.IDLE}
|
||||
|
||||
|
||||
async def test_upload_progress_debounced(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
generate_backup_id: MagicMock,
|
||||
) -> None:
|
||||
"""Test that rapid upload progress events are debounced.
|
||||
|
||||
Verify that when the on_progress callback is called multiple times during
|
||||
the debounce cooldown period, only the latest event is fired.
|
||||
"""
|
||||
agent_ids = ["test.remote"]
|
||||
mock_agents = await setup_backup_integration(hass, remote_agents=["test.remote"])
|
||||
manager = hass.data[DATA_MANAGER]
|
||||
|
||||
remote_agent = mock_agents["test.remote"]
|
||||
|
||||
progress_done = asyncio.Event()
|
||||
upload_done = asyncio.Event()
|
||||
|
||||
async def upload_with_progress(**kwargs: Any) -> None:
|
||||
"""Upload and report progress."""
|
||||
on_progress = kwargs["on_progress"]
|
||||
# First call fires immediately
|
||||
on_progress(bytes_uploaded=100)
|
||||
# These two are buffered during cooldown; 1000 should replace 500
|
||||
on_progress(bytes_uploaded=500)
|
||||
on_progress(bytes_uploaded=1000)
|
||||
progress_done.set()
|
||||
await upload_done.wait()
|
||||
|
||||
remote_agent.async_upload_backup.side_effect = upload_with_progress
|
||||
|
||||
# Subscribe directly to collect all events
|
||||
events: list[Any] = []
|
||||
manager.async_subscribe_events(events.append)
|
||||
|
||||
ws_client = await hass_ws_client(hass)
|
||||
|
||||
with patch("pathlib.Path.open", mock_open(read_data=b"test")):
|
||||
await ws_client.send_json_auto_id(
|
||||
{"type": "backup/generate", "agent_ids": agent_ids}
|
||||
)
|
||||
result = await ws_client.receive_json()
|
||||
assert result["success"] is True
|
||||
|
||||
# Wait for upload to reach the sync point (progress reported, upload paused)
|
||||
await progress_done.wait()
|
||||
|
||||
# At this point the debouncer's cooldown timer is pending.
|
||||
# The first event (100 bytes) fired immediately, 500 and 1000 are buffered.
|
||||
remote_events = [
|
||||
e
|
||||
for e in events
|
||||
if isinstance(e, UploadBackupEvent) and e.agent_id == "test.remote"
|
||||
]
|
||||
assert len(remote_events) == 1
|
||||
assert remote_events[0].uploaded_bytes == 100
|
||||
|
||||
# Advance time past the cooldown to trigger the debouncer timer.
|
||||
# This fires the coalesced event: 500 was replaced by 1000.
|
||||
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2))
|
||||
|
||||
remote_events = [
|
||||
e
|
||||
for e in events
|
||||
if isinstance(e, UploadBackupEvent) and e.agent_id == "test.remote"
|
||||
]
|
||||
assert len(remote_events) == 2
|
||||
assert remote_events[0].uploaded_bytes == 100
|
||||
assert remote_events[1].uploaded_bytes == 1000
|
||||
|
||||
# Let the upload finish
|
||||
upload_done.set()
|
||||
# Fire pending timers so the backup task can complete
|
||||
async_fire_time_changed(
|
||||
hass, dt_util.utcnow() + timedelta(seconds=10), fire_all=True
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Check the final 100% progress event is sent, that is sent for every agent
|
||||
remote_events = [
|
||||
e
|
||||
for e in events
|
||||
if isinstance(e, UploadBackupEvent) and e.agent_id == "test.remote"
|
||||
]
|
||||
assert len(remote_events) == 3
|
||||
assert remote_events[2].uploaded_bytes == remote_events[2].total_bytes
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
"""Test binary sensor 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) -> tuple[list[str], list[str]]:
|
||||
"""Create multiple binary sensor entities associated with different targets."""
|
||||
return await target_entities(hass, "binary_sensor")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"trigger_key",
|
||||
[
|
||||
"binary_sensor.occupancy_detected",
|
||||
"binary_sensor.occupancy_cleared",
|
||||
],
|
||||
)
|
||||
async def test_binary_sensor_triggers_gated_by_labs_flag(
|
||||
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str
|
||||
) -> None:
|
||||
"""Test the binary sensor 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="binary_sensor.occupancy_detected",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "occupancy"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="binary_sensor.occupancy_cleared",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "occupancy"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_binary_sensor_state_attribute_trigger_behavior_any(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
target_binary_sensors: dict[list[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 that the binary sensor state trigger fires when any binary sensor state changes to a specific state."""
|
||||
other_entity_ids = set(target_binary_sensors["included"]) - {entity_id}
|
||||
excluded_entity_ids = set(target_binary_sensors["excluded"]) - {entity_id}
|
||||
|
||||
# Set all binary sensors, including the tested binary sensor, to the initial state
|
||||
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()
|
||||
|
||||
# Check if changing other binary sensors also triggers
|
||||
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="binary_sensor.occupancy_detected",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "occupancy"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="binary_sensor.occupancy_cleared",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "occupancy"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_binary_sensor_state_attribute_trigger_behavior_first(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
target_binary_sensors: 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 that the binary sensor state trigger fires when the first binary sensor state changes to a specific state."""
|
||||
other_entity_ids = set(target_binary_sensors["included"]) - {entity_id}
|
||||
excluded_entity_ids = set(target_binary_sensors["excluded"]) - {entity_id}
|
||||
|
||||
# Set all binary sensors, including the tested binary sensor, to the initial state
|
||||
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()
|
||||
|
||||
# Triggering other binary sensors should not cause the trigger to fire again
|
||||
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="binary_sensor.occupancy_detected",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "occupancy"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="binary_sensor.occupancy_cleared",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "occupancy"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_binary_sensor_state_attribute_trigger_behavior_last(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
target_binary_sensors: 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 that the binary sensor state trigger fires when the last binary sensor state changes to a specific state."""
|
||||
other_entity_ids = set(target_binary_sensors["included"]) - {entity_id}
|
||||
excluded_entity_ids = set(target_binary_sensors["excluded"]) - {entity_id}
|
||||
|
||||
# Set all binary sensors, including the tested binary sensor, to the initial state
|
||||
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
|
||||
101
tests/components/eheimdigital/snapshots/test_binary_sensor.ambr
Normal file
101
tests/components/eheimdigital/snapshots/test_binary_sensor.ambr
Normal file
@@ -0,0 +1,101 @@
|
||||
# serializer version: 1
|
||||
# name: test_setup[binary_sensor.mock_reeflex_light-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'binary_sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'binary_sensor.mock_reeflex_light',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Light',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <BinarySensorDeviceClass.LIGHT: 'light'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Light',
|
||||
'platform': 'eheimdigital',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'is_lighting',
|
||||
'unique_id': '00:00:00:00:00:05_is_lighting',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_setup[binary_sensor.mock_reeflex_light-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'light',
|
||||
'friendly_name': 'Mock reeflex Light',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.mock_reeflex_light',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_setup[binary_sensor.mock_reeflex_uvc_lamp_connected-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'binary_sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'binary_sensor.mock_reeflex_uvc_lamp_connected',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'UVC lamp connected',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <BinarySensorDeviceClass.CONNECTIVITY: 'connectivity'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'UVC lamp connected',
|
||||
'platform': 'eheimdigital',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'is_uvc_connected',
|
||||
'unique_id': '00:00:00:00:00:05_is_uvc_connected',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_setup[binary_sensor.mock_reeflex_uvc_lamp_connected-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'connectivity',
|
||||
'friendly_name': 'Mock reeflex UVC lamp connected',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.mock_reeflex_uvc_lamp_connected',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
109
tests/components/eheimdigital/test_binary_sensor.py
Normal file
109
tests/components/eheimdigital/test_binary_sensor.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Tests for the binary sensor module."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from .conftest import init_integration
|
||||
|
||||
from tests.common import MockConfigEntry, get_sensor_display_state, snapshot_platform
|
||||
|
||||
|
||||
async def test_setup(
|
||||
hass: HomeAssistant,
|
||||
eheimdigital_hub_mock: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test binary sensor platform setup."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.eheimdigital.PLATFORMS", [Platform.BINARY_SENSOR]
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.eheimdigital.coordinator.asyncio.Event",
|
||||
new=AsyncMock,
|
||||
),
|
||||
):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
|
||||
for device in eheimdigital_hub_mock.return_value.devices:
|
||||
await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"](
|
||||
device, eheimdigital_hub_mock.return_value.devices[device].device_type
|
||||
)
|
||||
await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]()
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("device_name", "entity_list"),
|
||||
[
|
||||
(
|
||||
"reeflex_mock",
|
||||
[
|
||||
(
|
||||
"binary_sensor.mock_reeflex_light",
|
||||
"reeflex_data",
|
||||
"isLighting",
|
||||
True,
|
||||
"on",
|
||||
),
|
||||
(
|
||||
"binary_sensor.mock_reeflex_light",
|
||||
"reeflex_data",
|
||||
"isLighting",
|
||||
False,
|
||||
"off",
|
||||
),
|
||||
(
|
||||
"binary_sensor.mock_reeflex_uvc_lamp_connected",
|
||||
"reeflex_data",
|
||||
"isUVCConnected",
|
||||
True,
|
||||
"on",
|
||||
),
|
||||
(
|
||||
"binary_sensor.mock_reeflex_uvc_lamp_connected",
|
||||
"reeflex_data",
|
||||
"isUVCConnected",
|
||||
False,
|
||||
"off",
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_state_update(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
eheimdigital_hub_mock: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
device_name: str,
|
||||
entity_list: list[tuple[str, str, str, bool | int, str]],
|
||||
request: pytest.FixtureRequest,
|
||||
) -> None:
|
||||
"""Test the binary sensor state update."""
|
||||
device: MagicMock = request.getfixturevalue(device_name)
|
||||
await init_integration(hass, mock_config_entry)
|
||||
|
||||
await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"](
|
||||
device.mac_address, device.device_type
|
||||
)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
for item in entity_list:
|
||||
getattr(device, item[1])[item[2]] = item[3]
|
||||
await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]()
|
||||
assert get_sensor_display_state(hass, entity_registry, item[0]) == str(item[4])
|
||||
@@ -8,7 +8,7 @@ from datetime import timedelta
|
||||
from http import HTTPStatus
|
||||
from ipaddress import ip_address
|
||||
import json
|
||||
from unittest.mock import AsyncMock, _patch, patch
|
||||
from unittest.mock import _patch, patch
|
||||
|
||||
from aiohttp.hdrs import CONTENT_TYPE
|
||||
from aiohttp.test_utils import TestClient
|
||||
@@ -109,7 +109,7 @@ ENTITY_IDS_BY_NUMBER = {
|
||||
ENTITY_NUMBERS_BY_ID = {v: k for k, v in ENTITY_IDS_BY_NUMBER.items()}
|
||||
|
||||
|
||||
def patch_upnp() -> _patch[AsyncMock]:
|
||||
def patch_upnp() -> _patch:
|
||||
"""Patch async_create_upnp_datagram_endpoint."""
|
||||
return patch(
|
||||
"homeassistant.components.emulated_hue.async_create_upnp_datagram_endpoint"
|
||||
|
||||
@@ -8,7 +8,7 @@ from freezegun.api import FrozenDateTimeFactory
|
||||
from gardena_bluetooth.client import Client
|
||||
from gardena_bluetooth.const import DeviceInformation
|
||||
from gardena_bluetooth.exceptions import CharacteristicNotFound
|
||||
from gardena_bluetooth.parse import Characteristic
|
||||
from gardena_bluetooth.parse import Characteristic, Service
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.gardena_bluetooth.const import DOMAIN
|
||||
@@ -83,7 +83,7 @@ def mock_client(
|
||||
) -> Generator[Mock]:
|
||||
"""Auto mock bluetooth."""
|
||||
|
||||
client = Mock(spec_set=Client)
|
||||
client_class = Mock()
|
||||
|
||||
SENTINEL = object()
|
||||
|
||||
@@ -106,19 +106,32 @@ def mock_client(
|
||||
return default
|
||||
return val
|
||||
|
||||
def _all_char():
|
||||
def _all_char_uuid():
|
||||
return set(mock_read_char_raw.keys())
|
||||
|
||||
def _all_char():
|
||||
product_type = client_class.call_args.args[1]
|
||||
services = Service.services_for_product_type(product_type)
|
||||
return {
|
||||
char.unique_id: char
|
||||
for service in services
|
||||
for char in service.characteristics.values()
|
||||
if char.uuid in mock_read_char_raw
|
||||
}
|
||||
|
||||
client = Mock(spec_set=Client)
|
||||
client.read_char.side_effect = _read_char
|
||||
client.read_char_raw.side_effect = _read_char_raw
|
||||
client.get_all_characteristics_uuid.side_effect = _all_char
|
||||
client.get_all_characteristics_uuid.side_effect = _all_char_uuid
|
||||
client.get_all_characteristics.side_effect = _all_char
|
||||
client_class.return_value = client
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.gardena_bluetooth.config_flow.Client",
|
||||
return_value=client,
|
||||
new=client_class,
|
||||
),
|
||||
patch("homeassistant.components.gardena_bluetooth.Client", return_value=client),
|
||||
patch("homeassistant.components.gardena_bluetooth.Client", new=client_class),
|
||||
):
|
||||
yield client
|
||||
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
"""Test the Gardena Bluetooth setup."""
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
from unittest.mock import Mock
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from gardena_bluetooth.const import Battery
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.gardena_bluetooth import DeviceUnavailable
|
||||
from homeassistant.components.gardena_bluetooth.const import DOMAIN
|
||||
from homeassistant.components.gardena_bluetooth.util import (
|
||||
async_get_product_type as original_get_product_type,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.util import utcnow
|
||||
|
||||
from . import WATER_TIMER_SERVICE_INFO
|
||||
from . import MISSING_MANUFACTURER_DATA_SERVICE_INFO, WATER_TIMER_SERVICE_INFO
|
||||
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||
from tests.components.bluetooth import inject_bluetooth_service_info
|
||||
|
||||
|
||||
async def test_setup(
|
||||
@@ -28,12 +33,10 @@ async def test_setup(
|
||||
"""Test setup creates expected devices."""
|
||||
|
||||
mock_read_char_raw[Battery.battery_level.uuid] = Battery.battery_level.encode(100)
|
||||
inject_bluetooth_service_info(hass, WATER_TIMER_SERVICE_INFO)
|
||||
|
||||
mock_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_entry.state is ConfigEntryState.LOADED
|
||||
assert await hass.config_entries.async_setup(mock_entry.entry_id) is True
|
||||
|
||||
device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, WATER_TIMER_SERVICE_INFO.address)}
|
||||
@@ -41,11 +44,49 @@ async def test_setup(
|
||||
assert device == snapshot
|
||||
|
||||
|
||||
async def test_setup_delayed_product(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
mock_entry: MockConfigEntry,
|
||||
mock_read_char_raw: dict[str, bytes],
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test setup creates expected devices."""
|
||||
|
||||
mock_read_char_raw[Battery.battery_level.uuid] = Battery.battery_level.encode(100)
|
||||
|
||||
mock_entry.add_to_hass(hass)
|
||||
|
||||
event = asyncio.Event()
|
||||
|
||||
async def _get_product_type(*args, **kwargs):
|
||||
event.set()
|
||||
return await original_get_product_type(*args, **kwargs)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.gardena_bluetooth.async_get_product_type",
|
||||
wraps=_get_product_type,
|
||||
):
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
setup_task = tg.create_task(
|
||||
hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
)
|
||||
|
||||
await event.wait()
|
||||
assert mock_entry.state is ConfigEntryState.SETUP_IN_PROGRESS
|
||||
inject_bluetooth_service_info(hass, MISSING_MANUFACTURER_DATA_SERVICE_INFO)
|
||||
inject_bluetooth_service_info(hass, WATER_TIMER_SERVICE_INFO)
|
||||
|
||||
assert await setup_task is True
|
||||
|
||||
|
||||
async def test_setup_retry(
|
||||
hass: HomeAssistant, mock_entry: MockConfigEntry, mock_client: Mock
|
||||
) -> None:
|
||||
"""Test setup creates expected devices."""
|
||||
|
||||
inject_bluetooth_service_info(hass, WATER_TIMER_SERVICE_INFO)
|
||||
|
||||
original_read_char = mock_client.read_char.side_effect
|
||||
mock_client.read_char.side_effect = DeviceUnavailable
|
||||
mock_entry.add_to_hass(hass)
|
||||
|
||||
1
tests/components/gate/__init__.py
Normal file
1
tests/components/gate/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for the gate integration."""
|
||||
396
tests/components/gate/test_trigger.py
Normal file
396
tests/components/gate/test_trigger.py
Normal file
@@ -0,0 +1,396 @@
|
||||
"""Test gate trigger."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.cover import ATTR_IS_CLOSED, CoverState
|
||||
from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_LABEL_ID, CONF_ENTITY_ID
|
||||
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_covers(hass: HomeAssistant) -> dict[str, list[str]]:
|
||||
"""Create multiple cover entities associated with different targets."""
|
||||
return await target_entities(hass, "cover")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"trigger_key",
|
||||
[
|
||||
"gate.opened",
|
||||
"gate.closed",
|
||||
],
|
||||
)
|
||||
async def test_gate_triggers_gated_by_labs_flag(
|
||||
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str
|
||||
) -> None:
|
||||
"""Test the gate 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("cover"),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "trigger_options", "states"),
|
||||
[
|
||||
*parametrize_trigger_states(
|
||||
trigger="gate.opened",
|
||||
target_states=[
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: False}),
|
||||
(CoverState.OPENING, {ATTR_IS_CLOSED: False}),
|
||||
],
|
||||
other_states=[
|
||||
(CoverState.CLOSED, {ATTR_IS_CLOSED: True}),
|
||||
(CoverState.CLOSING, {ATTR_IS_CLOSED: True}),
|
||||
],
|
||||
extra_invalid_states=[
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
(CoverState.OPEN, {}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "gate"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="gate.closed",
|
||||
target_states=[
|
||||
(CoverState.CLOSED, {ATTR_IS_CLOSED: True}),
|
||||
(CoverState.CLOSING, {ATTR_IS_CLOSED: True}),
|
||||
],
|
||||
other_states=[
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: False}),
|
||||
(CoverState.OPENING, {ATTR_IS_CLOSED: False}),
|
||||
(CoverState.CLOSING, {ATTR_IS_CLOSED: False}),
|
||||
],
|
||||
extra_invalid_states=[
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
(CoverState.OPEN, {}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "gate"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_gate_trigger_cover_behavior_any(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
target_covers: 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 gate trigger fires for cover entities with device_class gate."""
|
||||
other_entity_ids = set(target_covers["included"]) - {entity_id}
|
||||
excluded_entity_ids = set(target_covers["excluded"]) - {entity_id}
|
||||
|
||||
for eid in target_covers["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("cover"),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "trigger_options", "states"),
|
||||
[
|
||||
*parametrize_trigger_states(
|
||||
trigger="gate.opened",
|
||||
target_states=[
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: False}),
|
||||
(CoverState.OPENING, {ATTR_IS_CLOSED: False}),
|
||||
],
|
||||
other_states=[
|
||||
(CoverState.CLOSED, {ATTR_IS_CLOSED: True}),
|
||||
(CoverState.CLOSING, {ATTR_IS_CLOSED: True}),
|
||||
],
|
||||
extra_invalid_states=[
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
(CoverState.OPEN, {}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "gate"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="gate.closed",
|
||||
target_states=[
|
||||
(CoverState.CLOSED, {ATTR_IS_CLOSED: True}),
|
||||
(CoverState.CLOSING, {ATTR_IS_CLOSED: True}),
|
||||
],
|
||||
other_states=[
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: False}),
|
||||
(CoverState.OPENING, {ATTR_IS_CLOSED: False}),
|
||||
(CoverState.CLOSING, {ATTR_IS_CLOSED: False}),
|
||||
],
|
||||
extra_invalid_states=[
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
(CoverState.OPEN, {}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "gate"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_gate_trigger_cover_behavior_first(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
target_covers: 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 gate trigger fires on the first cover state change."""
|
||||
other_entity_ids = set(target_covers["included"]) - {entity_id}
|
||||
excluded_entity_ids = set(target_covers["excluded"]) - {entity_id}
|
||||
|
||||
for eid in target_covers["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("cover"),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "trigger_options", "states"),
|
||||
[
|
||||
*parametrize_trigger_states(
|
||||
trigger="gate.opened",
|
||||
target_states=[
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: False}),
|
||||
(CoverState.OPENING, {ATTR_IS_CLOSED: False}),
|
||||
],
|
||||
other_states=[
|
||||
(CoverState.CLOSED, {ATTR_IS_CLOSED: True}),
|
||||
(CoverState.CLOSING, {ATTR_IS_CLOSED: True}),
|
||||
],
|
||||
extra_invalid_states=[
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
(CoverState.OPEN, {}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "gate"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="gate.closed",
|
||||
target_states=[
|
||||
(CoverState.CLOSED, {ATTR_IS_CLOSED: True}),
|
||||
(CoverState.CLOSING, {ATTR_IS_CLOSED: True}),
|
||||
],
|
||||
other_states=[
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: False}),
|
||||
(CoverState.OPENING, {ATTR_IS_CLOSED: False}),
|
||||
(CoverState.CLOSING, {ATTR_IS_CLOSED: False}),
|
||||
],
|
||||
extra_invalid_states=[
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
(CoverState.OPEN, {}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "gate"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_gate_trigger_cover_behavior_last(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
target_covers: 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 gate trigger fires when the last cover changes state."""
|
||||
other_entity_ids = set(target_covers["included"]) - {entity_id}
|
||||
excluded_entity_ids = set(target_covers["excluded"]) - {entity_id}
|
||||
|
||||
for eid in target_covers["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
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"trigger_key",
|
||||
"cover_initial",
|
||||
"cover_initial_is_closed",
|
||||
"cover_target",
|
||||
"cover_target_is_closed",
|
||||
),
|
||||
[
|
||||
(
|
||||
"gate.opened",
|
||||
CoverState.CLOSED,
|
||||
True,
|
||||
CoverState.OPEN,
|
||||
False,
|
||||
),
|
||||
(
|
||||
"gate.closed",
|
||||
CoverState.OPEN,
|
||||
False,
|
||||
CoverState.CLOSED,
|
||||
True,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_gate_trigger_excludes_non_gate_device_class(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
trigger_key: str,
|
||||
cover_initial: str,
|
||||
cover_initial_is_closed: bool,
|
||||
cover_target: str,
|
||||
cover_target_is_closed: bool,
|
||||
) -> None:
|
||||
"""Test gate trigger does not fire for entities without device_class gate."""
|
||||
entity_id_cover_gate = "cover.test_gate"
|
||||
entity_id_cover_garage = "cover.test_garage"
|
||||
|
||||
# Set initial states
|
||||
hass.states.async_set(
|
||||
entity_id_cover_gate,
|
||||
cover_initial,
|
||||
{ATTR_DEVICE_CLASS: "gate", ATTR_IS_CLOSED: cover_initial_is_closed},
|
||||
)
|
||||
hass.states.async_set(
|
||||
entity_id_cover_garage,
|
||||
cover_initial,
|
||||
{ATTR_DEVICE_CLASS: "garage", ATTR_IS_CLOSED: cover_initial_is_closed},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await arm_trigger(
|
||||
hass,
|
||||
trigger_key,
|
||||
{},
|
||||
{
|
||||
CONF_ENTITY_ID: [
|
||||
entity_id_cover_gate,
|
||||
entity_id_cover_garage,
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
# Gate cover changes - should trigger
|
||||
hass.states.async_set(
|
||||
entity_id_cover_gate,
|
||||
cover_target,
|
||||
{ATTR_DEVICE_CLASS: "gate", ATTR_IS_CLOSED: cover_target_is_closed},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id_cover_gate
|
||||
service_calls.clear()
|
||||
|
||||
# Garage cover changes - should NOT trigger (wrong device class)
|
||||
hass.states.async_set(
|
||||
entity_id_cover_garage,
|
||||
cover_target,
|
||||
{ATTR_DEVICE_CLASS: "garage", ATTR_IS_CLOSED: cover_target_is_closed},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
@@ -76,7 +76,7 @@ def fakeimg_gif(fakeimgbytes_gif: bytes) -> Generator[None]:
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_create_stream")
|
||||
def mock_create_stream(hass: HomeAssistant) -> Generator[AsyncMock]:
|
||||
def mock_create_stream(hass: HomeAssistant) -> Generator[MagicMock]:
|
||||
"""Mock create stream."""
|
||||
mock_stream = MagicMock()
|
||||
mock_stream.hass = hass
|
||||
|
||||
@@ -9,7 +9,7 @@ import errno
|
||||
from http import HTTPStatus
|
||||
import os.path
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, PropertyMock, _patch, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -73,8 +73,8 @@ async def test_form(
|
||||
fakeimgbytes_png: bytes,
|
||||
hass_client: ClientSessionGenerator,
|
||||
user_flow: ConfigFlowResult,
|
||||
mock_create_stream: _patch[MagicMock],
|
||||
mock_setup_entry: _patch[MagicMock],
|
||||
mock_create_stream: MagicMock,
|
||||
mock_setup_entry: AsyncMock,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""Test the form with a normal set of settings."""
|
||||
@@ -137,7 +137,7 @@ async def test_form(
|
||||
async def test_form_only_stillimage(
|
||||
hass: HomeAssistant,
|
||||
user_flow: ConfigFlowResult,
|
||||
mock_setup_entry: _patch[MagicMock],
|
||||
mock_setup_entry: AsyncMock,
|
||||
) -> None:
|
||||
"""Test we complete ok if the user wants still images only."""
|
||||
result1 = await hass.config_entries.flow.async_configure(
|
||||
@@ -172,7 +172,7 @@ async def test_form_only_stillimage(
|
||||
@pytest.mark.usefixtures("fakeimg_png")
|
||||
async def test_form_reject_preview(
|
||||
hass: HomeAssistant,
|
||||
mock_create_stream: _patch[MagicMock],
|
||||
mock_create_stream: MagicMock,
|
||||
user_flow: ConfigFlowResult,
|
||||
) -> None:
|
||||
"""Test we go back to the config screen if the user rejects the preview."""
|
||||
@@ -194,7 +194,7 @@ async def test_form_reject_preview(
|
||||
@pytest.mark.usefixtures("fakeimg_png")
|
||||
async def test_form_still_preview_cam_off(
|
||||
hass: HomeAssistant,
|
||||
mock_create_stream: _patch[MagicMock],
|
||||
mock_create_stream: MagicMock,
|
||||
user_flow: ConfigFlowResult,
|
||||
hass_client: ClientSessionGenerator,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
@@ -237,7 +237,7 @@ async def test_form_still_preview_cam_off(
|
||||
async def test_form_only_stillimage_gif(
|
||||
hass: HomeAssistant,
|
||||
user_flow: ConfigFlowResult,
|
||||
mock_setup_entry: _patch[MagicMock],
|
||||
mock_setup_entry: AsyncMock,
|
||||
) -> None:
|
||||
"""Test we complete ok if the user wants a gif."""
|
||||
result1 = await hass.config_entries.flow.async_configure(
|
||||
@@ -260,7 +260,7 @@ async def test_form_only_svg_whitespace(
|
||||
hass: HomeAssistant,
|
||||
fakeimgbytes_svg: bytes,
|
||||
user_flow: ConfigFlowResult,
|
||||
mock_setup_entry: _patch[MagicMock],
|
||||
mock_setup_entry: AsyncMock,
|
||||
) -> None:
|
||||
"""Test we complete ok if svg starts with whitespace, issue #68889."""
|
||||
fakeimgbytes_wspace_svg = bytes(" \n ", encoding="utf-8") + fakeimgbytes_svg
|
||||
@@ -379,8 +379,8 @@ async def test_form_still_template(
|
||||
async def test_form_rtsp_mode(
|
||||
hass: HomeAssistant,
|
||||
user_flow: ConfigFlowResult,
|
||||
mock_create_stream: _patch[MagicMock],
|
||||
mock_setup_entry: _patch[MagicMock],
|
||||
mock_create_stream: MagicMock,
|
||||
mock_setup_entry: AsyncMock,
|
||||
) -> None:
|
||||
"""Test we complete ok if the user enters a stream url."""
|
||||
data = deepcopy(TESTDATA)
|
||||
@@ -414,7 +414,7 @@ async def test_form_only_stream(
|
||||
hass: HomeAssistant,
|
||||
fakeimgbytes_jpg: bytes,
|
||||
user_flow: ConfigFlowResult,
|
||||
mock_create_stream: _patch[MagicMock],
|
||||
mock_create_stream: MagicMock,
|
||||
) -> None:
|
||||
"""Test we complete ok if the user wants stream only."""
|
||||
data = TESTDATA_ONLYSTREAM.copy()
|
||||
@@ -505,7 +505,7 @@ async def test_form_image_http_exceptions(
|
||||
expected_message,
|
||||
hass: HomeAssistant,
|
||||
user_flow: ConfigFlowResult,
|
||||
mock_create_stream: _patch[MagicMock],
|
||||
mock_create_stream: MagicMock,
|
||||
) -> None:
|
||||
"""Test we handle image http exceptions."""
|
||||
respx.get("http://127.0.0.1/testurl/1").side_effect = [side_effect]
|
||||
@@ -522,7 +522,7 @@ async def test_form_image_http_exceptions(
|
||||
async def test_form_image_http_302(
|
||||
hass: HomeAssistant,
|
||||
user_flow: ConfigFlowResult,
|
||||
mock_create_stream: _patch[MagicMock],
|
||||
mock_create_stream: MagicMock,
|
||||
fakeimgbytes_png: bytes,
|
||||
) -> None:
|
||||
"""Test we handle image http 302 (temporary redirect)."""
|
||||
@@ -547,7 +547,7 @@ async def test_form_image_http_302(
|
||||
async def test_form_stream_invalidimage(
|
||||
hass: HomeAssistant,
|
||||
user_flow: ConfigFlowResult,
|
||||
mock_create_stream: _patch[MagicMock],
|
||||
mock_create_stream: MagicMock,
|
||||
) -> None:
|
||||
"""Test we handle invalid image when a stream is specified."""
|
||||
respx.get("http://127.0.0.1/testurl/1").respond(stream=b"invalid")
|
||||
@@ -564,7 +564,7 @@ async def test_form_stream_invalidimage(
|
||||
async def test_form_stream_invalidimage2(
|
||||
hass: HomeAssistant,
|
||||
user_flow: ConfigFlowResult,
|
||||
mock_create_stream: _patch[MagicMock],
|
||||
mock_create_stream: MagicMock,
|
||||
) -> None:
|
||||
"""Test we handle invalid image when a stream is specified."""
|
||||
respx.get("http://127.0.0.1/testurl/1").respond(content=None)
|
||||
@@ -581,7 +581,7 @@ async def test_form_stream_invalidimage2(
|
||||
async def test_form_stream_invalidimage3(
|
||||
hass: HomeAssistant,
|
||||
user_flow: ConfigFlowResult,
|
||||
mock_create_stream: _patch[MagicMock],
|
||||
mock_create_stream: MagicMock,
|
||||
) -> None:
|
||||
"""Test we handle invalid image when a stream is specified."""
|
||||
respx.get("http://127.0.0.1/testurl/1").respond(content=bytes([0xFF]))
|
||||
@@ -599,7 +599,7 @@ async def test_form_stream_invalidimage3(
|
||||
async def test_form_stream_timeout(
|
||||
hass: HomeAssistant,
|
||||
user_flow: ConfigFlowResult,
|
||||
mock_create_stream: _patch[MagicMock],
|
||||
mock_create_stream: MagicMock,
|
||||
) -> None:
|
||||
"""Test we handle invalid auth."""
|
||||
mock_create_stream.return_value.start = AsyncMock()
|
||||
@@ -728,7 +728,7 @@ async def test_form_oserror(hass: HomeAssistant, user_flow: ConfigFlowResult) ->
|
||||
@pytest.mark.usefixtures("fakeimg_png")
|
||||
async def test_options_template_error(
|
||||
hass: HomeAssistant,
|
||||
mock_create_stream: _patch[MagicMock],
|
||||
mock_create_stream: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test the options flow with a template error."""
|
||||
@@ -814,8 +814,8 @@ async def test_slug(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> No
|
||||
@pytest.mark.usefixtures("fakeimg_png")
|
||||
async def test_options_only_stream(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: _patch[MagicMock],
|
||||
mock_create_stream: _patch[MagicMock],
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_create_stream: MagicMock,
|
||||
) -> None:
|
||||
"""Test the options flow without a still_image_url."""
|
||||
|
||||
@@ -849,7 +849,7 @@ async def test_options_only_stream(
|
||||
|
||||
async def test_options_still_and_stream_not_provided(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: _patch[MagicMock],
|
||||
mock_setup_entry: AsyncMock,
|
||||
) -> None:
|
||||
"""Test we show a suitable error if neither still or stream URL are provided."""
|
||||
data = TESTDATA_ONLYSTILL.copy()
|
||||
@@ -942,12 +942,12 @@ async def test_migrate_existing_ids(
|
||||
@pytest.mark.usefixtures("fakeimg_png")
|
||||
async def test_options_use_wallclock_as_timestamps(
|
||||
hass: HomeAssistant,
|
||||
mock_create_stream: _patch[MagicMock],
|
||||
mock_create_stream: MagicMock,
|
||||
hass_client: ClientSessionGenerator,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
fakeimgbytes_png: bytes,
|
||||
config_entry: MockConfigEntry,
|
||||
mock_setup_entry: _patch[MagicMock],
|
||||
mock_setup_entry: AsyncMock,
|
||||
) -> None:
|
||||
"""Test the use_wallclock_as_timestamps option flow."""
|
||||
|
||||
|
||||
170
tests/components/growatt_server/snapshots/test_config_flow.ambr
Normal file
170
tests/components/growatt_server/snapshots/test_config_flow.ambr
Normal file
@@ -0,0 +1,170 @@
|
||||
# serializer version: 1
|
||||
# name: test_reauth_password_error_then_recovery[None-login_return_value0]
|
||||
FlowResultSnapshot({
|
||||
'description_placeholders': dict({
|
||||
'name': 'Mock Title',
|
||||
}),
|
||||
'errors': dict({
|
||||
'base': 'invalid_auth',
|
||||
}),
|
||||
'flow_id': <ANY>,
|
||||
'handler': 'growatt_server',
|
||||
'last_step': None,
|
||||
'preview': None,
|
||||
'step_id': 'reauth_confirm',
|
||||
'type': <FlowResultType.FORM: 'form'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_reauth_password_error_then_recovery[login_side_effect1-None]
|
||||
FlowResultSnapshot({
|
||||
'description_placeholders': dict({
|
||||
'name': 'Mock Title',
|
||||
}),
|
||||
'errors': dict({
|
||||
'base': 'cannot_connect',
|
||||
}),
|
||||
'flow_id': <ANY>,
|
||||
'handler': 'growatt_server',
|
||||
'last_step': None,
|
||||
'preview': None,
|
||||
'step_id': 'reauth_confirm',
|
||||
'type': <FlowResultType.FORM: 'form'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_reauth_password_exception
|
||||
dict({
|
||||
'auth_type': 'password',
|
||||
'name': 'Test Plant',
|
||||
'password': 'password',
|
||||
'plant_id': '123456',
|
||||
'url': 'https://openapi.growatt.com/',
|
||||
'username': 'username',
|
||||
})
|
||||
# ---
|
||||
# name: test_reauth_password_non_auth_login_failure
|
||||
dict({
|
||||
'auth_type': 'password',
|
||||
'name': 'Test Plant',
|
||||
'password': 'password',
|
||||
'plant_id': '123456',
|
||||
'url': 'https://openapi.growatt.com/',
|
||||
'username': 'username',
|
||||
})
|
||||
# ---
|
||||
# name: test_reauth_password_success[https://openapi-us.growatt.com/-user_input1-north_america]
|
||||
FlowResultSnapshot({
|
||||
'description_placeholders': dict({
|
||||
'name': 'Mock Title',
|
||||
}),
|
||||
'errors': dict({
|
||||
}),
|
||||
'flow_id': <ANY>,
|
||||
'handler': 'growatt_server',
|
||||
'last_step': None,
|
||||
'preview': None,
|
||||
'step_id': 'reauth_confirm',
|
||||
'type': <FlowResultType.FORM: 'form'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_reauth_password_success[https://openapi-us.growatt.com/-user_input1-north_america].1
|
||||
dict({
|
||||
'auth_type': 'password',
|
||||
'name': 'Test Plant',
|
||||
'password': 'password',
|
||||
'plant_id': '123456',
|
||||
'url': 'https://openapi-us.growatt.com/',
|
||||
'username': 'username',
|
||||
})
|
||||
# ---
|
||||
# name: test_reauth_password_success[https://openapi.growatt.com/-user_input0-other_regions]
|
||||
FlowResultSnapshot({
|
||||
'description_placeholders': dict({
|
||||
'name': 'Mock Title',
|
||||
}),
|
||||
'errors': dict({
|
||||
}),
|
||||
'flow_id': <ANY>,
|
||||
'handler': 'growatt_server',
|
||||
'last_step': None,
|
||||
'preview': None,
|
||||
'step_id': 'reauth_confirm',
|
||||
'type': <FlowResultType.FORM: 'form'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_reauth_password_success[https://openapi.growatt.com/-user_input0-other_regions].1
|
||||
dict({
|
||||
'auth_type': 'password',
|
||||
'name': 'Test Plant',
|
||||
'password': 'password',
|
||||
'plant_id': '123456',
|
||||
'url': 'https://openapi.growatt.com/',
|
||||
'username': 'username',
|
||||
})
|
||||
# ---
|
||||
# name: test_reauth_token_error_then_recovery[plant_list_side_effect0]
|
||||
FlowResultSnapshot({
|
||||
'description_placeholders': dict({
|
||||
'name': 'Mock Title',
|
||||
}),
|
||||
'errors': dict({
|
||||
'base': 'invalid_auth',
|
||||
}),
|
||||
'flow_id': <ANY>,
|
||||
'handler': 'growatt_server',
|
||||
'last_step': None,
|
||||
'preview': None,
|
||||
'step_id': 'reauth_confirm',
|
||||
'type': <FlowResultType.FORM: 'form'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_reauth_token_error_then_recovery[plant_list_side_effect1]
|
||||
FlowResultSnapshot({
|
||||
'description_placeholders': dict({
|
||||
'name': 'Mock Title',
|
||||
}),
|
||||
'errors': dict({
|
||||
'base': 'cannot_connect',
|
||||
}),
|
||||
'flow_id': <ANY>,
|
||||
'handler': 'growatt_server',
|
||||
'last_step': None,
|
||||
'preview': None,
|
||||
'step_id': 'reauth_confirm',
|
||||
'type': <FlowResultType.FORM: 'form'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_reauth_token_exception
|
||||
dict({
|
||||
'auth_type': 'api_token',
|
||||
'name': 'Test Plant',
|
||||
'plant_id': '123456',
|
||||
'token': 'test_api_token_12345',
|
||||
'url': 'https://openapi.growatt.com/',
|
||||
'user_id': '12345',
|
||||
})
|
||||
# ---
|
||||
# name: test_reauth_token_success
|
||||
FlowResultSnapshot({
|
||||
'description_placeholders': dict({
|
||||
'name': 'Mock Title',
|
||||
}),
|
||||
'errors': dict({
|
||||
}),
|
||||
'flow_id': <ANY>,
|
||||
'handler': 'growatt_server',
|
||||
'last_step': None,
|
||||
'preview': None,
|
||||
'step_id': 'reauth_confirm',
|
||||
'type': <FlowResultType.FORM: 'form'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_reauth_token_success.1
|
||||
dict({
|
||||
'auth_type': 'api_token',
|
||||
'name': 'Test Plant',
|
||||
'plant_id': '123456',
|
||||
'token': 'test_api_token_12345',
|
||||
'url': 'https://openapi.growatt.com/',
|
||||
'user_id': '12345',
|
||||
})
|
||||
# ---
|
||||
@@ -5,6 +5,9 @@ from copy import deepcopy
|
||||
import growattServer
|
||||
import pytest
|
||||
import requests
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
from syrupy.filters import props
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.growatt_server.const import (
|
||||
@@ -19,8 +22,17 @@ from homeassistant.components.growatt_server.const import (
|
||||
ERROR_CANNOT_CONNECT,
|
||||
ERROR_INVALID_AUTH,
|
||||
LOGIN_INVALID_AUTH_CODE,
|
||||
SERVER_URLS_NAMES,
|
||||
V1_API_ERROR_NO_PRIVILEGE,
|
||||
V1_API_ERROR_RATE_LIMITED,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
CONF_TOKEN,
|
||||
CONF_URL,
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
@@ -299,11 +311,21 @@ async def test_password_auth_multiple_plants(
|
||||
# Token authentication tests
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("error_code", "expected_error"),
|
||||
[
|
||||
(V1_API_ERROR_NO_PRIVILEGE, ERROR_INVALID_AUTH),
|
||||
(V1_API_ERROR_RATE_LIMITED, ERROR_CANNOT_CONNECT),
|
||||
],
|
||||
)
|
||||
async def test_token_auth_api_error(
|
||||
hass: HomeAssistant, mock_growatt_v1_api, mock_setup_entry
|
||||
hass: HomeAssistant,
|
||||
mock_growatt_v1_api,
|
||||
mock_setup_entry,
|
||||
error_code: int,
|
||||
expected_error: str,
|
||||
) -> None:
|
||||
"""Test token authentication with API error, then recovery."""
|
||||
|
||||
"""Test token authentication with V1 API error maps to correct error type."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
@@ -312,9 +334,8 @@ async def test_token_auth_api_error(
|
||||
result["flow_id"], {"next_step_id": "token_auth"}
|
||||
)
|
||||
|
||||
# Any GrowattV1ApiError during token verification should result in invalid_auth
|
||||
error = growattServer.GrowattV1ApiError("API error")
|
||||
error.error_code = 100
|
||||
error.error_code = error_code
|
||||
mock_growatt_v1_api.plant_list.side_effect = error
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
@@ -323,9 +344,9 @@ async def test_token_auth_api_error(
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "token_auth"
|
||||
assert result["errors"] == {"base": ERROR_INVALID_AUTH}
|
||||
assert result["errors"] == {"base": expected_error}
|
||||
|
||||
# Test recovery - reset side_effect and set normal return value
|
||||
# Test recovery
|
||||
mock_growatt_v1_api.plant_list.side_effect = None
|
||||
mock_growatt_v1_api.plant_list.return_value = GROWATT_V1_PLANT_LIST_RESPONSE
|
||||
|
||||
@@ -671,3 +692,362 @@ async def test_password_auth_plant_list_invalid_format(
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == ERROR_CANNOT_CONNECT
|
||||
|
||||
|
||||
# Reauthentication flow tests
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("stored_url", "user_input", "expected_region"),
|
||||
[
|
||||
(
|
||||
SERVER_URLS_NAMES["other_regions"],
|
||||
FIXTURE_USER_INPUT_PASSWORD,
|
||||
"other_regions",
|
||||
),
|
||||
(
|
||||
SERVER_URLS_NAMES["north_america"],
|
||||
{
|
||||
CONF_USERNAME: "username",
|
||||
CONF_PASSWORD: "password",
|
||||
CONF_REGION: "north_america",
|
||||
},
|
||||
"north_america",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_reauth_password_success(
|
||||
hass: HomeAssistant,
|
||||
mock_growatt_classic_api,
|
||||
snapshot: SnapshotAssertion,
|
||||
stored_url: str,
|
||||
user_input: dict,
|
||||
expected_region: str,
|
||||
) -> None:
|
||||
"""Test successful reauthentication with password auth for default and non-default regions."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_AUTH_TYPE: AUTH_PASSWORD,
|
||||
CONF_USERNAME: "test_user",
|
||||
CONF_PASSWORD: "test_password",
|
||||
CONF_URL: stored_url,
|
||||
CONF_PLANT_ID: "123456",
|
||||
"name": "Test Plant",
|
||||
},
|
||||
unique_id="123456",
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await entry.start_reauth_flow(hass)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert result == snapshot(exclude=props("data_schema"))
|
||||
region_key = next(
|
||||
k
|
||||
for k in result["data_schema"].schema
|
||||
if isinstance(k, vol.Required) and k.schema == CONF_REGION
|
||||
)
|
||||
assert region_key.default() == expected_region
|
||||
|
||||
mock_growatt_classic_api.login.return_value = GROWATT_LOGIN_RESPONSE
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
assert entry.data == snapshot
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("login_side_effect", "login_return_value"),
|
||||
[
|
||||
(
|
||||
None,
|
||||
{"msg": LOGIN_INVALID_AUTH_CODE, "success": False},
|
||||
),
|
||||
(
|
||||
requests.exceptions.ConnectionError("Connection failed"),
|
||||
None,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_reauth_password_error_then_recovery(
|
||||
hass: HomeAssistant,
|
||||
mock_growatt_classic_api,
|
||||
mock_config_entry_classic: MockConfigEntry,
|
||||
snapshot: SnapshotAssertion,
|
||||
login_side_effect: Exception | None,
|
||||
login_return_value: dict | None,
|
||||
) -> None:
|
||||
"""Test password reauth shows error then allows recovery."""
|
||||
mock_config_entry_classic.add_to_hass(hass)
|
||||
|
||||
result = await mock_config_entry_classic.start_reauth_flow(hass)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
|
||||
mock_growatt_classic_api.login.side_effect = login_side_effect
|
||||
if login_return_value is not None:
|
||||
mock_growatt_classic_api.login.return_value = login_return_value
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert result == snapshot(exclude=props("data_schema"))
|
||||
|
||||
# Recover with correct credentials
|
||||
mock_growatt_classic_api.login.side_effect = None
|
||||
mock_growatt_classic_api.login.return_value = GROWATT_LOGIN_RESPONSE
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
|
||||
|
||||
async def test_reauth_token_success(
|
||||
hass: HomeAssistant,
|
||||
mock_growatt_v1_api,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test successful reauthentication with token auth."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await mock_config_entry.start_reauth_flow(hass)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert result == snapshot(exclude=props("data_schema"))
|
||||
|
||||
mock_growatt_v1_api.plant_list.return_value = GROWATT_V1_PLANT_LIST_RESPONSE
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
assert mock_config_entry.data == snapshot
|
||||
|
||||
|
||||
def _make_no_privilege_error() -> growattServer.GrowattV1ApiError:
|
||||
error = growattServer.GrowattV1ApiError("No privilege access")
|
||||
error.error_code = V1_API_ERROR_NO_PRIVILEGE
|
||||
return error
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"plant_list_side_effect",
|
||||
[
|
||||
_make_no_privilege_error(),
|
||||
requests.exceptions.ConnectionError("Network error"),
|
||||
],
|
||||
)
|
||||
async def test_reauth_token_error_then_recovery(
|
||||
hass: HomeAssistant,
|
||||
mock_growatt_v1_api,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
snapshot: SnapshotAssertion,
|
||||
plant_list_side_effect: Exception,
|
||||
) -> None:
|
||||
"""Test token reauth shows error then allows recovery."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await mock_config_entry.start_reauth_flow(hass)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
|
||||
mock_growatt_v1_api.plant_list.side_effect = plant_list_side_effect
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert result == snapshot(exclude=props("data_schema"))
|
||||
|
||||
# Recover with a valid token
|
||||
mock_growatt_v1_api.plant_list.side_effect = None
|
||||
mock_growatt_v1_api.plant_list.return_value = GROWATT_V1_PLANT_LIST_RESPONSE
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
|
||||
|
||||
async def test_reauth_token_non_auth_api_error(
|
||||
hass: HomeAssistant,
|
||||
mock_growatt_v1_api,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test reauth token with non-auth V1 API error (e.g. rate limit) shows cannot_connect."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await mock_config_entry.start_reauth_flow(hass)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
|
||||
error = growattServer.GrowattV1ApiError("Rate limit exceeded")
|
||||
error.error_code = V1_API_ERROR_RATE_LIMITED
|
||||
mock_growatt_v1_api.plant_list.side_effect = error
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert result["errors"] == {"base": ERROR_CANNOT_CONNECT}
|
||||
|
||||
|
||||
async def test_reauth_password_invalid_response(
|
||||
hass: HomeAssistant,
|
||||
mock_growatt_classic_api,
|
||||
mock_config_entry_classic: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test reauth password flow with non-dict login response, then recovery."""
|
||||
mock_config_entry_classic.add_to_hass(hass)
|
||||
result = await mock_config_entry_classic.start_reauth_flow(hass)
|
||||
|
||||
mock_growatt_classic_api.login.return_value = "not_a_dict"
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert result["errors"] == {"base": ERROR_CANNOT_CONNECT}
|
||||
|
||||
# Recover with correct credentials
|
||||
mock_growatt_classic_api.login.return_value = GROWATT_LOGIN_RESPONSE
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
|
||||
|
||||
async def test_reauth_password_non_auth_login_failure(
|
||||
hass: HomeAssistant,
|
||||
mock_growatt_classic_api,
|
||||
mock_config_entry_classic: MockConfigEntry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test reauth password flow when login fails with a non-auth error."""
|
||||
mock_config_entry_classic.add_to_hass(hass)
|
||||
result = await mock_config_entry_classic.start_reauth_flow(hass)
|
||||
|
||||
mock_growatt_classic_api.login.return_value = {
|
||||
"success": False,
|
||||
"msg": "server_maintenance",
|
||||
}
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert result["errors"] == {"base": ERROR_CANNOT_CONNECT}
|
||||
|
||||
# Recover with correct credentials
|
||||
mock_growatt_classic_api.login.return_value = GROWATT_LOGIN_RESPONSE
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
assert mock_config_entry_classic.data == snapshot
|
||||
|
||||
|
||||
async def test_reauth_password_exception(
|
||||
hass: HomeAssistant,
|
||||
mock_growatt_classic_api,
|
||||
mock_config_entry_classic: MockConfigEntry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test reauth password flow with unexpected exception from login, then recovery."""
|
||||
mock_config_entry_classic.add_to_hass(hass)
|
||||
result = await mock_config_entry_classic.start_reauth_flow(hass)
|
||||
|
||||
mock_growatt_classic_api.login.side_effect = ValueError("Unexpected error")
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert result["errors"] == {"base": ERROR_CANNOT_CONNECT}
|
||||
|
||||
# Recover with correct credentials
|
||||
mock_growatt_classic_api.login.side_effect = None
|
||||
mock_growatt_classic_api.login.return_value = GROWATT_LOGIN_RESPONSE
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
assert mock_config_entry_classic.data == snapshot
|
||||
|
||||
|
||||
async def test_reauth_token_exception(
|
||||
hass: HomeAssistant,
|
||||
mock_growatt_v1_api,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test reauth token flow with unexpected exception from plant_list, then recovery."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
result = await mock_config_entry.start_reauth_flow(hass)
|
||||
|
||||
mock_growatt_v1_api.plant_list.side_effect = ValueError("Unexpected error")
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert result["errors"] == {"base": ERROR_CANNOT_CONNECT}
|
||||
|
||||
# Recover with a valid token
|
||||
mock_growatt_v1_api.plant_list.side_effect = None
|
||||
mock_growatt_v1_api.plant_list.return_value = GROWATT_V1_PLANT_LIST_RESPONSE
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
assert mock_config_entry.data == snapshot
|
||||
|
||||
|
||||
async def test_reauth_unknown_auth_type(hass: HomeAssistant) -> None:
|
||||
"""Test reauth aborts immediately when the config entry has an unknown auth type."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_AUTH_TYPE: "unknown_type",
|
||||
"plant_id": "123456",
|
||||
"name": "Test Plant",
|
||||
},
|
||||
unique_id="123456",
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
# The flow aborts immediately without showing a form
|
||||
result = await entry.start_reauth_flow(hass)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == ERROR_CANNOT_CONNECT
|
||||
|
||||
@@ -18,6 +18,9 @@ from homeassistant.components.growatt_server.const import (
|
||||
CONF_PLANT_ID,
|
||||
DEFAULT_PLANT_ID,
|
||||
DOMAIN,
|
||||
LOGIN_INVALID_AUTH_CODE,
|
||||
V1_API_ERROR_NO_PRIVILEGE,
|
||||
V1_API_ERROR_RATE_LIMITED,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import (
|
||||
@@ -26,6 +29,7 @@ from homeassistant.const import (
|
||||
CONF_TOKEN,
|
||||
CONF_URL,
|
||||
CONF_USERNAME,
|
||||
STATE_UNAVAILABLE,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
@@ -110,6 +114,149 @@ async def test_coordinator_update_failed(
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_coordinator_update_json_error(
|
||||
hass: HomeAssistant,
|
||||
mock_growatt_v1_api,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test coordinator handles JSONDecodeError gracefully."""
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
mock_growatt_v1_api.min_detail.side_effect = json.decoder.JSONDecodeError(
|
||||
"Invalid JSON", "", 0
|
||||
)
|
||||
|
||||
freezer.tick(timedelta(minutes=5))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
assert (
|
||||
hass.states.get("switch.min123456_charge_from_grid").state == STATE_UNAVAILABLE
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_coordinator_total_non_auth_api_error(
|
||||
hass: HomeAssistant,
|
||||
mock_growatt_v1_api,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test total coordinator handles non-auth V1 API errors as UpdateFailed."""
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
error = growattServer.GrowattV1ApiError("Rate limited")
|
||||
error.error_code = V1_API_ERROR_RATE_LIMITED
|
||||
mock_growatt_v1_api.plant_energy_overview.side_effect = error
|
||||
|
||||
freezer.tick(timedelta(minutes=5))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
# Should stay loaded (UpdateFailed is transient), no reauth flow started
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
flows = hass.config_entries.flow.async_progress()
|
||||
assert not any(flow["context"]["source"] == "reauth" for flow in flows)
|
||||
|
||||
|
||||
async def test_setup_auth_failed_on_permission_denied(
|
||||
hass: HomeAssistant,
|
||||
mock_growatt_v1_api,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test that error 10011 (no privilege) from device_list triggers reauth during setup."""
|
||||
error = growattServer.GrowattV1ApiError("Permission denied")
|
||||
error.error_code = V1_API_ERROR_NO_PRIVILEGE
|
||||
mock_growatt_v1_api.device_list.side_effect = error
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
|
||||
# Verify a reauth flow was started
|
||||
flows = hass.config_entries.flow.async_progress()
|
||||
assert any(
|
||||
flow["context"]["source"] == "reauth"
|
||||
and flow["context"]["entry_id"] == mock_config_entry.entry_id
|
||||
for flow in flows
|
||||
)
|
||||
|
||||
|
||||
async def test_coordinator_auth_failed_triggers_reauth(
|
||||
hass: HomeAssistant,
|
||||
mock_growatt_v1_api,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test that error 10011 (no privilege) from coordinator update triggers reauth."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
error = growattServer.GrowattV1ApiError("Permission denied")
|
||||
error.error_code = V1_API_ERROR_NO_PRIVILEGE
|
||||
mock_growatt_v1_api.min_detail.side_effect = error
|
||||
|
||||
freezer.tick(timedelta(minutes=5))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
# Verify a reauth flow was started
|
||||
flows = hass.config_entries.flow.async_progress()
|
||||
assert any(
|
||||
flow["context"]["source"] == "reauth"
|
||||
and flow["context"]["entry_id"] == mock_config_entry.entry_id
|
||||
for flow in flows
|
||||
)
|
||||
assert (
|
||||
hass.states.get("switch.min123456_charge_from_grid").state == STATE_UNAVAILABLE
|
||||
)
|
||||
|
||||
|
||||
async def test_classic_api_coordinator_auth_failed_triggers_reauth(
|
||||
hass: HomeAssistant,
|
||||
mock_growatt_classic_api,
|
||||
mock_config_entry_classic: MockConfigEntry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test that invalid classic API credentials during coordinator update trigger reauth."""
|
||||
mock_growatt_classic_api.device_list.return_value = [
|
||||
{"deviceSn": "TLX123456", "deviceType": "tlx"}
|
||||
]
|
||||
mock_growatt_classic_api.plant_info.return_value = {
|
||||
"deviceList": [],
|
||||
"totalEnergy": 1250.0,
|
||||
"todayEnergy": 12.5,
|
||||
"invTodayPpv": 2500,
|
||||
"plantMoneyText": "123.45/USD",
|
||||
}
|
||||
mock_growatt_classic_api.tlx_detail.return_value = {
|
||||
"data": {"deviceSn": "TLX123456"}
|
||||
}
|
||||
|
||||
await setup_integration(hass, mock_config_entry_classic)
|
||||
assert mock_config_entry_classic.state is ConfigEntryState.LOADED
|
||||
|
||||
# Credentials expire between updates
|
||||
mock_growatt_classic_api.login.return_value = {
|
||||
"success": False,
|
||||
"msg": LOGIN_INVALID_AUTH_CODE,
|
||||
}
|
||||
|
||||
freezer.tick(timedelta(minutes=5))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
flows = hass.config_entries.flow.async_progress()
|
||||
assert any(
|
||||
flow["context"]["source"] == "reauth"
|
||||
and flow["context"]["entry_id"] == mock_config_entry_classic.entry_id
|
||||
for flow in flows
|
||||
)
|
||||
assert hass.states.get("sensor.tlx123456_ac_frequency").state == STATE_UNAVAILABLE
|
||||
|
||||
|
||||
async def test_classic_api_setup(
|
||||
hass: HomeAssistant,
|
||||
snapshot: SnapshotAssertion,
|
||||
|
||||
@@ -980,7 +980,10 @@ async def test_reader_writer_create(
|
||||
"state": "in_progress",
|
||||
}
|
||||
|
||||
# Consume any upload progress events before the final state event
|
||||
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,
|
||||
@@ -1094,7 +1097,10 @@ async def test_reader_writer_create_addon_folder_error(
|
||||
"state": "in_progress",
|
||||
}
|
||||
|
||||
# Consume any upload progress events before the final state event
|
||||
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,
|
||||
@@ -1211,7 +1217,10 @@ async def test_reader_writer_create_report_progress(
|
||||
"state": "in_progress",
|
||||
}
|
||||
|
||||
# Consume any upload progress events before the final state event
|
||||
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,
|
||||
@@ -1273,7 +1282,10 @@ async def test_reader_writer_create_job_done(
|
||||
"state": "in_progress",
|
||||
}
|
||||
|
||||
# Consume any upload progress events before the final state event
|
||||
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,
|
||||
@@ -1536,7 +1548,10 @@ async def test_reader_writer_create_per_agent_encryption(
|
||||
"state": "in_progress",
|
||||
}
|
||||
|
||||
# Consume any upload progress events before the final state event
|
||||
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,
|
||||
@@ -1788,7 +1803,10 @@ async def test_reader_writer_create_download_remove_error(
|
||||
"state": "in_progress",
|
||||
}
|
||||
|
||||
# Consume any upload progress events before the final state event
|
||||
response = await client.receive_json()
|
||||
while "uploaded_bytes" in response["event"]:
|
||||
response = await client.receive_json()
|
||||
assert response["event"] == {
|
||||
"manager_state": "create_backup",
|
||||
"reason": "upload_failed",
|
||||
@@ -1952,7 +1970,10 @@ async def test_reader_writer_create_remote_backup(
|
||||
"state": "in_progress",
|
||||
}
|
||||
|
||||
# Consume any upload progress events before the final state event
|
||||
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,
|
||||
|
||||
@@ -241,8 +241,6 @@ def _get_set_setting_side_effect(
|
||||
def _get_set_program_options_side_effect(
|
||||
event_queue: asyncio.Queue[list[EventMessage | Exception]],
|
||||
):
|
||||
"""Set programs side effect."""
|
||||
|
||||
async def set_program_options_side_effect(ha_id: str, *_, **kwargs) -> None:
|
||||
await event_queue.put(
|
||||
[
|
||||
@@ -289,25 +287,9 @@ def _get_specific_appliance_side_effect(
|
||||
pytest.fail(f"Mock didn't include appliance with id {ha_id}")
|
||||
|
||||
|
||||
@pytest.fixture(name="client")
|
||||
def mock_client(
|
||||
appliances: list[HomeAppliance],
|
||||
appliance: HomeAppliance | None,
|
||||
request: pytest.FixtureRequest,
|
||||
) -> MagicMock:
|
||||
"""Fixture to mock Client from HomeConnect."""
|
||||
|
||||
mock = MagicMock(
|
||||
autospec=HomeConnectClient,
|
||||
)
|
||||
|
||||
event_queue: asyncio.Queue[list[EventMessage | Exception]] = asyncio.Queue()
|
||||
|
||||
async def add_events(events: list[EventMessage | Exception]) -> None:
|
||||
await event_queue.put(events)
|
||||
|
||||
mock.add_events = add_events
|
||||
|
||||
def _get_set_program_option_side_effect(
|
||||
event_queue: asyncio.Queue[list[EventMessage]],
|
||||
):
|
||||
async def set_program_option_side_effect(ha_id: str, *_, **kwargs) -> None:
|
||||
event_key = EventKey(kwargs["option_key"])
|
||||
await event_queue.put(
|
||||
@@ -331,6 +313,28 @@ def mock_client(
|
||||
]
|
||||
)
|
||||
|
||||
return set_program_option_side_effect
|
||||
|
||||
|
||||
@pytest.fixture(name="client")
|
||||
def mock_client(
|
||||
appliances: list[HomeAppliance],
|
||||
appliance: HomeAppliance | None,
|
||||
request: pytest.FixtureRequest,
|
||||
) -> MagicMock:
|
||||
"""Fixture to mock Client from HomeConnect."""
|
||||
|
||||
mock = MagicMock(
|
||||
autospec=HomeConnectClient,
|
||||
)
|
||||
|
||||
event_queue: asyncio.Queue[list[EventMessage | Exception]] = asyncio.Queue()
|
||||
|
||||
async def add_events(events: list[EventMessage | Exception]) -> None:
|
||||
await event_queue.put(events)
|
||||
|
||||
mock.add_events = add_events
|
||||
|
||||
appliances = [appliance] if appliance else appliances
|
||||
|
||||
async def stream_all_events() -> AsyncGenerator[EventMessage]:
|
||||
@@ -408,15 +412,9 @@ def mock_client(
|
||||
),
|
||||
)
|
||||
mock.stop_program = AsyncMock()
|
||||
mock.set_active_program_option = AsyncMock(
|
||||
side_effect=_get_set_program_options_side_effect(event_queue),
|
||||
)
|
||||
mock.set_active_program_options = AsyncMock(
|
||||
side_effect=_get_set_program_options_side_effect(event_queue),
|
||||
)
|
||||
mock.set_selected_program_option = AsyncMock(
|
||||
side_effect=_get_set_program_options_side_effect(event_queue),
|
||||
)
|
||||
mock.set_selected_program_options = AsyncMock(
|
||||
side_effect=_get_set_program_options_side_effect(event_queue),
|
||||
)
|
||||
@@ -437,10 +435,10 @@ def mock_client(
|
||||
mock.get_active_program_options = AsyncMock(return_value=ArrayOfOptions([]))
|
||||
mock.get_selected_program_options = AsyncMock(return_value=ArrayOfOptions([]))
|
||||
mock.set_active_program_option = AsyncMock(
|
||||
side_effect=set_program_option_side_effect
|
||||
side_effect=_get_set_program_option_side_effect(event_queue)
|
||||
)
|
||||
mock.set_selected_program_option = AsyncMock(
|
||||
side_effect=set_program_option_side_effect
|
||||
side_effect=_get_set_program_option_side_effect(event_queue)
|
||||
)
|
||||
|
||||
mock.side_effect = mock
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
"haId": "123456789012345678"
|
||||
},
|
||||
{
|
||||
"name": "AirConditioner",
|
||||
"name": "Air conditioner",
|
||||
"brand": "BOSCH",
|
||||
"vib": "HCS000006",
|
||||
"connected": true,
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
'connected': True,
|
||||
'e_number': 'HCS000000/07',
|
||||
'ha_id': '8765432109876543210',
|
||||
'name': 'AirConditioner',
|
||||
'name': 'Air conditioner',
|
||||
'programs': list([
|
||||
'HeatingVentilationAirConditioning.AirConditioner.Program.ActiveClean',
|
||||
'HeatingVentilationAirConditioning.AirConditioner.Program.Auto',
|
||||
|
||||
665
tests/components/home_connect/test_fan.py
Normal file
665
tests/components/home_connect/test_fan.py
Normal file
@@ -0,0 +1,665 @@
|
||||
"""Tests for home_connect fan entities."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from aiohomeconnect.model import (
|
||||
ArrayOfEvents,
|
||||
Event,
|
||||
EventKey,
|
||||
EventMessage,
|
||||
EventType,
|
||||
HomeAppliance,
|
||||
OptionKey,
|
||||
ProgramDefinition,
|
||||
ProgramKey,
|
||||
)
|
||||
from aiohomeconnect.model.error import (
|
||||
ActiveProgramNotSetError,
|
||||
HomeConnectApiError,
|
||||
HomeConnectError,
|
||||
SelectedProgramNotSetError,
|
||||
)
|
||||
from aiohomeconnect.model.program import (
|
||||
ProgramDefinitionConstraints,
|
||||
ProgramDefinitionOption,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.fan import (
|
||||
ATTR_PERCENTAGE,
|
||||
ATTR_PRESET_MODE,
|
||||
ATTR_PRESET_MODES,
|
||||
DOMAIN as FAN_DOMAIN,
|
||||
SERVICE_SET_PERCENTAGE,
|
||||
SERVICE_SET_PRESET_MODE,
|
||||
FanEntityFeature,
|
||||
)
|
||||
from homeassistant.components.home_connect.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
STATE_UNAVAILABLE,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def platforms() -> list[str]:
|
||||
"""Fixture to specify platforms to test."""
|
||||
return [Platform.FAN]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def get_available_program_fixture(
|
||||
client: MagicMock,
|
||||
) -> None:
|
||||
"""Mock get_available_program."""
|
||||
client.get_available_program = AsyncMock(
|
||||
return_value=ProgramDefinition(
|
||||
ProgramKey.UNKNOWN,
|
||||
options=[
|
||||
ProgramDefinitionOption(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
"Enumeration",
|
||||
),
|
||||
ProgramDefinitionOption(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE,
|
||||
"Enumeration",
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
|
||||
async def test_paired_depaired_devices_flow(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
appliance: HomeAppliance,
|
||||
) -> None:
|
||||
"""Test that removed devices are correctly removed from and added to hass on API events."""
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)})
|
||||
assert device
|
||||
entity_entries = entity_registry.entities.get_entries_for_device_id(device.id)
|
||||
assert entity_entries
|
||||
|
||||
await client.add_events(
|
||||
[
|
||||
EventMessage(
|
||||
appliance.ha_id,
|
||||
EventType.DEPAIRED,
|
||||
data=ArrayOfEvents([]),
|
||||
)
|
||||
]
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)})
|
||||
assert not device
|
||||
for entity_entry in entity_entries:
|
||||
assert not entity_registry.async_get(entity_entry.entity_id)
|
||||
|
||||
# Now that everything related to the device is removed, pair it again
|
||||
await client.add_events(
|
||||
[
|
||||
EventMessage(
|
||||
appliance.ha_id,
|
||||
EventType.PAIRED,
|
||||
data=ArrayOfEvents([]),
|
||||
)
|
||||
]
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)})
|
||||
for entity_entry in entity_entries:
|
||||
assert entity_registry.async_get(entity_entry.entity_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
|
||||
async def test_connected_devices(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
appliance: HomeAppliance,
|
||||
) -> None:
|
||||
"""Test that devices reconnect.
|
||||
|
||||
Specifically those devices whose settings, status, etc. could
|
||||
not be obtained while disconnected and once connected, the entities are added.
|
||||
"""
|
||||
get_settings_original_mock = client.get_settings
|
||||
get_all_programs_mock = client.get_all_programs
|
||||
|
||||
async def get_settings_side_effect(ha_id: str):
|
||||
if ha_id == appliance.ha_id:
|
||||
raise HomeConnectApiError(
|
||||
"SDK.Error.HomeAppliance.Connection.Initialization.Failed"
|
||||
)
|
||||
return await get_settings_original_mock.side_effect(ha_id)
|
||||
|
||||
async def get_all_programs_side_effect(ha_id: str):
|
||||
if ha_id == appliance.ha_id:
|
||||
raise HomeConnectApiError(
|
||||
"SDK.Error.HomeAppliance.Connection.Initialization.Failed"
|
||||
)
|
||||
return await get_all_programs_mock.side_effect(ha_id)
|
||||
|
||||
client.get_settings = AsyncMock(side_effect=get_settings_side_effect)
|
||||
client.get_all_programs = AsyncMock(side_effect=get_all_programs_side_effect)
|
||||
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
client.get_settings = get_settings_original_mock
|
||||
client.get_all_programs = get_all_programs_mock
|
||||
|
||||
device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)})
|
||||
assert device
|
||||
assert not entity_registry.async_get_entity_id(
|
||||
Platform.FAN,
|
||||
DOMAIN,
|
||||
f"{appliance.ha_id}-air_conditioner",
|
||||
)
|
||||
|
||||
await client.add_events(
|
||||
[
|
||||
EventMessage(
|
||||
appliance.ha_id,
|
||||
EventType.CONNECTED,
|
||||
data=ArrayOfEvents([]),
|
||||
)
|
||||
]
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entity_registry.async_get_entity_id(
|
||||
Platform.FAN,
|
||||
DOMAIN,
|
||||
f"{appliance.ha_id}-air_conditioner",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
|
||||
async def test_fan_entity_availability(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
appliance: HomeAppliance,
|
||||
) -> None:
|
||||
"""Test if fan entities availability are based on the appliance connection state."""
|
||||
entity_ids = [
|
||||
"fan.air_conditioner",
|
||||
]
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
for entity_id in entity_ids:
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.state != STATE_UNAVAILABLE
|
||||
|
||||
await client.add_events(
|
||||
[
|
||||
EventMessage(
|
||||
appliance.ha_id,
|
||||
EventType.DISCONNECTED,
|
||||
ArrayOfEvents([]),
|
||||
)
|
||||
]
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
for entity_id in entity_ids:
|
||||
assert hass.states.is_state(entity_id, STATE_UNAVAILABLE)
|
||||
|
||||
await client.add_events(
|
||||
[
|
||||
EventMessage(
|
||||
appliance.ha_id,
|
||||
EventType.CONNECTED,
|
||||
ArrayOfEvents([]),
|
||||
)
|
||||
]
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
for entity_id in entity_ids:
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.state != STATE_UNAVAILABLE
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"set_active_program_options_side_effect",
|
||||
"set_selected_program_options_side_effect",
|
||||
"called_mock_method",
|
||||
),
|
||||
[
|
||||
(
|
||||
None,
|
||||
SelectedProgramNotSetError("error.key"),
|
||||
"set_active_program_option",
|
||||
),
|
||||
(
|
||||
ActiveProgramNotSetError("error.key"),
|
||||
None,
|
||||
"set_selected_program_option",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_speed_percentage_functionality(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
appliance: HomeAppliance,
|
||||
set_active_program_options_side_effect: ActiveProgramNotSetError | None,
|
||||
set_selected_program_options_side_effect: SelectedProgramNotSetError | None,
|
||||
called_mock_method: str,
|
||||
) -> None:
|
||||
"""Test speed percentage functionality."""
|
||||
entity_id = "fan.air_conditioner"
|
||||
option_key = OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE
|
||||
if set_active_program_options_side_effect:
|
||||
client.set_active_program_option.side_effect = (
|
||||
set_active_program_options_side_effect
|
||||
)
|
||||
else:
|
||||
assert set_selected_program_options_side_effect
|
||||
client.set_selected_program_option.side_effect = (
|
||||
set_selected_program_options_side_effect
|
||||
)
|
||||
called_mock: AsyncMock = getattr(client, called_mock_method)
|
||||
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
assert not hass.states.is_state(entity_id, "50")
|
||||
|
||||
await hass.services.async_call(
|
||||
FAN_DOMAIN,
|
||||
SERVICE_SET_PERCENTAGE,
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
ATTR_PERCENTAGE: 50,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
called_mock.assert_called_once_with(
|
||||
appliance.ha_id,
|
||||
option_key=option_key,
|
||||
value=50,
|
||||
)
|
||||
entity_state = hass.states.get(entity_id)
|
||||
assert entity_state
|
||||
assert entity_state.attributes[ATTR_PERCENTAGE] == 50
|
||||
|
||||
|
||||
async def test_set_speed_raises_home_assistant_error_on_api_errors(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
) -> None:
|
||||
"""Test that setting a fan mode raises HomeAssistantError on API errors."""
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
client.set_active_program_option.side_effect = HomeConnectError("Test error")
|
||||
with pytest.raises(HomeAssistantError, match="Test error"):
|
||||
await hass.services.async_call(
|
||||
FAN_DOMAIN,
|
||||
SERVICE_SET_PERCENTAGE,
|
||||
{
|
||||
ATTR_ENTITY_ID: "fan.air_conditioner",
|
||||
ATTR_PERCENTAGE: 50,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"set_active_program_options_side_effect",
|
||||
"set_selected_program_options_side_effect",
|
||||
"called_mock_method",
|
||||
),
|
||||
[
|
||||
(
|
||||
None,
|
||||
SelectedProgramNotSetError("error.key"),
|
||||
"set_active_program_option",
|
||||
),
|
||||
(
|
||||
ActiveProgramNotSetError("error.key"),
|
||||
None,
|
||||
"set_selected_program_option",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("allowed_values", "expected_fan_modes"),
|
||||
[
|
||||
(
|
||||
None,
|
||||
["auto", "manual"],
|
||||
),
|
||||
(
|
||||
[
|
||||
"HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Automatic",
|
||||
"HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Manual",
|
||||
],
|
||||
["auto", "manual"],
|
||||
),
|
||||
(
|
||||
[
|
||||
"HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Automatic",
|
||||
],
|
||||
["auto"],
|
||||
),
|
||||
(
|
||||
[
|
||||
"HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Manual",
|
||||
"A.Non.Documented.Option",
|
||||
],
|
||||
["manual"],
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_preset_mode_functionality(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
appliance: HomeAppliance,
|
||||
allowed_values: list[str | None] | None,
|
||||
expected_fan_modes: list[str],
|
||||
set_active_program_options_side_effect: ActiveProgramNotSetError | None,
|
||||
set_selected_program_options_side_effect: SelectedProgramNotSetError | None,
|
||||
called_mock_method: str,
|
||||
) -> None:
|
||||
"""Test preset mode functionality."""
|
||||
entity_id = "fan.air_conditioner"
|
||||
option_key = (
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
|
||||
)
|
||||
if set_active_program_options_side_effect:
|
||||
client.set_active_program_option.side_effect = (
|
||||
set_active_program_options_side_effect
|
||||
)
|
||||
else:
|
||||
assert set_selected_program_options_side_effect
|
||||
client.set_selected_program_option.side_effect = (
|
||||
set_selected_program_options_side_effect
|
||||
)
|
||||
called_mock: AsyncMock = getattr(client, called_mock_method)
|
||||
client.get_available_program = AsyncMock(
|
||||
return_value=ProgramDefinition(
|
||||
ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO,
|
||||
options=[
|
||||
ProgramDefinitionOption(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
"Enumeration",
|
||||
constraints=ProgramDefinitionConstraints(
|
||||
allowed_values=allowed_values
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
entity_state = hass.states.get(entity_id)
|
||||
assert entity_state
|
||||
assert entity_state.attributes[ATTR_PRESET_MODES] == expected_fan_modes
|
||||
assert entity_state.attributes[ATTR_PRESET_MODE] != expected_fan_modes[0]
|
||||
|
||||
await hass.services.async_call(
|
||||
FAN_DOMAIN,
|
||||
SERVICE_SET_PRESET_MODE,
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
ATTR_PRESET_MODE: expected_fan_modes[0],
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
called_mock.assert_called_once_with(
|
||||
appliance.ha_id,
|
||||
option_key=option_key,
|
||||
value=allowed_values[0]
|
||||
if allowed_values
|
||||
else "HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Automatic",
|
||||
)
|
||||
entity_state = hass.states.get(entity_id)
|
||||
assert entity_state
|
||||
assert entity_state.attributes[ATTR_PRESET_MODE] == expected_fan_modes[0]
|
||||
|
||||
|
||||
async def test_set_preset_mode_raises_home_assistant_error_on_api_errors(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
) -> None:
|
||||
"""Test that setting a fan mode raises HomeAssistantError on API errors."""
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
client.set_active_program_option.side_effect = HomeConnectError("Test error")
|
||||
with pytest.raises(HomeAssistantError, match="Test error"):
|
||||
await hass.services.async_call(
|
||||
FAN_DOMAIN,
|
||||
SERVICE_SET_PRESET_MODE,
|
||||
{
|
||||
ATTR_ENTITY_ID: "fan.air_conditioner",
|
||||
ATTR_PRESET_MODE: "auto",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
|
||||
@pytest.mark.parametrize(
|
||||
("option_key", "expected_fan_feature"),
|
||||
[
|
||||
(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
FanEntityFeature.PRESET_MODE,
|
||||
),
|
||||
(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE,
|
||||
FanEntityFeature.SET_SPEED,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_supported_features(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
appliance: HomeAppliance,
|
||||
option_key: OptionKey,
|
||||
expected_fan_feature: FanEntityFeature,
|
||||
) -> None:
|
||||
"""Test that supported features are detected correctly."""
|
||||
client.get_available_program = AsyncMock(
|
||||
return_value=ProgramDefinition(
|
||||
ProgramKey.UNKNOWN,
|
||||
options=[
|
||||
ProgramDefinitionOption(
|
||||
option_key,
|
||||
"Enumeration",
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
entity_id = "fan.air_conditioner"
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
|
||||
assert state.attributes[ATTR_SUPPORTED_FEATURES] & expected_fan_feature
|
||||
|
||||
client.get_available_program = AsyncMock(
|
||||
return_value=ProgramDefinition(
|
||||
ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO,
|
||||
options=[],
|
||||
)
|
||||
)
|
||||
await client.add_events(
|
||||
[
|
||||
EventMessage(
|
||||
appliance.ha_id,
|
||||
EventType.NOTIFY,
|
||||
data=ArrayOfEvents(
|
||||
[
|
||||
Event(
|
||||
key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
|
||||
raw_key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM.value,
|
||||
timestamp=0,
|
||||
level="",
|
||||
handling="",
|
||||
value=ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO,
|
||||
)
|
||||
]
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert not state.attributes[ATTR_SUPPORTED_FEATURES] & expected_fan_feature
|
||||
|
||||
client.get_available_program = AsyncMock(
|
||||
return_value=ProgramDefinition(
|
||||
ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO,
|
||||
options=[
|
||||
ProgramDefinitionOption(
|
||||
option_key,
|
||||
"Enumeration",
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
await client.add_events(
|
||||
[
|
||||
EventMessage(
|
||||
appliance.ha_id,
|
||||
EventType.NOTIFY,
|
||||
data=ArrayOfEvents(
|
||||
[
|
||||
Event(
|
||||
key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
|
||||
raw_key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM.value,
|
||||
timestamp=0,
|
||||
level="",
|
||||
handling="",
|
||||
value=ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO.value,
|
||||
)
|
||||
]
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.attributes[ATTR_SUPPORTED_FEATURES] & expected_fan_feature
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
|
||||
async def test_added_entity_automatically(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
appliance: HomeAppliance,
|
||||
) -> None:
|
||||
"""Test that no fan entity is created if no fan options are available but when they are added later, the entity is created."""
|
||||
entity_id = "fan.air_conditioner"
|
||||
client.get_available_program = AsyncMock(
|
||||
return_value=ProgramDefinition(
|
||||
ProgramKey.UNKNOWN,
|
||||
options=[
|
||||
ProgramDefinitionOption(
|
||||
OptionKey.LAUNDRY_CARE_WASHER_SPIN_SPEED,
|
||||
"Enumeration",
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
assert not hass.states.get(entity_id)
|
||||
|
||||
client.get_available_program = AsyncMock(
|
||||
return_value=ProgramDefinition(
|
||||
ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO,
|
||||
options=[
|
||||
ProgramDefinitionOption(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
"Enumeration",
|
||||
),
|
||||
ProgramDefinitionOption(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE,
|
||||
"Enumeration",
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
await client.add_events(
|
||||
[
|
||||
EventMessage(
|
||||
appliance.ha_id,
|
||||
EventType.NOTIFY,
|
||||
data=ArrayOfEvents(
|
||||
[
|
||||
Event(
|
||||
key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
|
||||
raw_key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM.value,
|
||||
timestamp=0,
|
||||
level="",
|
||||
handling="",
|
||||
value=ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO.value,
|
||||
)
|
||||
]
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get(entity_id)
|
||||
@@ -42,7 +42,7 @@ def mock_lutron() -> Generator[MagicMock]:
|
||||
# Mock a light
|
||||
light = MagicMock()
|
||||
light.name = "Test Light"
|
||||
light.id = "light_id"
|
||||
light.id = 1
|
||||
light.uuid = "light_uuid"
|
||||
light.legacy_uuid = "light_legacy_uuid"
|
||||
light.is_dimmable = True
|
||||
@@ -53,7 +53,7 @@ def mock_lutron() -> Generator[MagicMock]:
|
||||
# Mock a switch
|
||||
switch = MagicMock()
|
||||
switch.name = "Test Switch"
|
||||
switch.id = "switch_id"
|
||||
switch.id = 2
|
||||
switch.uuid = "switch_uuid"
|
||||
switch.legacy_uuid = "switch_legacy_uuid"
|
||||
switch.is_dimmable = False
|
||||
@@ -64,7 +64,7 @@ def mock_lutron() -> Generator[MagicMock]:
|
||||
# Mock a cover
|
||||
cover = MagicMock()
|
||||
cover.name = "Test Cover"
|
||||
cover.id = "cover_id"
|
||||
cover.id = 3
|
||||
cover.uuid = "cover_uuid"
|
||||
cover.legacy_uuid = "cover_legacy_uuid"
|
||||
cover.type = "SYSTEM_SHADE"
|
||||
@@ -74,6 +74,7 @@ def mock_lutron() -> Generator[MagicMock]:
|
||||
# Mock a fan
|
||||
fan = MagicMock()
|
||||
fan.name = "Test Fan"
|
||||
fan.id = 4
|
||||
fan.uuid = "fan_uuid"
|
||||
fan.legacy_uuid = "fan_legacy_uuid"
|
||||
fan.type = "CEILING_FAN_TYPE"
|
||||
@@ -108,7 +109,7 @@ def mock_lutron() -> Generator[MagicMock]:
|
||||
# Mock an occupancy group
|
||||
occ_group = MagicMock()
|
||||
occ_group.name = "Test Occupancy"
|
||||
occ_group.id = "occ_id"
|
||||
occ_group.id = 5
|
||||
occ_group.uuid = "occ_uuid"
|
||||
occ_group.legacy_uuid = "occ_legacy_uuid"
|
||||
occ_group.state = OccupancyGroup.State.VACANT
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'occupancy',
|
||||
'friendly_name': 'Test Occupancy Occupancy',
|
||||
'lutron_integration_id': 'occ_id',
|
||||
'lutron_integration_id': 5,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.test_occupancy_occupancy',
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
'current_position': 0,
|
||||
'friendly_name': 'Test Cover',
|
||||
'is_closed': True,
|
||||
'lutron_integration_id': 'cover_id',
|
||||
'lutron_integration_id': 3,
|
||||
'supported_features': <CoverEntityFeature: 7>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
'brightness': None,
|
||||
'color_mode': None,
|
||||
'friendly_name': 'Test Light',
|
||||
'lutron_integration_id': 'light_id',
|
||||
'lutron_integration_id': 1,
|
||||
'supported_color_modes': list([
|
||||
<ColorMode.BRIGHTNESS: 'brightness'>,
|
||||
]),
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Test Switch',
|
||||
'lutron_integration_id': 'switch_id',
|
||||
'lutron_integration_id': 2,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'switch.test_switch',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from pylutron import OccupancyGroup
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON, Platform
|
||||
@@ -12,6 +13,13 @@ from homeassistant.helpers import entity_registry as er
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_platforms():
|
||||
"""Patch PLATFORMS for all tests in this file."""
|
||||
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.BINARY_SENSOR]):
|
||||
yield
|
||||
|
||||
|
||||
async def test_binary_sensor_setup(
|
||||
hass: HomeAssistant,
|
||||
mock_lutron: MagicMock,
|
||||
@@ -25,9 +33,8 @@ async def test_binary_sensor_setup(
|
||||
occ_group = mock_lutron.areas[0].occupancy_group
|
||||
occ_group.state = OccupancyGroup.State.VACANT
|
||||
|
||||
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.BINARY_SENSOR]):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.cover import DOMAIN as COVER_DOMAIN
|
||||
@@ -20,6 +21,13 @@ from homeassistant.helpers import entity_registry as er
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_platforms():
|
||||
"""Patch PLATFORMS for all tests in this file."""
|
||||
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.COVER]):
|
||||
yield
|
||||
|
||||
|
||||
async def test_cover_setup(
|
||||
hass: HomeAssistant,
|
||||
mock_lutron: MagicMock,
|
||||
@@ -34,9 +42,8 @@ async def test_cover_setup(
|
||||
cover.level = 0
|
||||
cover.last_level.return_value = 0
|
||||
|
||||
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.COVER]):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from pylutron import Button
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.const import Platform
|
||||
@@ -12,6 +13,13 @@ from homeassistant.helpers import entity_registry as er
|
||||
from tests.common import MockConfigEntry, async_capture_events, snapshot_platform
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_platforms():
|
||||
"""Patch PLATFORMS for all tests in this file."""
|
||||
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.EVENT]):
|
||||
yield
|
||||
|
||||
|
||||
async def test_event_setup(
|
||||
hass: HomeAssistant,
|
||||
mock_lutron: MagicMock,
|
||||
@@ -22,9 +30,8 @@ async def test_event_setup(
|
||||
"""Test event setup."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.EVENT]):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.fan import (
|
||||
@@ -18,6 +19,13 @@ from homeassistant.helpers import entity_registry as er
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_platforms():
|
||||
"""Patch PLATFORMS for all tests in this file."""
|
||||
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.FAN]):
|
||||
yield
|
||||
|
||||
|
||||
async def test_fan_setup(
|
||||
hass: HomeAssistant,
|
||||
mock_lutron: MagicMock,
|
||||
@@ -32,9 +40,8 @@ async def test_fan_setup(
|
||||
fan.level = 0
|
||||
fan.last_level.return_value = 0
|
||||
|
||||
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.FAN]):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@@ -25,6 +25,13 @@ from homeassistant.helpers import entity_registry as er
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_platforms():
|
||||
"""Patch PLATFORMS for all tests in this file."""
|
||||
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.LIGHT]):
|
||||
yield
|
||||
|
||||
|
||||
async def test_light_setup(
|
||||
hass: HomeAssistant,
|
||||
mock_lutron: MagicMock,
|
||||
@@ -39,9 +46,8 @@ async def test_light_setup(
|
||||
light.level = 0
|
||||
light.last_level.return_value = 0
|
||||
|
||||
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.LIGHT]):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user