Compare commits

...

27 Commits

Author SHA1 Message Date
Paul Bottein
c9537f7357 Merge branch 'dev' into state_attr_translated 2026-03-11 17:49:04 +01:00
Josef Zweck
2eb65ab314 Buffer backup upload progress events (#165249)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-03-11 17:29:35 +01:00
ams2990
402a37b435 Change light.toggle service call to invoke LightEntity.async_toggle (#156196)
Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2026-03-11 17:17:10 +01:00
Erik Montnemery
aa66e8ef0c Improve humidity triggers (#165323) 2026-03-11 17:11:27 +01:00
noambav
f1a1e284b7 Add support for Fish Audio s2-pro model (#165269) 2026-03-11 17:07:56 +01:00
hanwg
08594f4e0c Update migration message for Telegram bot (#165299) 2026-03-11 17:04:16 +01:00
Joakim Plate
8d810588f8 Move secondary zone of arcam to sub-device (#165336) 2026-03-11 16:57:47 +01:00
Sid
70faad15d5 Add binary_sensor to eheimdigital (#165035)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-11 16:21:16 +01:00
TheJulianJES
d447843687 Bump python-otbr-api to 2.9.0 (#165298) 2026-03-11 16:15:35 +01:00
Steve Easley
83b64e29fa Bump pyjvcprojector to 2.0.3 (#165327) 2026-03-11 16:13:26 +01:00
tronikos
4558a10e05 Improve test coverage in Opower to make it silver (#165124) 2026-03-11 15:56:31 +01:00
johanzander
5ad9e81082 Add reauthentication flow to growatt_server (silver quality scale) (#164993)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 15:51:25 +01:00
cdheiser
ba00a14772 Fix flakiness in lutron tests and isolate platforms per test file (#165328) 2026-03-11 15:08:00 +01:00
J. Diego Rodríguez Royo
49f4d07eeb Add fan entity for air conditioner to Home Connect (#155983)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-03-11 14:29:01 +01:00
Dan Raper
5d271a0d30 Bump ohme to 1.7.0 (#165318) 2026-03-11 12:49:07 +01:00
Joakim Plate
474b683d3c Update gardena to 2.1.0 (#165322) 2026-03-11 12:48:24 +01:00
Erik Montnemery
d37106a360 Add gate triggers (#165228) 2026-03-11 10:59:53 +01:00
Paul Bottein
57a33dd34d Add state_attr_translated template filter and function 2026-03-11 10:24:28 +01:00
epenet
e115c90719 Reduce internal testing in arcam_fmj tests (#165315) 2026-03-11 10:14:24 +01:00
epenet
6ad3adf0c3 Remove duplicate fixture in arcam_fmj tests (#165312) 2026-03-11 09:51:51 +01:00
dependabot[bot]
2a8d59be4c Bump docker/login-action from 3.7.0 to 4.0.0 (#165302) 2026-03-11 09:16:34 +01:00
dependabot[bot]
6e6e35bc3b Bump actions/dependency-review-action from 4.8.3 to 4.9.0 (#165304) 2026-03-11 09:15:36 +01:00
epenet
795b4c8414 Fix incorrect type annotations in tests (#165305) 2026-03-11 08:38:58 +01:00
Luke Lashley
16389dc18e Bump python-roborock to 4.20.0 (#165292) 2026-03-11 08:21:28 +01:00
Erik Montnemery
e7a1c8d001 Remove triggers binary_sensor.occupancy_cleared and occupancy_detected (#165181) 2026-03-11 07:37:40 +01:00
Luke Lashley
4efb10dae1 Remove an extra roborock trait from updating (#165297) 2026-03-11 02:31:10 +01:00
Erik Montnemery
f163576e78 Fail more tests when pytest_socket.SocketBlockedError is raised (#155398)
Co-authored-by: Franck Nijhof <git@frenck.dev>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-03-11 00:00:00 +01:00
112 changed files with 4146 additions and 911 deletions

View File

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

View File

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

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

View File

@@ -243,6 +243,7 @@ DEFAULT_INTEGRATIONS = {
# Integrations providing triggers and conditions for base platforms:
"door",
"garage_door",
"gate",
"humidity",
}
DEFAULT_INTEGRATIONS_RECOVERY_MODE = {

View File

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

View File

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

View File

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

View File

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

View File

@@ -174,13 +174,5 @@
"on": "mdi:window-open"
}
}
},
"triggers": {
"occupancy_cleared": {
"trigger": "mdi:home-outline"
},
"occupancy_detected": {
"trigger": "mdi:home"
}
}
}

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ from .const import DOMAIN
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.CLIMATE,
Platform.LIGHT,
Platform.NUMBER,

View 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)

View File

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

View File

@@ -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": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"]
}

View File

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

View File

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

View File

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

View 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")

View File

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

View 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

View File

@@ -0,0 +1,10 @@
{
"triggers": {
"closed": {
"trigger": "mdi:gate"
},
"opened": {
"trigger": "mdi:gate-open"
}
}
}

View 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"
}

View 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"
}
}
}

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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,
)
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,7 @@
selector:
number:
mode: box
unit_of_measurement: "%"
entity:
selector:
entity:

View File

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

View File

@@ -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"]
}

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["jvcprojector"],
"requirements": ["pyjvcprojector==2.0.2"]
"requirements": ["pyjvcprojector==2.0.3"]
}

View File

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

View File

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

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "cloud_polling",
"quality_scale": "platinum",
"requirements": ["ohme==1.6.0"]
"requirements": ["ohme==1.7.0"]
}

View File

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

View File

@@ -8,6 +8,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["opower"],
"quality_scale": "bronze",
"quality_scale": "silver",
"requirements": ["opower==0.17.0"]
}

View File

@@ -39,7 +39,7 @@ rules:
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage: todo
test-coverage: done
# Gold
devices:

View File

@@ -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"]
}

View File

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

View File

@@ -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"
]
}

View File

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

View File

@@ -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."]
}

View File

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

View File

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

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

View File

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

View File

@@ -77,6 +77,7 @@ NO_IOT_CLASS = [
"file_upload",
"frontend",
"garage_door",
"gate",
"hardkernel",
"hardware",
"history",

View File

@@ -2112,6 +2112,7 @@ NO_QUALITY_SCALE = [
"file_upload",
"frontend",
"garage_door",
"gate",
"hardkernel",
"hardware",
"history",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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',
})
# ---

View 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])

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View 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',
})
# ---

View File

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

View File

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

View File

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

View File

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

View File

@@ -109,7 +109,7 @@
"haId": "123456789012345678"
},
{
"name": "AirConditioner",
"name": "Air conditioner",
"brand": "BOSCH",
"vib": "HCS000006",
"connected": true,

View File

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

View 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)

View File

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

View File

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

View File

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

View File

@@ -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'>,
]),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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