mirror of
https://github.com/home-assistant/core.git
synced 2026-03-13 06:22:03 +01:00
Compare commits
21 Commits
fix_numeri
...
scop-actio
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2294b7dfee | ||
|
|
30aec4d2ab | ||
|
|
335abd7002 | ||
|
|
3b3f0e9240 | ||
|
|
49586d1519 | ||
|
|
c63ded3522 | ||
|
|
2eb65ab314 | ||
|
|
402a37b435 | ||
|
|
aa66e8ef0c | ||
|
|
f1a1e284b7 | ||
|
|
08594f4e0c | ||
|
|
8d810588f8 | ||
|
|
70faad15d5 | ||
|
|
d447843687 | ||
|
|
83b64e29fa | ||
|
|
4558a10e05 | ||
|
|
5ad9e81082 | ||
|
|
ba00a14772 | ||
|
|
49f4d07eeb | ||
|
|
5d271a0d30 | ||
|
|
474b683d3c |
2
.github/workflows/builder.yml
vendored
2
.github/workflows/builder.yml
vendored
@@ -614,7 +614,7 @@ jobs:
|
||||
|
||||
- name: Generate artifact attestation
|
||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
|
||||
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
||||
with:
|
||||
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
|
||||
@@ -101,7 +101,10 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
|
||||
assert method is not None
|
||||
|
||||
await method(self.device, state)
|
||||
await self.coordinator.async_request_refresh()
|
||||
self.coordinator.data[self.device.serial_number].sensors[
|
||||
self.entity_description.key
|
||||
].value = state
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
|
||||
@@ -11,8 +11,11 @@ from arcam.fmj.state import State
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -50,6 +53,23 @@ class ArcamFmjCoordinator(DataUpdateCoordinator[None]):
|
||||
self.state = State(client, zone)
|
||||
self.last_update_success = False
|
||||
|
||||
name = config_entry.title
|
||||
unique_id = config_entry.unique_id or config_entry.entry_id
|
||||
unique_id_device = unique_id
|
||||
if zone != 1:
|
||||
unique_id_device += f"-{zone}"
|
||||
name += f" Zone {zone}"
|
||||
|
||||
self.device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, unique_id_device)},
|
||||
manufacturer="Arcam",
|
||||
model="Arcam FMJ AVR",
|
||||
name=name,
|
||||
)
|
||||
|
||||
if zone != 1:
|
||||
self.device_info["via_device"] = (DOMAIN, unique_id)
|
||||
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Fetch data for manual refresh."""
|
||||
try:
|
||||
|
||||
@@ -21,11 +21,10 @@ from homeassistant.components.media_player import (
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, EVENT_TURN_ON
|
||||
from .const import EVENT_TURN_ON
|
||||
from .coordinator import ArcamFmjConfigEntry, ArcamFmjCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -97,14 +96,7 @@ class ArcamFmj(CoordinatorEntity[ArcamFmjCoordinator], MediaPlayerEntity):
|
||||
self._attr_supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE
|
||||
self._attr_unique_id = f"{uuid}-{self._state.zn}"
|
||||
self._attr_entity_registry_enabled_default = self._state.zn == 1
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={
|
||||
(DOMAIN, uuid),
|
||||
},
|
||||
manufacturer="Arcam",
|
||||
model="Arcam FMJ AVR",
|
||||
name=device_name,
|
||||
)
|
||||
self._attr_device_info = coordinator.device_info
|
||||
|
||||
@property
|
||||
def state(self) -> MediaPlayerState:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -13,6 +13,6 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["acme", "hass_nabucasa", "snitun"],
|
||||
"requirements": ["hass-nabucasa==1.15.0", "openai==2.21.0"],
|
||||
"requirements": ["hass-nabucasa==2.0.0", "openai==2.21.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ from .const import DOMAIN
|
||||
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.CLIMATE,
|
||||
Platform.LIGHT,
|
||||
Platform.NUMBER,
|
||||
|
||||
101
homeassistant/components/eheimdigital/binary_sensor.py
Normal file
101
homeassistant/components/eheimdigital/binary_sensor.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""EHEIM Digital binary sensors."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from eheimdigital.device import EheimDigitalDevice
|
||||
from eheimdigital.reeflex import EheimDigitalReeflexUV
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
|
||||
from .entity import EheimDigitalEntity
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class EheimDigitalBinarySensorDescription[_DeviceT: EheimDigitalDevice](
|
||||
BinarySensorEntityDescription
|
||||
):
|
||||
"""Class describing EHEIM Digital binary sensor entities."""
|
||||
|
||||
value_fn: Callable[[_DeviceT], bool | None]
|
||||
|
||||
|
||||
REEFLEX_DESCRIPTIONS: tuple[
|
||||
EheimDigitalBinarySensorDescription[EheimDigitalReeflexUV], ...
|
||||
] = (
|
||||
EheimDigitalBinarySensorDescription[EheimDigitalReeflexUV](
|
||||
key="is_lighting",
|
||||
translation_key="is_lighting",
|
||||
value_fn=lambda device: device.is_lighting,
|
||||
device_class=BinarySensorDeviceClass.LIGHT,
|
||||
),
|
||||
EheimDigitalBinarySensorDescription[EheimDigitalReeflexUV](
|
||||
key="is_uvc_connected",
|
||||
translation_key="is_uvc_connected",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda device: device.is_uvc_connected,
|
||||
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: EheimDigitalConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the callbacks for the coordinator so binary sensors can be added as devices are found."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
def async_setup_device_entities(
|
||||
device_address: dict[str, EheimDigitalDevice],
|
||||
) -> None:
|
||||
"""Set up the binary sensor entities for one or multiple devices."""
|
||||
entities: list[EheimDigitalBinarySensor[Any]] = []
|
||||
for device in device_address.values():
|
||||
if isinstance(device, EheimDigitalReeflexUV):
|
||||
entities += [
|
||||
EheimDigitalBinarySensor[EheimDigitalReeflexUV](
|
||||
coordinator, device, description
|
||||
)
|
||||
for description in REEFLEX_DESCRIPTIONS
|
||||
]
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
coordinator.add_platform_callback(async_setup_device_entities)
|
||||
async_setup_device_entities(coordinator.hub.devices)
|
||||
|
||||
|
||||
class EheimDigitalBinarySensor[_DeviceT: EheimDigitalDevice](
|
||||
EheimDigitalEntity[_DeviceT], BinarySensorEntity
|
||||
):
|
||||
"""Represent an EHEIM Digital binary sensor entity."""
|
||||
|
||||
entity_description: EheimDigitalBinarySensorDescription[_DeviceT]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: EheimDigitalUpdateCoordinator,
|
||||
device: _DeviceT,
|
||||
description: EheimDigitalBinarySensorDescription[_DeviceT],
|
||||
) -> None:
|
||||
"""Initialize an EHEIM Digital binary sensor entity."""
|
||||
super().__init__(coordinator, device)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{self._device_address}_{description.key}"
|
||||
|
||||
def _async_update_attrs(self) -> None:
|
||||
self._attr_is_on = self.entity_description.value_fn(self._device)
|
||||
@@ -1,5 +1,19 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"is_lighting": {
|
||||
"default": "mdi:lightbulb-outline",
|
||||
"state": {
|
||||
"on": "mdi:lightbulb-on"
|
||||
}
|
||||
},
|
||||
"is_uvc_connected": {
|
||||
"default": "mdi:lightbulb-off",
|
||||
"state": {
|
||||
"on": "mdi:lightbulb-outline"
|
||||
}
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"day_speed": {
|
||||
"default": "mdi:weather-sunny"
|
||||
|
||||
@@ -33,6 +33,17 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"is_lighting": {
|
||||
"state": {
|
||||
"off": "[%key:common::state::off%]",
|
||||
"on": "[%key:common::state::on%]"
|
||||
}
|
||||
},
|
||||
"is_uvc_connected": {
|
||||
"name": "UVC lamp connected"
|
||||
}
|
||||
},
|
||||
"climate": {
|
||||
"heater": {
|
||||
"state_attributes": {
|
||||
|
||||
@@ -111,7 +111,7 @@ def get_model_selection_schema(
|
||||
),
|
||||
vol.Required(
|
||||
CONF_BACKEND,
|
||||
default=options.get(CONF_BACKEND, "s1"),
|
||||
default=options.get(CONF_BACKEND, "s2-pro"),
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
|
||||
@@ -31,7 +31,7 @@ TTS_SUPPORTED_LANGUAGES = [
|
||||
]
|
||||
|
||||
|
||||
BACKEND_MODELS = ["s1", "speech-1.5", "speech-1.6"]
|
||||
BACKEND_MODELS = ["s2-pro", "s1", "speech-1.5", "speech-1.6"]
|
||||
SORT_BY_OPTIONS = ["task_count", "score", "created_at"]
|
||||
LATENCY_OPTIONS = ["normal", "balanced"]
|
||||
|
||||
|
||||
@@ -2,12 +2,18 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from bleak.backends.device import BLEDevice
|
||||
from gardena_bluetooth.client import CachedConnection, Client
|
||||
from gardena_bluetooth.const import DeviceConfiguration, DeviceInformation
|
||||
from gardena_bluetooth.exceptions import CommunicationFailure
|
||||
from gardena_bluetooth.exceptions import (
|
||||
CharacteristicNoAccess,
|
||||
CharacteristicNotFound,
|
||||
CommunicationFailure,
|
||||
)
|
||||
from gardena_bluetooth.parse import CharacteristicTime
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.const import CONF_ADDRESS, Platform
|
||||
@@ -23,6 +29,7 @@ from .coordinator import (
|
||||
GardenaBluetoothConfigEntry,
|
||||
GardenaBluetoothCoordinator,
|
||||
)
|
||||
from .util import async_get_product_type
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BINARY_SENSOR,
|
||||
@@ -51,22 +58,41 @@ def get_connection(hass: HomeAssistant, address: str) -> CachedConnection:
|
||||
return CachedConnection(DISCONNECT_DELAY, _device_lookup)
|
||||
|
||||
|
||||
async def _update_timestamp(client: Client, characteristics: CharacteristicTime):
|
||||
try:
|
||||
await client.update_timestamp(characteristics, dt_util.now())
|
||||
except CharacteristicNotFound:
|
||||
pass
|
||||
except CharacteristicNoAccess:
|
||||
LOGGER.debug("No access to update internal time")
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: GardenaBluetoothConfigEntry
|
||||
) -> bool:
|
||||
"""Set up Gardena Bluetooth from a config entry."""
|
||||
|
||||
address = entry.data[CONF_ADDRESS]
|
||||
client = Client(get_connection(hass, address))
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(TIMEOUT):
|
||||
product_type = await async_get_product_type(hass, address)
|
||||
except TimeoutError as exception:
|
||||
raise ConfigEntryNotReady("Unable to find product type") from exception
|
||||
|
||||
client = Client(get_connection(hass, address), product_type)
|
||||
try:
|
||||
chars = await client.get_all_characteristics()
|
||||
|
||||
sw_version = await client.read_char(DeviceInformation.firmware_version, None)
|
||||
manufacturer = await client.read_char(DeviceInformation.manufacturer_name, None)
|
||||
model = await client.read_char(DeviceInformation.model_number, None)
|
||||
name = await client.read_char(
|
||||
DeviceConfiguration.custom_device_name, entry.title
|
||||
)
|
||||
uuids = await client.get_all_characteristics_uuid()
|
||||
await client.update_timestamp(dt_util.now())
|
||||
|
||||
name = entry.title
|
||||
name = await client.read_char(DeviceConfiguration.custom_device_name, name)
|
||||
|
||||
await _update_timestamp(client, DeviceConfiguration.unix_timestamp)
|
||||
|
||||
except (TimeoutError, CommunicationFailure, DeviceUnavailable) as exception:
|
||||
await client.disconnect()
|
||||
raise ConfigEntryNotReady(
|
||||
@@ -83,7 +109,7 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
coordinator = GardenaBluetoothCoordinator(
|
||||
hass, entry, LOGGER, client, uuids, device, address
|
||||
hass, entry, LOGGER, client, set(chars.keys()), device, address
|
||||
)
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
@@ -34,14 +34,14 @@ class GardenaBluetoothBinarySensorEntityDescription(BinarySensorEntityDescriptio
|
||||
|
||||
DESCRIPTIONS = (
|
||||
GardenaBluetoothBinarySensorEntityDescription(
|
||||
key=Valve.connected_state.uuid,
|
||||
key=Valve.connected_state.unique_id,
|
||||
translation_key="valve_connected_state",
|
||||
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
char=Valve.connected_state,
|
||||
),
|
||||
GardenaBluetoothBinarySensorEntityDescription(
|
||||
key=Sensor.connected_state.uuid,
|
||||
key=Sensor.connected_state.unique_id,
|
||||
translation_key="sensor_connected_state",
|
||||
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -60,7 +60,7 @@ async def async_setup_entry(
|
||||
entities = [
|
||||
GardenaBluetoothBinarySensor(coordinator, description, description.context)
|
||||
for description in DESCRIPTIONS
|
||||
if description.key in coordinator.characteristics
|
||||
if description.char.unique_id in coordinator.characteristics
|
||||
]
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ class GardenaBluetoothButtonEntityDescription(ButtonEntityDescription):
|
||||
|
||||
DESCRIPTIONS = (
|
||||
GardenaBluetoothButtonEntityDescription(
|
||||
key=Reset.factory_reset.uuid,
|
||||
key=Reset.factory_reset.unique_id,
|
||||
translation_key="factory_reset",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
@@ -49,7 +49,7 @@ async def async_setup_entry(
|
||||
entities = [
|
||||
GardenaBluetoothButton(coordinator, description, description.context)
|
||||
for description in DESCRIPTIONS
|
||||
if description.key in coordinator.characteristics
|
||||
if description.char.unique_id in coordinator.characteristics
|
||||
]
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["bleak", "bleak_esphome", "gardena_bluetooth"],
|
||||
"requirements": ["gardena-bluetooth==1.6.0"]
|
||||
"requirements": ["gardena-bluetooth==2.1.0"]
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ class GardenaBluetoothNumberEntityDescription(NumberEntityDescription):
|
||||
|
||||
DESCRIPTIONS = (
|
||||
GardenaBluetoothNumberEntityDescription(
|
||||
key=Valve.manual_watering_time.uuid,
|
||||
key=Valve.manual_watering_time.unique_id,
|
||||
translation_key="manual_watering_time",
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
mode=NumberMode.BOX,
|
||||
@@ -58,7 +58,7 @@ DESCRIPTIONS = (
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
),
|
||||
GardenaBluetoothNumberEntityDescription(
|
||||
key=Valve.remaining_open_time.uuid,
|
||||
key=Valve.remaining_open_time.unique_id,
|
||||
translation_key="remaining_open_time",
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
native_min_value=0.0,
|
||||
@@ -69,7 +69,7 @@ DESCRIPTIONS = (
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
),
|
||||
GardenaBluetoothNumberEntityDescription(
|
||||
key=DeviceConfiguration.rain_pause.uuid,
|
||||
key=DeviceConfiguration.rain_pause.unique_id,
|
||||
translation_key="rain_pause",
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
mode=NumberMode.BOX,
|
||||
@@ -81,7 +81,7 @@ DESCRIPTIONS = (
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
),
|
||||
GardenaBluetoothNumberEntityDescription(
|
||||
key=DeviceConfiguration.seasonal_adjust.uuid,
|
||||
key=DeviceConfiguration.seasonal_adjust.unique_id,
|
||||
translation_key="seasonal_adjust",
|
||||
native_unit_of_measurement=UnitOfTime.DAYS,
|
||||
mode=NumberMode.BOX,
|
||||
@@ -93,7 +93,7 @@ DESCRIPTIONS = (
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
),
|
||||
GardenaBluetoothNumberEntityDescription(
|
||||
key=Sensor.threshold.uuid,
|
||||
key=Sensor.threshold.unique_id,
|
||||
translation_key="sensor_threshold",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
mode=NumberMode.BOX,
|
||||
@@ -117,9 +117,9 @@ async def async_setup_entry(
|
||||
entities: list[NumberEntity] = [
|
||||
GardenaBluetoothNumber(coordinator, description, description.context)
|
||||
for description in DESCRIPTIONS
|
||||
if description.key in coordinator.characteristics
|
||||
if description.char.unique_id in coordinator.characteristics
|
||||
]
|
||||
if Valve.remaining_open_time.uuid in coordinator.characteristics:
|
||||
if Valve.remaining_open_time.unique_id in coordinator.characteristics:
|
||||
entities.append(GardenaBluetoothRemainingOpenSetNumber(coordinator))
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ class GardenaBluetoothSensorEntityDescription(SensorEntityDescription):
|
||||
|
||||
DESCRIPTIONS = (
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Valve.activation_reason.uuid,
|
||||
key=Valve.activation_reason.unique_id,
|
||||
translation_key="activation_reason",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -49,7 +49,7 @@ DESCRIPTIONS = (
|
||||
char=Valve.activation_reason,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Battery.battery_level.uuid,
|
||||
key=Battery.battery_level.unique_id,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -57,7 +57,7 @@ DESCRIPTIONS = (
|
||||
char=Battery.battery_level,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Sensor.battery_level.uuid,
|
||||
key=Sensor.battery_level.unique_id,
|
||||
translation_key="sensor_battery_level",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
@@ -67,7 +67,7 @@ DESCRIPTIONS = (
|
||||
connected_state=Sensor.connected_state,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Sensor.value.uuid,
|
||||
key=Sensor.value.unique_id,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.MOISTURE,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
@@ -75,14 +75,14 @@ DESCRIPTIONS = (
|
||||
connected_state=Sensor.connected_state,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Sensor.type.uuid,
|
||||
key=Sensor.type.unique_id,
|
||||
translation_key="sensor_type",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
char=Sensor.type,
|
||||
connected_state=Sensor.connected_state,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Sensor.measurement_timestamp.uuid,
|
||||
key=Sensor.measurement_timestamp.unique_id,
|
||||
translation_key="sensor_measurement_timestamp",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@@ -102,9 +102,9 @@ async def async_setup_entry(
|
||||
entities: list[GardenaBluetoothEntity] = [
|
||||
GardenaBluetoothSensor(coordinator, description, description.context)
|
||||
for description in DESCRIPTIONS
|
||||
if description.key in coordinator.characteristics
|
||||
if description.char.unique_id in coordinator.characteristics
|
||||
]
|
||||
if Valve.remaining_open_time.uuid in coordinator.characteristics:
|
||||
if Valve.remaining_open_time.unique_id in coordinator.characteristics:
|
||||
entities.append(GardenaBluetoothRemainSensor(coordinator))
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -35,9 +35,9 @@ class GardenaBluetoothValveSwitch(GardenaBluetoothEntity, SwitchEntity):
|
||||
"""Representation of a valve switch."""
|
||||
|
||||
characteristics = {
|
||||
Valve.state.uuid,
|
||||
Valve.manual_watering_time.uuid,
|
||||
Valve.remaining_open_time.uuid,
|
||||
Valve.state.unique_id,
|
||||
Valve.manual_watering_time.unique_id,
|
||||
Valve.remaining_open_time.unique_id,
|
||||
}
|
||||
|
||||
def __init__(
|
||||
@@ -48,7 +48,7 @@ class GardenaBluetoothValveSwitch(GardenaBluetoothEntity, SwitchEntity):
|
||||
super().__init__(
|
||||
coordinator, {Valve.state.uuid, Valve.manual_watering_time.uuid}
|
||||
)
|
||||
self._attr_unique_id = f"{coordinator.address}-{Valve.state.uuid}"
|
||||
self._attr_unique_id = f"{coordinator.address}-{Valve.state.unique_id}"
|
||||
self._attr_translation_key = "state"
|
||||
self._attr_is_on = None
|
||||
self._attr_entity_registry_enabled_default = False
|
||||
|
||||
51
homeassistant/components/gardena_bluetooth/util.py
Normal file
51
homeassistant/components/gardena_bluetooth/util.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Utility functions for Gardena Bluetooth integration."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from gardena_bluetooth.parse import ManufacturerData, ProductType
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
|
||||
|
||||
async def _async_service_info(
|
||||
hass, address
|
||||
) -> AsyncIterator[bluetooth.BluetoothServiceInfoBleak]:
|
||||
queue = asyncio.Queue[bluetooth.BluetoothServiceInfoBleak]()
|
||||
|
||||
def _callback(
|
||||
service_info: bluetooth.BluetoothServiceInfoBleak,
|
||||
change: bluetooth.BluetoothChange,
|
||||
) -> None:
|
||||
if change != bluetooth.BluetoothChange.ADVERTISEMENT:
|
||||
return
|
||||
|
||||
queue.put_nowait(service_info)
|
||||
|
||||
service_info = bluetooth.async_last_service_info(hass, address, True)
|
||||
if service_info:
|
||||
yield service_info
|
||||
|
||||
cancel = bluetooth.async_register_callback(
|
||||
hass,
|
||||
_callback,
|
||||
{bluetooth.match.ADDRESS: address},
|
||||
bluetooth.BluetoothScanningMode.ACTIVE,
|
||||
)
|
||||
try:
|
||||
while True:
|
||||
yield await queue.get()
|
||||
finally:
|
||||
cancel()
|
||||
|
||||
|
||||
async def async_get_product_type(hass, address: str) -> ProductType:
|
||||
"""Wait for enough packets of manufacturer data to get the product type."""
|
||||
data = ManufacturerData()
|
||||
|
||||
async for service_info in _async_service_info(hass, address):
|
||||
data.update(service_info.manufacturer_data.get(ManufacturerData.company, b""))
|
||||
product_type = ProductType.from_manufacturer_data(data)
|
||||
if product_type is not ProductType.UNKNOWN:
|
||||
return product_type
|
||||
raise AssertionError("Iterator should have been infinite")
|
||||
@@ -44,9 +44,9 @@ class GardenaBluetoothValve(GardenaBluetoothEntity, ValveEntity):
|
||||
_attr_device_class = ValveDeviceClass.WATER
|
||||
|
||||
characteristics = {
|
||||
Valve.state.uuid,
|
||||
Valve.manual_watering_time.uuid,
|
||||
Valve.remaining_open_time.uuid,
|
||||
Valve.state.unique_id,
|
||||
Valve.manual_watering_time.unique_id,
|
||||
Valve.remaining_open_time.unique_id,
|
||||
}
|
||||
|
||||
def __init__(
|
||||
@@ -57,7 +57,7 @@ class GardenaBluetoothValve(GardenaBluetoothEntity, ValveEntity):
|
||||
super().__init__(
|
||||
coordinator, {Valve.state.uuid, Valve.manual_watering_time.uuid}
|
||||
)
|
||||
self._attr_unique_id = f"{coordinator.address}-{Valve.state.uuid}"
|
||||
self._attr_unique_id = f"{coordinator.address}-{Valve.state.unique_id}"
|
||||
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
self._attr_is_closed = not self.coordinator.get_cached(Valve.state)
|
||||
|
||||
@@ -7,7 +7,12 @@ import aiohttp
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryNotReady,
|
||||
OAuth2TokenRequestError,
|
||||
OAuth2TokenRequestReauthError,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
OAuth2Session,
|
||||
@@ -39,11 +44,11 @@ async def async_setup_entry(
|
||||
session = OAuth2Session(hass, entry, implementation)
|
||||
try:
|
||||
await session.async_ensure_token_valid()
|
||||
except aiohttp.ClientResponseError as err:
|
||||
if 400 <= err.status < 500:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"OAuth session is not valid, reauth required"
|
||||
) from err
|
||||
except OAuth2TokenRequestReauthError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"OAuth session is not valid, reauth required"
|
||||
) from err
|
||||
except OAuth2TokenRequestError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
except aiohttp.ClientError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
@@ -1,4 +1,28 @@
|
||||
"""The Growatt server PV inverter sensor integration."""
|
||||
"""The Growatt server PV inverter sensor integration.
|
||||
|
||||
This integration supports two distinct Growatt APIs with different auth models:
|
||||
|
||||
Classic API (username/password):
|
||||
- Authenticates via api.login(), which returns a dict with a "success" key.
|
||||
- Auth failure is signalled by success=False and msg="502" (LOGIN_INVALID_AUTH_CODE).
|
||||
- A failed login does NOT raise an exception — the return value must be checked.
|
||||
- The coordinator calls api.login() on every update cycle to maintain the session.
|
||||
|
||||
Open API V1 (API token):
|
||||
- Stateless — no login call, token is sent as a Bearer header on every request.
|
||||
- Auth failure is signalled by raising GrowattV1ApiError with error_code=10011
|
||||
(V1_API_ERROR_NO_PRIVILEGE). The library NEVER returns a failure silently;
|
||||
any non-zero error_code raises an exception via _process_response().
|
||||
- Because the library always raises on error, return-value validation after a
|
||||
successful V1 API call is unnecessary — if it returned, the token was valid.
|
||||
|
||||
Error handling pattern for reauth:
|
||||
- Classic API: check NOT login_response["success"] and msg == LOGIN_INVALID_AUTH_CODE
|
||||
→ raise ConfigEntryAuthFailed
|
||||
- V1 API: catch GrowattV1ApiError with error_code == V1_API_ERROR_NO_PRIVILEGE
|
||||
→ raise ConfigEntryAuthFailed
|
||||
- All other errors → ConfigEntryError (setup) or UpdateFailed (coordinator)
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from json import JSONDecodeError
|
||||
@@ -25,6 +49,7 @@ from .const import (
|
||||
DOMAIN,
|
||||
LOGIN_INVALID_AUTH_CODE,
|
||||
PLATFORMS,
|
||||
V1_API_ERROR_NO_PRIVILEGE,
|
||||
)
|
||||
from .coordinator import GrowattConfigEntry, GrowattCoordinator
|
||||
from .models import GrowattRuntimeData
|
||||
@@ -227,8 +252,12 @@ def get_device_list_v1(
|
||||
try:
|
||||
devices_dict = api.device_list(plant_id)
|
||||
except growattServer.GrowattV1ApiError as e:
|
||||
if e.error_code == V1_API_ERROR_NO_PRIVILEGE:
|
||||
raise ConfigEntryAuthFailed(
|
||||
f"Authentication failed for Growatt API: {e.error_msg or str(e)}"
|
||||
) from e
|
||||
raise ConfigEntryError(
|
||||
f"API error during device list: {e} (Code: {getattr(e, 'error_code', None)}, Message: {getattr(e, 'error_msg', None)})"
|
||||
f"API error during device list: {e.error_msg or str(e)} (Code: {e.error_code})"
|
||||
) from e
|
||||
devices = devices_dict.get("devices", [])
|
||||
# Only MIN device (type = 7) support implemented in current V1 API
|
||||
@@ -272,6 +301,7 @@ async def async_setup_entry(
|
||||
# V1 API (token-based, no login needed)
|
||||
token = config[CONF_TOKEN]
|
||||
api = growattServer.OpenApiV1(token=token)
|
||||
api.server_url = url
|
||||
devices, plant_id = await hass.async_add_executor_job(
|
||||
get_device_list_v1, api, config
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Config flow for growatt server integration."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -31,8 +32,11 @@ from .const import (
|
||||
ERROR_INVALID_AUTH,
|
||||
LOGIN_INVALID_AUTH_CODE,
|
||||
SERVER_URLS_NAMES,
|
||||
V1_API_ERROR_NO_PRIVILEGE,
|
||||
)
|
||||
|
||||
_URL_TO_REGION = {v: k for k, v in SERVER_URLS_NAMES.items()}
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -60,6 +64,137 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
menu_options=["password_auth", "token_auth"],
|
||||
)
|
||||
|
||||
async def async_step_reauth(self, _: Mapping[str, Any]) -> ConfigFlowResult:
|
||||
"""Handle reauth."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauth confirmation."""
|
||||
errors: dict[str, str] = {}
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
|
||||
if user_input is not None:
|
||||
auth_type = reauth_entry.data.get(CONF_AUTH_TYPE)
|
||||
|
||||
if auth_type == AUTH_PASSWORD:
|
||||
server_url = SERVER_URLS_NAMES[user_input[CONF_REGION]]
|
||||
api = growattServer.GrowattApi(
|
||||
add_random_user_id=True,
|
||||
agent_identifier=user_input[CONF_USERNAME],
|
||||
)
|
||||
api.server_url = server_url
|
||||
|
||||
try:
|
||||
login_response = await self.hass.async_add_executor_job(
|
||||
api.login, user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
|
||||
)
|
||||
except requests.exceptions.RequestException as ex:
|
||||
_LOGGER.debug("Network error during reauth login: %s", ex)
|
||||
errors["base"] = ERROR_CANNOT_CONNECT
|
||||
except (ValueError, KeyError, TypeError, AttributeError) as ex:
|
||||
_LOGGER.debug("Invalid response format during reauth login: %s", ex)
|
||||
errors["base"] = ERROR_CANNOT_CONNECT
|
||||
else:
|
||||
if not isinstance(login_response, dict):
|
||||
errors["base"] = ERROR_CANNOT_CONNECT
|
||||
elif login_response.get("success"):
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry,
|
||||
data_updates={
|
||||
CONF_USERNAME: user_input[CONF_USERNAME],
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
CONF_URL: server_url,
|
||||
},
|
||||
)
|
||||
elif login_response.get("msg") == LOGIN_INVALID_AUTH_CODE:
|
||||
errors["base"] = ERROR_INVALID_AUTH
|
||||
else:
|
||||
errors["base"] = ERROR_CANNOT_CONNECT
|
||||
|
||||
elif auth_type == AUTH_API_TOKEN:
|
||||
server_url = SERVER_URLS_NAMES[user_input[CONF_REGION]]
|
||||
api = growattServer.OpenApiV1(token=user_input[CONF_TOKEN])
|
||||
api.server_url = server_url
|
||||
|
||||
try:
|
||||
await self.hass.async_add_executor_job(api.plant_list)
|
||||
except requests.exceptions.RequestException as ex:
|
||||
_LOGGER.debug(
|
||||
"Network error during reauth token validation: %s", ex
|
||||
)
|
||||
errors["base"] = ERROR_CANNOT_CONNECT
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
|
||||
errors["base"] = ERROR_INVALID_AUTH
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Growatt V1 API error during reauth: %s (Code: %s)",
|
||||
err.error_msg or str(err),
|
||||
err.error_code,
|
||||
)
|
||||
errors["base"] = ERROR_CANNOT_CONNECT
|
||||
except (ValueError, KeyError, TypeError, AttributeError) as ex:
|
||||
_LOGGER.debug(
|
||||
"Invalid response format during reauth token validation: %s", ex
|
||||
)
|
||||
errors["base"] = ERROR_CANNOT_CONNECT
|
||||
else:
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry,
|
||||
data_updates={
|
||||
CONF_TOKEN: user_input[CONF_TOKEN],
|
||||
CONF_URL: server_url,
|
||||
},
|
||||
)
|
||||
|
||||
# Determine the current region key from the stored config value.
|
||||
# Legacy entries may store the region key directly; newer entries store the URL.
|
||||
stored_url = reauth_entry.data.get(CONF_URL, "")
|
||||
if stored_url in SERVER_URLS_NAMES:
|
||||
current_region = stored_url
|
||||
else:
|
||||
current_region = _URL_TO_REGION.get(stored_url, DEFAULT_URL)
|
||||
|
||||
auth_type = reauth_entry.data.get(CONF_AUTH_TYPE)
|
||||
if auth_type == AUTH_PASSWORD:
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_USERNAME,
|
||||
default=reauth_entry.data.get(CONF_USERNAME),
|
||||
): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
vol.Required(CONF_REGION, default=current_region): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=list(SERVER_URLS_NAMES.keys()),
|
||||
translation_key="region",
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
elif auth_type == AUTH_API_TOKEN:
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_TOKEN): str,
|
||||
vol.Required(CONF_REGION, default=current_region): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=list(SERVER_URLS_NAMES.keys()),
|
||||
translation_key="region",
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
else:
|
||||
return self.async_abort(reason=ERROR_CANNOT_CONNECT)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=data_schema,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_password_auth(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -129,9 +264,11 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.error(
|
||||
"Growatt V1 API error: %s (Code: %s)",
|
||||
e.error_msg or str(e),
|
||||
getattr(e, "error_code", None),
|
||||
e.error_code,
|
||||
)
|
||||
return self._async_show_token_form({"base": ERROR_INVALID_AUTH})
|
||||
if e.error_code == V1_API_ERROR_NO_PRIVILEGE:
|
||||
return self._async_show_token_form({"base": ERROR_INVALID_AUTH})
|
||||
return self._async_show_token_form({"base": ERROR_CANNOT_CONNECT})
|
||||
except (ValueError, KeyError, TypeError, AttributeError) as ex:
|
||||
_LOGGER.error(
|
||||
"Invalid response format during Growatt V1 API plant list: %s", ex
|
||||
|
||||
@@ -40,8 +40,17 @@ DOMAIN = "growatt_server"
|
||||
|
||||
PLATFORMS = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH]
|
||||
|
||||
# Growatt Classic API error codes
|
||||
LOGIN_INVALID_AUTH_CODE = "502"
|
||||
|
||||
# Growatt Open API V1 error codes
|
||||
# Reference: https://www.showdoc.com.cn/262556420217021/1494055648380019
|
||||
V1_API_ERROR_WRONG_DOMAIN = -1 # Use correct regional domain
|
||||
V1_API_ERROR_NO_PRIVILEGE = 10011 # No privilege access — invalid or expired token
|
||||
V1_API_ERROR_RATE_LIMITED = 10012 # Access frequency limit (5 minutes per call)
|
||||
V1_API_ERROR_PAGE_SIZE = 10013 # Page size cannot exceed 100
|
||||
V1_API_ERROR_PAGE_COUNT = 10014 # Page count cannot exceed 250
|
||||
|
||||
# Config flow error types (also used as abort reasons)
|
||||
ERROR_CANNOT_CONNECT = "cannot_connect" # Used for both form errors and aborts
|
||||
ERROR_INVALID_AUTH = "invalid_auth"
|
||||
|
||||
@@ -13,7 +13,11 @@ from homeassistant.components.sensor import SensorStateClass
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
HomeAssistantError,
|
||||
ServiceValidationError,
|
||||
)
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
@@ -23,6 +27,8 @@ from .const import (
|
||||
BATT_MODE_LOAD_FIRST,
|
||||
DEFAULT_URL,
|
||||
DOMAIN,
|
||||
LOGIN_INVALID_AUTH_CODE,
|
||||
V1_API_ERROR_NO_PRIVILEGE,
|
||||
)
|
||||
from .models import GrowattRuntimeData
|
||||
|
||||
@@ -63,6 +69,7 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self.url = config_entry.data.get(CONF_URL, DEFAULT_URL)
|
||||
self.token = config_entry.data["token"]
|
||||
self.api = growattServer.OpenApiV1(token=self.token)
|
||||
self.api.server_url = self.url
|
||||
elif self.api_version == "classic":
|
||||
self.username = config_entry.data.get(CONF_USERNAME)
|
||||
self.password = config_entry.data[CONF_PASSWORD]
|
||||
@@ -88,7 +95,14 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
# login only required for classic API
|
||||
if self.api_version == "classic":
|
||||
self.api.login(self.username, self.password)
|
||||
login_response = self.api.login(self.username, self.password)
|
||||
if not login_response.get("success"):
|
||||
msg = login_response.get("msg", "Unknown error")
|
||||
if msg == LOGIN_INVALID_AUTH_CODE:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Username, password, or URL may be incorrect"
|
||||
)
|
||||
raise UpdateFailed(f"Growatt login failed: {msg}")
|
||||
|
||||
if self.device_type == "total":
|
||||
if self.api_version == "v1":
|
||||
@@ -100,7 +114,16 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# todayEnergy -> today_energy
|
||||
# totalEnergy -> total_energy
|
||||
# invTodayPpv -> current_power
|
||||
total_info = self.api.plant_energy_overview(self.plant_id)
|
||||
try:
|
||||
total_info = self.api.plant_energy_overview(self.plant_id)
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
|
||||
raise ConfigEntryAuthFailed(
|
||||
f"Authentication failed for Growatt API: {err.error_msg or str(err)}"
|
||||
) from err
|
||||
raise UpdateFailed(
|
||||
f"Error fetching plant energy overview: {err}"
|
||||
) from err
|
||||
total_info["todayEnergy"] = total_info["today_energy"]
|
||||
total_info["totalEnergy"] = total_info["total_energy"]
|
||||
total_info["invTodayPpv"] = total_info["current_power"]
|
||||
@@ -122,6 +145,10 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
min_settings = self.api.min_settings(self.device_id)
|
||||
min_energy = self.api.min_energy(self.device_id)
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
|
||||
raise ConfigEntryAuthFailed(
|
||||
f"Authentication failed for Growatt API: {err.error_msg or str(err)}"
|
||||
) from err
|
||||
raise UpdateFailed(f"Error fetching min device data: {err}") from err
|
||||
|
||||
min_info = {**min_details, **min_settings, **min_energy}
|
||||
|
||||
@@ -30,7 +30,7 @@ rules:
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"no_plants": "No plants have been found on this account"
|
||||
"no_plants": "No plants have been found on this account",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Cannot connect to Growatt servers. Please check your internet connection and try again.",
|
||||
@@ -13,7 +14,7 @@
|
||||
"password_auth": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"url": "Server region",
|
||||
"region": "Server region",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"title": "Enter your Growatt login credentials"
|
||||
@@ -24,10 +25,20 @@
|
||||
},
|
||||
"title": "Select your plant"
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"region": "[%key:component::growatt_server::config::step::password_auth::data::region%]",
|
||||
"token": "[%key:component::growatt_server::config::step::token_auth::data::token%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"description": "Re-enter your credentials to continue using this integration.",
|
||||
"title": "Re-authenticate with Growatt"
|
||||
},
|
||||
"token_auth": {
|
||||
"data": {
|
||||
"token": "API Token",
|
||||
"url": "Server region"
|
||||
"region": "[%key:component::growatt_server::config::step::password_auth::data::region%]",
|
||||
"token": "API Token"
|
||||
},
|
||||
"description": "Token authentication is only supported for MIN/TLX devices. For other device types, please use username/password authentication.",
|
||||
"title": "Enter your API token"
|
||||
|
||||
@@ -38,6 +38,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.FAN,
|
||||
Platform.LIGHT,
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
|
||||
@@ -19,7 +19,7 @@ from .coordinator import (
|
||||
HomeConnectApplianceData,
|
||||
HomeConnectConfigEntry,
|
||||
)
|
||||
from .entity import HomeConnectEntity, HomeConnectOptionEntity
|
||||
from .entity import HomeConnectEntity
|
||||
|
||||
|
||||
def should_add_option_entity(
|
||||
@@ -48,7 +48,7 @@ def _create_option_entities(
|
||||
known_entity_unique_ids: dict[str, str],
|
||||
get_option_entities_for_appliance: Callable[
|
||||
[HomeConnectApplianceCoordinator, er.EntityRegistry],
|
||||
list[HomeConnectOptionEntity],
|
||||
list[HomeConnectEntity],
|
||||
],
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
@@ -78,7 +78,7 @@ def _handle_paired_or_connected_appliance(
|
||||
],
|
||||
get_option_entities_for_appliance: Callable[
|
||||
[HomeConnectApplianceCoordinator, er.EntityRegistry],
|
||||
list[HomeConnectOptionEntity],
|
||||
list[HomeConnectEntity],
|
||||
]
|
||||
| None,
|
||||
changed_options_listener_remove_callbacks: dict[str, list[Callable[[], None]]],
|
||||
@@ -161,7 +161,7 @@ def setup_home_connect_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
get_option_entities_for_appliance: Callable[
|
||||
[HomeConnectApplianceCoordinator, er.EntityRegistry],
|
||||
list[HomeConnectOptionEntity],
|
||||
list[HomeConnectEntity],
|
||||
]
|
||||
| None = None,
|
||||
) -> None:
|
||||
|
||||
235
homeassistant/components/home_connect/fan.py
Normal file
235
homeassistant/components/home_connect/fan.py
Normal file
@@ -0,0 +1,235 @@
|
||||
"""Provides fan entities for Home Connect."""
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from aiohomeconnect.model import EventKey, OptionKey
|
||||
from aiohomeconnect.model.error import ActiveProgramNotSetError, HomeConnectError
|
||||
|
||||
from homeassistant.components.fan import (
|
||||
FanEntity,
|
||||
FanEntityDescription,
|
||||
FanEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .common import setup_home_connect_entry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
FAN_SPEED_MODE_OPTIONS = {
|
||||
"auto": "HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Automatic",
|
||||
"manual": "HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Manual",
|
||||
}
|
||||
FAN_SPEED_MODE_OPTIONS_INVERTED = {v: k for k, v in FAN_SPEED_MODE_OPTIONS.items()}
|
||||
|
||||
|
||||
AIR_CONDITIONER_ENTITY_DESCRIPTION = FanEntityDescription(
|
||||
key="air_conditioner",
|
||||
translation_key="air_conditioner",
|
||||
name=None,
|
||||
)
|
||||
|
||||
|
||||
def _get_entities_for_appliance(
|
||||
appliance_coordinator: HomeConnectApplianceCoordinator,
|
||||
) -> list[HomeConnectEntity]:
|
||||
"""Get a list of entities."""
|
||||
return (
|
||||
[HomeConnectAirConditioningFanEntity(appliance_coordinator)]
|
||||
if appliance_coordinator.data.options
|
||||
and any(
|
||||
option in appliance_coordinator.data.options
|
||||
for option in (
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE,
|
||||
)
|
||||
)
|
||||
else []
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HomeConnectConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Home Connect fan entities."""
|
||||
setup_home_connect_entry(
|
||||
hass,
|
||||
entry,
|
||||
_get_entities_for_appliance,
|
||||
async_add_entities,
|
||||
lambda appliance_coordinator, _: _get_entities_for_appliance(
|
||||
appliance_coordinator
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class HomeConnectAirConditioningFanEntity(HomeConnectEntity, FanEntity):
|
||||
"""Representation of a Home Connect fan entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: HomeConnectApplianceCoordinator,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
self._attr_preset_modes = list(FAN_SPEED_MODE_OPTIONS.keys())
|
||||
self._original_speed_modes_keys = set(FAN_SPEED_MODE_OPTIONS_INVERTED)
|
||||
super().__init__(
|
||||
coordinator,
|
||||
AIR_CONDITIONER_ENTITY_DESCRIPTION,
|
||||
context_override=(
|
||||
EventKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE
|
||||
),
|
||||
)
|
||||
self.update_preset_mode()
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update_preset_mode(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self.update_preset_mode()
|
||||
self.async_write_ha_state()
|
||||
_LOGGER.debug(
|
||||
"Updated %s (fan mode), new state: %s", self.entity_id, self.preset_mode
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
self.async_on_remove(
|
||||
self.coordinator.async_add_listener(
|
||||
self._handle_coordinator_update_preset_mode,
|
||||
EventKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
)
|
||||
)
|
||||
|
||||
def update_native_value(self) -> None:
|
||||
"""Set the speed percentage and speed mode values."""
|
||||
option_value = None
|
||||
option_key = OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE
|
||||
if event := self.appliance.events.get(EventKey(option_key)):
|
||||
option_value = event.value
|
||||
self._attr_percentage = (
|
||||
cast(int, option_value) if option_value is not None else None
|
||||
)
|
||||
|
||||
@property
|
||||
def supported_features(self) -> FanEntityFeature:
|
||||
"""Return the supported features for this fan entity."""
|
||||
features = FanEntityFeature(0)
|
||||
if (
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE
|
||||
in self.appliance.options
|
||||
):
|
||||
features |= FanEntityFeature.SET_SPEED
|
||||
if (
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
|
||||
in self.appliance.options
|
||||
):
|
||||
features |= FanEntityFeature.PRESET_MODE
|
||||
return features
|
||||
|
||||
def update_preset_mode(self) -> None:
|
||||
"""Set the preset mode value."""
|
||||
option_value = None
|
||||
option_key = OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
|
||||
if event := self.appliance.events.get(EventKey(option_key)):
|
||||
option_value = event.value
|
||||
self._attr_preset_mode = (
|
||||
FAN_SPEED_MODE_OPTIONS_INVERTED.get(cast(str, option_value))
|
||||
if option_value is not None
|
||||
else None
|
||||
)
|
||||
if (
|
||||
(
|
||||
option_definition := self.appliance.options.get(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
|
||||
)
|
||||
)
|
||||
and (option_constraints := option_definition.constraints)
|
||||
and option_constraints.allowed_values
|
||||
and (
|
||||
allowed_values_without_none := {
|
||||
value
|
||||
for value in option_constraints.allowed_values
|
||||
if value is not None
|
||||
}
|
||||
)
|
||||
and self._original_speed_modes_keys != allowed_values_without_none
|
||||
):
|
||||
self._original_speed_modes_keys = allowed_values_without_none
|
||||
self._attr_preset_modes = [
|
||||
key
|
||||
for key, value in FAN_SPEED_MODE_OPTIONS.items()
|
||||
if value in self._original_speed_modes_keys
|
||||
]
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
"""Set the speed of the fan, as a percentage."""
|
||||
await self._async_set_option(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE,
|
||||
percentage,
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Updated %s's speed percentage option, new state: %s",
|
||||
self.entity_id,
|
||||
percentage,
|
||||
)
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set new target fan mode."""
|
||||
await self._async_set_option(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
FAN_SPEED_MODE_OPTIONS[preset_mode],
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Updated %s's speed mode option, new state: %s",
|
||||
self.entity_id,
|
||||
self.state,
|
||||
)
|
||||
|
||||
async def _async_set_option(self, key: OptionKey, value: str | int) -> None:
|
||||
"""Set an option for the entity."""
|
||||
try:
|
||||
# We try to set the active program option first,
|
||||
# if it fails we try to set the selected program option
|
||||
with contextlib.suppress(ActiveProgramNotSetError):
|
||||
await self.coordinator.client.set_active_program_option(
|
||||
self.appliance.info.ha_id,
|
||||
option_key=key,
|
||||
value=value,
|
||||
)
|
||||
return
|
||||
|
||||
await self.coordinator.client.set_selected_program_option(
|
||||
self.appliance.info.ha_id,
|
||||
option_key=key,
|
||||
value=value,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_option",
|
||||
translation_placeholders=get_dict_from_home_connect_error(err),
|
||||
) from err
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return super().available and any(
|
||||
option in self.appliance.options
|
||||
for option in (
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE,
|
||||
)
|
||||
)
|
||||
@@ -136,7 +136,7 @@ def _get_entities_for_appliance(
|
||||
def _get_option_entities_for_appliance(
|
||||
appliance_coordinator: HomeConnectApplianceCoordinator,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> list[HomeConnectOptionEntity]:
|
||||
) -> list[HomeConnectEntity]:
|
||||
"""Get a list of currently available option entities."""
|
||||
return [
|
||||
HomeConnectOptionNumberEntity(appliance_coordinator, description)
|
||||
|
||||
@@ -355,7 +355,7 @@ def _get_entities_for_appliance(
|
||||
def _get_option_entities_for_appliance(
|
||||
appliance_coordinator: HomeConnectApplianceCoordinator,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> list[HomeConnectOptionEntity]:
|
||||
) -> list[HomeConnectEntity]:
|
||||
"""Get a list of entities."""
|
||||
return [
|
||||
HomeConnectSelectOptionEntity(appliance_coordinator, desc)
|
||||
|
||||
@@ -119,6 +119,18 @@
|
||||
"name": "Stop program"
|
||||
}
|
||||
},
|
||||
"fan": {
|
||||
"air_conditioner": {
|
||||
"state_attributes": {
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"auto": "[%key:common::state::auto%]",
|
||||
"manual": "[%key:common::state::manual%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"ambient_light": {
|
||||
"name": "Ambient light"
|
||||
|
||||
@@ -189,7 +189,7 @@ def _get_entities_for_appliance(
|
||||
def _get_option_entities_for_appliance(
|
||||
appliance_coordinator: HomeConnectApplianceCoordinator,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> list[HomeConnectOptionEntity]:
|
||||
) -> list[HomeConnectEntity]:
|
||||
"""Get a list of currently available option entities."""
|
||||
return [
|
||||
HomeConnectSwitchOptionEntity(appliance_coordinator, description)
|
||||
|
||||
@@ -29,21 +29,21 @@
|
||||
"title": "Humidity",
|
||||
"triggers": {
|
||||
"changed": {
|
||||
"description": "Triggers when the humidity changes.",
|
||||
"description": "Triggers when the relative humidity changes.",
|
||||
"fields": {
|
||||
"above": {
|
||||
"description": "Only trigger when humidity is above this value.",
|
||||
"description": "Only trigger when relative humidity is above this value.",
|
||||
"name": "Above"
|
||||
},
|
||||
"below": {
|
||||
"description": "Only trigger when humidity is below this value.",
|
||||
"description": "Only trigger when relative humidity is below this value.",
|
||||
"name": "Below"
|
||||
}
|
||||
},
|
||||
"name": "Humidity changed"
|
||||
"name": "Relative humidity changed"
|
||||
},
|
||||
"crossed_threshold": {
|
||||
"description": "Triggers when the humidity crosses a threshold.",
|
||||
"description": "Triggers when the relative humidity crosses a threshold.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::humidity::common::trigger_behavior_description%]",
|
||||
@@ -62,7 +62,7 @@
|
||||
"name": "Upper limit"
|
||||
}
|
||||
},
|
||||
"name": "Humidity crossed threshold"
|
||||
"name": "Relative humidity crossed threshold"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
unit_of_measurement: "%"
|
||||
entity:
|
||||
selector:
|
||||
entity:
|
||||
|
||||
@@ -58,7 +58,7 @@ def _is_supported(discovery_info: BluetoothServiceInfo):
|
||||
|
||||
# Some mowers only expose the serial number in the manufacturer data
|
||||
# and not the product type, so we allow None here as well.
|
||||
if product_type not in (ProductType.MOWER, None):
|
||||
if product_type not in (ProductType.MOWER, ProductType.UNKNOWN):
|
||||
LOGGER.debug("Unsupported device: %s (%s)", manufacturer_data, discovery_info)
|
||||
return False
|
||||
|
||||
|
||||
@@ -13,5 +13,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/husqvarna_automower_ble",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==1.6.0"]
|
||||
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.1.0"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["jvcprojector"],
|
||||
"requirements": ["pyjvcprojector==2.0.2"]
|
||||
"requirements": ["pyjvcprojector==2.0.3"]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import csv
|
||||
import dataclasses
|
||||
import logging
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Any, Self, cast, final
|
||||
from typing import TYPE_CHECKING, Any, Self, cast, final, override
|
||||
|
||||
from propcache.api import cached_property
|
||||
import voluptuous as vol
|
||||
@@ -272,6 +272,18 @@ def filter_turn_off_params(
|
||||
return {k: v for k, v in params.items() if k in (ATTR_TRANSITION, ATTR_FLASH)}
|
||||
|
||||
|
||||
def process_turn_off_params(
|
||||
hass: HomeAssistant, light: LightEntity, params: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Process light turn off params."""
|
||||
params = dict(params)
|
||||
|
||||
if ATTR_TRANSITION not in params:
|
||||
hass.data[DATA_PROFILES].apply_default(light.entity_id, True, params)
|
||||
|
||||
return params
|
||||
|
||||
|
||||
def filter_turn_on_params(light: LightEntity, params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Filter out params not supported by the light."""
|
||||
supported_features = light.supported_features
|
||||
@@ -306,7 +318,171 @@ def filter_turn_on_params(light: LightEntity, params: dict[str, Any]) -> dict[st
|
||||
return params
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: C901
|
||||
def process_turn_on_params( # noqa: C901
|
||||
hass: HomeAssistant, light: LightEntity, params: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Process light turn on params."""
|
||||
params = dict(params)
|
||||
|
||||
# Only process params once we processed brightness step
|
||||
if params and (
|
||||
ATTR_BRIGHTNESS_STEP in params or ATTR_BRIGHTNESS_STEP_PCT in params
|
||||
):
|
||||
brightness = light.brightness if light.is_on and light.brightness else 0
|
||||
|
||||
if ATTR_BRIGHTNESS_STEP in params:
|
||||
brightness += params.pop(ATTR_BRIGHTNESS_STEP)
|
||||
|
||||
else:
|
||||
brightness_pct = round(brightness / 255 * 100)
|
||||
brightness = round(
|
||||
(brightness_pct + params.pop(ATTR_BRIGHTNESS_STEP_PCT)) / 100 * 255
|
||||
)
|
||||
|
||||
params[ATTR_BRIGHTNESS] = max(0, min(255, brightness))
|
||||
|
||||
preprocess_turn_on_alternatives(hass, params)
|
||||
|
||||
if (not params or not light.is_on) or (params and ATTR_TRANSITION not in params):
|
||||
hass.data[DATA_PROFILES].apply_default(light.entity_id, light.is_on, params)
|
||||
|
||||
supported_color_modes = light._light_internal_supported_color_modes # noqa: SLF001
|
||||
|
||||
# If a color temperature is specified, emulate it if not supported by the light
|
||||
if ATTR_COLOR_TEMP_KELVIN in params:
|
||||
if (
|
||||
ColorMode.COLOR_TEMP not in supported_color_modes
|
||||
and ColorMode.RGBWW in supported_color_modes
|
||||
):
|
||||
color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN)
|
||||
brightness = cast(int, params.get(ATTR_BRIGHTNESS, light.brightness))
|
||||
params[ATTR_RGBWW_COLOR] = color_util.color_temperature_to_rgbww(
|
||||
color_temp,
|
||||
brightness,
|
||||
light.min_color_temp_kelvin,
|
||||
light.max_color_temp_kelvin,
|
||||
)
|
||||
elif ColorMode.COLOR_TEMP not in supported_color_modes:
|
||||
color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN)
|
||||
if color_supported(supported_color_modes):
|
||||
params[ATTR_HS_COLOR] = color_util.color_temperature_to_hs(color_temp)
|
||||
|
||||
# If a color is specified, convert to the color space supported by the light
|
||||
rgb_color: tuple[int, int, int] | None
|
||||
rgbww_color: tuple[int, int, int, int, int] | None
|
||||
if ATTR_HS_COLOR in params and ColorMode.HS not in supported_color_modes:
|
||||
hs_color = params.pop(ATTR_HS_COLOR)
|
||||
if ColorMode.RGB in supported_color_modes:
|
||||
params[ATTR_RGB_COLOR] = color_util.color_hs_to_RGB(*hs_color)
|
||||
elif ColorMode.RGBW in supported_color_modes:
|
||||
rgb_color = color_util.color_hs_to_RGB(*hs_color)
|
||||
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
|
||||
elif ColorMode.RGBWW in supported_color_modes:
|
||||
rgb_color = color_util.color_hs_to_RGB(*hs_color)
|
||||
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
|
||||
*rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
|
||||
)
|
||||
elif ColorMode.XY in supported_color_modes:
|
||||
params[ATTR_XY_COLOR] = color_util.color_hs_to_xy(*hs_color)
|
||||
elif ColorMode.COLOR_TEMP in supported_color_modes:
|
||||
xy_color = color_util.color_hs_to_xy(*hs_color)
|
||||
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
|
||||
*xy_color
|
||||
)
|
||||
elif ATTR_RGB_COLOR in params and ColorMode.RGB not in supported_color_modes:
|
||||
rgb_color = params.pop(ATTR_RGB_COLOR)
|
||||
assert rgb_color is not None
|
||||
if TYPE_CHECKING:
|
||||
rgb_color = cast(tuple[int, int, int], rgb_color)
|
||||
if ColorMode.RGBW in supported_color_modes:
|
||||
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
|
||||
elif ColorMode.RGBWW in supported_color_modes:
|
||||
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
|
||||
*rgb_color,
|
||||
light.min_color_temp_kelvin,
|
||||
light.max_color_temp_kelvin,
|
||||
)
|
||||
elif ColorMode.HS in supported_color_modes:
|
||||
params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color)
|
||||
elif ColorMode.XY in supported_color_modes:
|
||||
params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color)
|
||||
elif ColorMode.COLOR_TEMP in supported_color_modes:
|
||||
xy_color = color_util.color_RGB_to_xy(*rgb_color)
|
||||
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
|
||||
*xy_color
|
||||
)
|
||||
elif ATTR_XY_COLOR in params and ColorMode.XY not in supported_color_modes:
|
||||
xy_color = params.pop(ATTR_XY_COLOR)
|
||||
if ColorMode.HS in supported_color_modes:
|
||||
params[ATTR_HS_COLOR] = color_util.color_xy_to_hs(*xy_color)
|
||||
elif ColorMode.RGB in supported_color_modes:
|
||||
params[ATTR_RGB_COLOR] = color_util.color_xy_to_RGB(*xy_color)
|
||||
elif ColorMode.RGBW in supported_color_modes:
|
||||
rgb_color = color_util.color_xy_to_RGB(*xy_color)
|
||||
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
|
||||
elif ColorMode.RGBWW in supported_color_modes:
|
||||
rgb_color = color_util.color_xy_to_RGB(*xy_color)
|
||||
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
|
||||
*rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
|
||||
)
|
||||
elif ColorMode.COLOR_TEMP in supported_color_modes:
|
||||
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
|
||||
*xy_color
|
||||
)
|
||||
elif ATTR_RGBW_COLOR in params and ColorMode.RGBW not in supported_color_modes:
|
||||
rgbw_color = params.pop(ATTR_RGBW_COLOR)
|
||||
rgb_color = color_util.color_rgbw_to_rgb(*rgbw_color)
|
||||
if ColorMode.RGB in supported_color_modes:
|
||||
params[ATTR_RGB_COLOR] = rgb_color
|
||||
elif ColorMode.RGBWW in supported_color_modes:
|
||||
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
|
||||
*rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
|
||||
)
|
||||
elif ColorMode.HS in supported_color_modes:
|
||||
params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color)
|
||||
elif ColorMode.XY in supported_color_modes:
|
||||
params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color)
|
||||
elif ColorMode.COLOR_TEMP in supported_color_modes:
|
||||
xy_color = color_util.color_RGB_to_xy(*rgb_color)
|
||||
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
|
||||
*xy_color
|
||||
)
|
||||
elif ATTR_RGBWW_COLOR in params and ColorMode.RGBWW not in supported_color_modes:
|
||||
rgbww_color = params.pop(ATTR_RGBWW_COLOR)
|
||||
assert rgbww_color is not None
|
||||
if TYPE_CHECKING:
|
||||
rgbww_color = cast(tuple[int, int, int, int, int], rgbww_color)
|
||||
rgb_color = color_util.color_rgbww_to_rgb(
|
||||
*rgbww_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
|
||||
)
|
||||
if ColorMode.RGB in supported_color_modes:
|
||||
params[ATTR_RGB_COLOR] = rgb_color
|
||||
elif ColorMode.RGBW in supported_color_modes:
|
||||
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
|
||||
elif ColorMode.HS in supported_color_modes:
|
||||
params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color)
|
||||
elif ColorMode.XY in supported_color_modes:
|
||||
params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color)
|
||||
elif ColorMode.COLOR_TEMP in supported_color_modes:
|
||||
xy_color = color_util.color_RGB_to_xy(*rgb_color)
|
||||
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
|
||||
*xy_color
|
||||
)
|
||||
|
||||
# If white is set to True, set it to the light's brightness
|
||||
# Add a warning in Home Assistant Core 2024.3 if the brightness is set to an
|
||||
# integer.
|
||||
if params.get(ATTR_WHITE) is True:
|
||||
params[ATTR_WHITE] = light.brightness
|
||||
|
||||
# If both white and brightness are specified, override white
|
||||
if ATTR_WHITE in params and ColorMode.WHITE in supported_color_modes:
|
||||
params[ATTR_WHITE] = params.pop(ATTR_BRIGHTNESS, params[ATTR_WHITE])
|
||||
|
||||
return params
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Expose light control via state machine and services."""
|
||||
component = hass.data[DATA_COMPONENT] = EntityComponent[LightEntity](
|
||||
_LOGGER, DOMAIN, hass, SCAN_INTERVAL
|
||||
@@ -330,177 +506,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
base["params"] = data
|
||||
return base
|
||||
|
||||
async def async_handle_light_on_service( # noqa: C901
|
||||
async def async_handle_light_on_service(
|
||||
light: LightEntity, call: ServiceCall
|
||||
) -> None:
|
||||
"""Handle turning a light on.
|
||||
|
||||
If brightness is set to 0, this service will turn the light off.
|
||||
"""
|
||||
params: dict[str, Any] = dict(call.data["params"])
|
||||
params = process_turn_on_params(hass, light, call.data["params"])
|
||||
|
||||
# Only process params once we processed brightness step
|
||||
if params and (
|
||||
ATTR_BRIGHTNESS_STEP in params or ATTR_BRIGHTNESS_STEP_PCT in params
|
||||
):
|
||||
brightness = light.brightness if light.is_on and light.brightness else 0
|
||||
|
||||
if ATTR_BRIGHTNESS_STEP in params:
|
||||
brightness += params.pop(ATTR_BRIGHTNESS_STEP)
|
||||
|
||||
else:
|
||||
brightness_pct = round(brightness / 255 * 100)
|
||||
brightness = round(
|
||||
(brightness_pct + params.pop(ATTR_BRIGHTNESS_STEP_PCT)) / 100 * 255
|
||||
)
|
||||
|
||||
params[ATTR_BRIGHTNESS] = max(0, min(255, brightness))
|
||||
|
||||
preprocess_turn_on_alternatives(hass, params)
|
||||
|
||||
if (not params or not light.is_on) or (
|
||||
params and ATTR_TRANSITION not in params
|
||||
):
|
||||
profiles.apply_default(light.entity_id, light.is_on, params)
|
||||
|
||||
supported_color_modes = light._light_internal_supported_color_modes # noqa: SLF001
|
||||
|
||||
# If a color temperature is specified, emulate it if not supported by the light
|
||||
if ATTR_COLOR_TEMP_KELVIN in params:
|
||||
if (
|
||||
ColorMode.COLOR_TEMP not in supported_color_modes
|
||||
and ColorMode.RGBWW in supported_color_modes
|
||||
):
|
||||
color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN)
|
||||
brightness = cast(int, params.get(ATTR_BRIGHTNESS, light.brightness))
|
||||
params[ATTR_RGBWW_COLOR] = color_util.color_temperature_to_rgbww(
|
||||
color_temp,
|
||||
brightness,
|
||||
light.min_color_temp_kelvin,
|
||||
light.max_color_temp_kelvin,
|
||||
)
|
||||
elif ColorMode.COLOR_TEMP not in supported_color_modes:
|
||||
color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN)
|
||||
if color_supported(supported_color_modes):
|
||||
params[ATTR_HS_COLOR] = color_util.color_temperature_to_hs(
|
||||
color_temp
|
||||
)
|
||||
|
||||
# If a color is specified, convert to the color space supported by the light
|
||||
rgb_color: tuple[int, int, int] | None
|
||||
rgbww_color: tuple[int, int, int, int, int] | None
|
||||
if ATTR_HS_COLOR in params and ColorMode.HS not in supported_color_modes:
|
||||
hs_color = params.pop(ATTR_HS_COLOR)
|
||||
if ColorMode.RGB in supported_color_modes:
|
||||
params[ATTR_RGB_COLOR] = color_util.color_hs_to_RGB(*hs_color)
|
||||
elif ColorMode.RGBW in supported_color_modes:
|
||||
rgb_color = color_util.color_hs_to_RGB(*hs_color)
|
||||
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
|
||||
elif ColorMode.RGBWW in supported_color_modes:
|
||||
rgb_color = color_util.color_hs_to_RGB(*hs_color)
|
||||
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
|
||||
*rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
|
||||
)
|
||||
elif ColorMode.XY in supported_color_modes:
|
||||
params[ATTR_XY_COLOR] = color_util.color_hs_to_xy(*hs_color)
|
||||
elif ColorMode.COLOR_TEMP in supported_color_modes:
|
||||
xy_color = color_util.color_hs_to_xy(*hs_color)
|
||||
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
|
||||
*xy_color
|
||||
)
|
||||
elif ATTR_RGB_COLOR in params and ColorMode.RGB not in supported_color_modes:
|
||||
rgb_color = params.pop(ATTR_RGB_COLOR)
|
||||
assert rgb_color is not None
|
||||
if TYPE_CHECKING:
|
||||
rgb_color = cast(tuple[int, int, int], rgb_color)
|
||||
if ColorMode.RGBW in supported_color_modes:
|
||||
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
|
||||
elif ColorMode.RGBWW in supported_color_modes:
|
||||
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
|
||||
*rgb_color,
|
||||
light.min_color_temp_kelvin,
|
||||
light.max_color_temp_kelvin,
|
||||
)
|
||||
elif ColorMode.HS in supported_color_modes:
|
||||
params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color)
|
||||
elif ColorMode.XY in supported_color_modes:
|
||||
params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color)
|
||||
elif ColorMode.COLOR_TEMP in supported_color_modes:
|
||||
xy_color = color_util.color_RGB_to_xy(*rgb_color)
|
||||
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
|
||||
*xy_color
|
||||
)
|
||||
elif ATTR_XY_COLOR in params and ColorMode.XY not in supported_color_modes:
|
||||
xy_color = params.pop(ATTR_XY_COLOR)
|
||||
if ColorMode.HS in supported_color_modes:
|
||||
params[ATTR_HS_COLOR] = color_util.color_xy_to_hs(*xy_color)
|
||||
elif ColorMode.RGB in supported_color_modes:
|
||||
params[ATTR_RGB_COLOR] = color_util.color_xy_to_RGB(*xy_color)
|
||||
elif ColorMode.RGBW in supported_color_modes:
|
||||
rgb_color = color_util.color_xy_to_RGB(*xy_color)
|
||||
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
|
||||
elif ColorMode.RGBWW in supported_color_modes:
|
||||
rgb_color = color_util.color_xy_to_RGB(*xy_color)
|
||||
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
|
||||
*rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
|
||||
)
|
||||
elif ColorMode.COLOR_TEMP in supported_color_modes:
|
||||
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
|
||||
*xy_color
|
||||
)
|
||||
elif ATTR_RGBW_COLOR in params and ColorMode.RGBW not in supported_color_modes:
|
||||
rgbw_color = params.pop(ATTR_RGBW_COLOR)
|
||||
rgb_color = color_util.color_rgbw_to_rgb(*rgbw_color)
|
||||
if ColorMode.RGB in supported_color_modes:
|
||||
params[ATTR_RGB_COLOR] = rgb_color
|
||||
elif ColorMode.RGBWW in supported_color_modes:
|
||||
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
|
||||
*rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
|
||||
)
|
||||
elif ColorMode.HS in supported_color_modes:
|
||||
params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color)
|
||||
elif ColorMode.XY in supported_color_modes:
|
||||
params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color)
|
||||
elif ColorMode.COLOR_TEMP in supported_color_modes:
|
||||
xy_color = color_util.color_RGB_to_xy(*rgb_color)
|
||||
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
|
||||
*xy_color
|
||||
)
|
||||
elif (
|
||||
ATTR_RGBWW_COLOR in params and ColorMode.RGBWW not in supported_color_modes
|
||||
):
|
||||
rgbww_color = params.pop(ATTR_RGBWW_COLOR)
|
||||
assert rgbww_color is not None
|
||||
if TYPE_CHECKING:
|
||||
rgbww_color = cast(tuple[int, int, int, int, int], rgbww_color)
|
||||
rgb_color = color_util.color_rgbww_to_rgb(
|
||||
*rgbww_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
|
||||
)
|
||||
if ColorMode.RGB in supported_color_modes:
|
||||
params[ATTR_RGB_COLOR] = rgb_color
|
||||
elif ColorMode.RGBW in supported_color_modes:
|
||||
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
|
||||
elif ColorMode.HS in supported_color_modes:
|
||||
params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color)
|
||||
elif ColorMode.XY in supported_color_modes:
|
||||
params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color)
|
||||
elif ColorMode.COLOR_TEMP in supported_color_modes:
|
||||
xy_color = color_util.color_RGB_to_xy(*rgb_color)
|
||||
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
|
||||
*xy_color
|
||||
)
|
||||
|
||||
# If white is set to True, set it to the light's brightness
|
||||
# Add a warning in Home Assistant Core 2024.3 if the brightness is set to an
|
||||
# integer.
|
||||
if params.get(ATTR_WHITE) is True:
|
||||
params[ATTR_WHITE] = light.brightness
|
||||
|
||||
# If both white and brightness are specified, override white
|
||||
if ATTR_WHITE in params and ColorMode.WHITE in supported_color_modes:
|
||||
params[ATTR_WHITE] = params.pop(ATTR_BRIGHTNESS, params[ATTR_WHITE])
|
||||
|
||||
# Remove deprecated white value if the light supports color mode
|
||||
if params.get(ATTR_BRIGHTNESS) == 0 or params.get(ATTR_WHITE) == 0:
|
||||
await async_handle_light_off_service(light, call)
|
||||
else:
|
||||
@@ -510,10 +524,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
light: LightEntity, call: ServiceCall
|
||||
) -> None:
|
||||
"""Handle turning off a light."""
|
||||
params = dict(call.data["params"])
|
||||
|
||||
if ATTR_TRANSITION not in params:
|
||||
profiles.apply_default(light.entity_id, True, params)
|
||||
params = process_turn_off_params(hass, light, call.data["params"])
|
||||
|
||||
await light.async_turn_off(**filter_turn_off_params(light, params))
|
||||
|
||||
@@ -521,10 +532,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
light: LightEntity, call: ServiceCall
|
||||
) -> None:
|
||||
"""Handle toggling a light."""
|
||||
if light.is_on:
|
||||
await async_handle_light_off_service(light, call)
|
||||
else:
|
||||
await async_handle_light_on_service(light, call)
|
||||
await light.async_toggle(**call.data["params"])
|
||||
|
||||
# Listen for light on and light off service calls.
|
||||
|
||||
@@ -1046,3 +1054,15 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
def supported_features(self) -> LightEntityFeature:
|
||||
"""Flag supported features."""
|
||||
return self._attr_supported_features
|
||||
|
||||
@override
|
||||
async def async_toggle(self, **kwargs: Any) -> None:
|
||||
"""Toggle the entity."""
|
||||
if not self.is_on:
|
||||
params = process_turn_on_params(self.hass, self, kwargs)
|
||||
if params.get(ATTR_BRIGHTNESS) != 0 and params.get(ATTR_WHITE) != 0:
|
||||
await self.async_turn_on(**filter_turn_on_params(self, params))
|
||||
return
|
||||
|
||||
params = process_turn_off_params(self.hass, self, kwargs)
|
||||
await self.async_turn_off(**filter_turn_off_params(self, params))
|
||||
|
||||
@@ -19,6 +19,5 @@ async def async_get_config_entry_diagnostics(
|
||||
return {
|
||||
"device_info": client.device_info,
|
||||
"vehicles": client.vehicles,
|
||||
"ct_connected": client.ct_connected,
|
||||
"cap_available": client.cap_available,
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["ohme==1.6.0"]
|
||||
"requirements": ["ohme==1.7.0"]
|
||||
}
|
||||
|
||||
@@ -431,6 +431,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, OpowerData]]):
|
||||
for source_id, source_stats in existing_stats.items():
|
||||
_LOGGER.debug("Found %d statistics for %s", len(source_stats), source_id)
|
||||
if not source_stats:
|
||||
need_migration_source_ids.remove(source_id)
|
||||
continue
|
||||
target_id = migration_map[source_id]
|
||||
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["opower"],
|
||||
"quality_scale": "bronze",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["opower==0.17.0"]
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ rules:
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices:
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/otbr",
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["python-otbr-api==2.8.0"]
|
||||
"requirements": ["python-otbr-api==2.9.0"]
|
||||
}
|
||||
|
||||
@@ -196,6 +196,7 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD
|
||||
# Check if container belongs to a stack via docker compose label
|
||||
stack_name: str | None = (
|
||||
container.labels.get("com.docker.compose.project")
|
||||
or container.labels.get("com.docker.stack.namespace")
|
||||
if container.labels
|
||||
else None
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ from homeassistant.components.notify import (
|
||||
BaseNotificationService,
|
||||
)
|
||||
from homeassistant.components.telegram_bot import (
|
||||
ATTR_CHAT_ID,
|
||||
ATTR_DISABLE_NOTIF,
|
||||
ATTR_DISABLE_WEB_PREV,
|
||||
ATTR_MESSAGE_TAG,
|
||||
@@ -58,7 +59,7 @@ async def async_get_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"migrate_notify",
|
||||
breaks_in_ha_version="2026.5.0",
|
||||
breaks_in_ha_version="2026.8.0",
|
||||
is_fixable=False,
|
||||
translation_key="migrate_notify",
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
@@ -80,7 +81,7 @@ class TelegramNotificationService(BaseNotificationService):
|
||||
|
||||
def send_message(self, message: str = "", **kwargs: Any) -> None:
|
||||
"""Send a message to a user."""
|
||||
service_data = {ATTR_TARGET: kwargs.get(ATTR_TARGET, self._chat_id)}
|
||||
service_data = {ATTR_CHAT_ID: kwargs.get(ATTR_TARGET, self._chat_id)}
|
||||
data = kwargs.get(ATTR_DATA)
|
||||
|
||||
# Set message tag
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/thread",
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["python-otbr-api==2.8.0", "pyroute2==0.7.5"],
|
||||
"requirements": ["python-otbr-api==2.9.0", "pyroute2==0.7.5"],
|
||||
"single_config_entry": true,
|
||||
"zeroconf": ["_meshcop._udp.local."]
|
||||
}
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from enum import IntEnum
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from zwave_js_server.const import CommandClass
|
||||
from zwave_js_server.const.command_class.lock import DOOR_STATUS_PROPERTY
|
||||
from zwave_js_server.const.command_class.notification import (
|
||||
CC_SPECIFIC_NOTIFICATION_TYPE,
|
||||
AccessControlNotificationEvent,
|
||||
NotificationEvent,
|
||||
NotificationType,
|
||||
SmokeAlarmNotificationEvent,
|
||||
@@ -29,6 +32,10 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .entity import NewZwaveDiscoveryInfo, ZWaveBaseEntity
|
||||
from .helpers import (
|
||||
get_opening_state_notification_value,
|
||||
is_opening_state_notification_value,
|
||||
)
|
||||
from .models import (
|
||||
NewZWaveDiscoverySchema,
|
||||
ValueType,
|
||||
@@ -59,6 +66,42 @@ NOTIFICATION_WEATHER = "16"
|
||||
NOTIFICATION_IRRIGATION = "17"
|
||||
NOTIFICATION_GAS = "18"
|
||||
|
||||
# Deprecated/legacy synthetic Access Control door state notification
|
||||
# event IDs that don't exist in zwave-js-server
|
||||
ACCESS_CONTROL_DOOR_STATE_OPEN_REGULAR = 5632
|
||||
ACCESS_CONTROL_DOOR_STATE_OPEN_TILT = 5633
|
||||
|
||||
|
||||
# Numeric State values used by the "Opening state" notification variable.
|
||||
# This is only needed temporarily until the legacy Access Control door state binary sensors are removed.
|
||||
class OpeningState(IntEnum):
|
||||
"""Opening state values exposed by Access Control notifications."""
|
||||
|
||||
CLOSED = 0
|
||||
OPEN = 1
|
||||
TILTED = 2
|
||||
|
||||
|
||||
# parse_opening_state helpers for the DEPRECATED legacy Access Control binary sensors.
|
||||
def _legacy_is_closed(opening_state: OpeningState) -> bool:
|
||||
"""Return if Opening state represents closed."""
|
||||
return opening_state is OpeningState.CLOSED
|
||||
|
||||
|
||||
def _legacy_is_open(opening_state: OpeningState) -> bool:
|
||||
"""Return if Opening state represents open."""
|
||||
return opening_state is OpeningState.OPEN
|
||||
|
||||
|
||||
def _legacy_is_open_or_tilted(opening_state: OpeningState) -> bool:
|
||||
"""Return if Opening state represents open or tilted."""
|
||||
return opening_state in (OpeningState.OPEN, OpeningState.TILTED)
|
||||
|
||||
|
||||
def _legacy_is_tilted(opening_state: OpeningState) -> bool:
|
||||
"""Return if Opening state represents tilted."""
|
||||
return opening_state is OpeningState.TILTED
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class NotificationZWaveJSEntityDescription(BinarySensorEntityDescription):
|
||||
@@ -82,6 +125,14 @@ class NewNotificationZWaveJSEntityDescription(BinarySensorEntityDescription):
|
||||
state_key: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class OpeningStateZWaveJSEntityDescription(BinarySensorEntityDescription):
|
||||
"""Describe a legacy Access Control binary sensor that derives state from Opening state."""
|
||||
|
||||
state_key: int
|
||||
parse_opening_state: Callable[[OpeningState], bool]
|
||||
|
||||
|
||||
# Mappings for Notification sensors
|
||||
# https://github.com/zwave-js/specs/blob/master/Registries/Notification%20Command%20Class%2C%20list%20of%20assigned%20Notifications.xlsx
|
||||
#
|
||||
@@ -127,6 +178,7 @@ class NewNotificationZWaveJSEntityDescription(BinarySensorEntityDescription):
|
||||
# to use the new discovery schema and we've removed the old discovery code.
|
||||
MIGRATED_NOTIFICATION_TYPES = {
|
||||
NotificationType.SMOKE_ALARM,
|
||||
NotificationType.ACCESS_CONTROL,
|
||||
}
|
||||
|
||||
NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] = (
|
||||
@@ -202,26 +254,6 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] =
|
||||
key=NOTIFICATION_WATER,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
NotificationZWaveJSEntityDescription(
|
||||
# NotificationType 6: Access Control - State Id's 1, 2, 3, 4 (Lock)
|
||||
key=NOTIFICATION_ACCESS_CONTROL,
|
||||
states={1, 2, 3, 4},
|
||||
device_class=BinarySensorDeviceClass.LOCK,
|
||||
),
|
||||
NotificationZWaveJSEntityDescription(
|
||||
# NotificationType 6: Access Control - State Id's 11 (Lock jammed)
|
||||
key=NOTIFICATION_ACCESS_CONTROL,
|
||||
states={11},
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
NotificationZWaveJSEntityDescription(
|
||||
# NotificationType 6: Access Control - State Id 22 (door/window open)
|
||||
key=NOTIFICATION_ACCESS_CONTROL,
|
||||
not_states={23},
|
||||
states={22},
|
||||
device_class=BinarySensorDeviceClass.DOOR,
|
||||
),
|
||||
NotificationZWaveJSEntityDescription(
|
||||
# NotificationType 7: Home Security - State Id's 1, 2 (intrusion)
|
||||
key=NOTIFICATION_HOME_SECURITY,
|
||||
@@ -364,6 +396,10 @@ def is_valid_notification_binary_sensor(
|
||||
"""Return if the notification CC Value is valid as binary sensor."""
|
||||
if not info.primary_value.metadata.states:
|
||||
return False
|
||||
# Access Control - Opening state is exposed as a single enum sensor instead
|
||||
# of fanning out one binary sensor per state.
|
||||
if is_opening_state_notification_value(info.primary_value):
|
||||
return False
|
||||
return len(info.primary_value.metadata.states) > 1
|
||||
|
||||
|
||||
@@ -406,6 +442,13 @@ async def async_setup_entry(
|
||||
and info.entity_class is ZWaveBooleanBinarySensor
|
||||
):
|
||||
entities.append(ZWaveBooleanBinarySensor(config_entry, driver, info))
|
||||
elif (
|
||||
isinstance(info, NewZwaveDiscoveryInfo)
|
||||
and info.entity_class is ZWaveLegacyDoorStateBinarySensor
|
||||
):
|
||||
entities.append(
|
||||
ZWaveLegacyDoorStateBinarySensor(config_entry, driver, info)
|
||||
)
|
||||
elif isinstance(info, NewZwaveDiscoveryInfo):
|
||||
pass # other entity classes are not migrated yet
|
||||
elif info.platform_hint == "notification":
|
||||
@@ -542,6 +585,51 @@ class ZWaveNotificationBinarySensor(ZWaveBaseEntity, BinarySensorEntity):
|
||||
return int(self.info.primary_value.value) == int(self.state_key)
|
||||
|
||||
|
||||
class ZWaveLegacyDoorStateBinarySensor(ZWaveBaseEntity, BinarySensorEntity):
|
||||
"""DEPRECATED: Legacy door state binary sensors.
|
||||
|
||||
These entities exist purely for backwards compatibility with users who had
|
||||
door state binary sensors before the Opening state value was introduced.
|
||||
They are disabled by default when the Opening state value is present and
|
||||
should not be extended. State is derived from the Opening state notification
|
||||
value using the parse_opening_state function defined on the entity description.
|
||||
"""
|
||||
|
||||
entity_description: OpeningStateZWaveJSEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_entry: ZwaveJSConfigEntry,
|
||||
driver: Driver,
|
||||
info: NewZwaveDiscoveryInfo,
|
||||
) -> None:
|
||||
"""Initialize a legacy Door state binary sensor entity."""
|
||||
super().__init__(config_entry, driver, info)
|
||||
opening_state_value = get_opening_state_notification_value(self.info.node)
|
||||
assert opening_state_value is not None # guaranteed by required_values schema
|
||||
self._opening_state_value_id = opening_state_value.value_id
|
||||
self.watched_value_ids.add(opening_state_value.value_id)
|
||||
self._attr_unique_id = (
|
||||
f"{self._attr_unique_id}.{self.entity_description.state_key}"
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return if the sensor is on or off."""
|
||||
value = self.info.node.values.get(self._opening_state_value_id)
|
||||
if value is None:
|
||||
return None
|
||||
opening_state = value.value
|
||||
if opening_state is None:
|
||||
return None
|
||||
try:
|
||||
return self.entity_description.parse_opening_state(
|
||||
OpeningState(int(opening_state))
|
||||
)
|
||||
except TypeError, ValueError:
|
||||
return None
|
||||
|
||||
|
||||
class ZWavePropertyBinarySensor(ZWaveBaseEntity, BinarySensorEntity):
|
||||
"""Representation of a Z-Wave binary_sensor from a property."""
|
||||
|
||||
@@ -586,7 +674,392 @@ class ZWaveConfigParameterBinarySensor(ZWaveBooleanBinarySensor):
|
||||
)
|
||||
|
||||
|
||||
OPENING_STATE_NOTIFICATION_SCHEMA = ZWaveValueDiscoverySchema(
|
||||
command_class={CommandClass.NOTIFICATION},
|
||||
property={"Access Control"},
|
||||
property_key={"Opening state"},
|
||||
type={ValueType.NUMBER},
|
||||
any_available_cc_specific={
|
||||
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [
|
||||
NewZWaveDiscoverySchema(
|
||||
platform=Platform.BINARY_SENSOR,
|
||||
primary_value=ZWaveValueDiscoverySchema(
|
||||
command_class={CommandClass.NOTIFICATION},
|
||||
property={"Access Control"},
|
||||
property_key={"Lock state"},
|
||||
type={ValueType.NUMBER},
|
||||
any_available_states_keys={1, 2, 3, 4},
|
||||
any_available_cc_specific={
|
||||
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL)
|
||||
},
|
||||
),
|
||||
allow_multi=True,
|
||||
entity_description=NotificationZWaveJSEntityDescription(
|
||||
# NotificationType 6: Access Control - State Id's 1, 2, 3, 4 (Lock)
|
||||
key=NOTIFICATION_ACCESS_CONTROL,
|
||||
states={1, 2, 3, 4},
|
||||
device_class=BinarySensorDeviceClass.LOCK,
|
||||
),
|
||||
entity_class=ZWaveNotificationBinarySensor,
|
||||
),
|
||||
NewZWaveDiscoverySchema(
|
||||
platform=Platform.BINARY_SENSOR,
|
||||
primary_value=ZWaveValueDiscoverySchema(
|
||||
command_class={CommandClass.NOTIFICATION},
|
||||
property={"Access Control"},
|
||||
property_key={"Lock state"},
|
||||
type={ValueType.NUMBER},
|
||||
any_available_states_keys={11},
|
||||
any_available_cc_specific={
|
||||
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL)
|
||||
},
|
||||
),
|
||||
entity_description=NotificationZWaveJSEntityDescription(
|
||||
# NotificationType 6: Access Control - State Id's 11 (Lock jammed)
|
||||
key=NOTIFICATION_ACCESS_CONTROL,
|
||||
states={11},
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
entity_class=ZWaveNotificationBinarySensor,
|
||||
),
|
||||
# -------------------------------------------------------------------
|
||||
# DEPRECATED legacy Access Control door/window binary sensors.
|
||||
# These schemas exist only for backwards compatibility with users who
|
||||
# already have these entities registered. New integrations should use
|
||||
# the Opening state enum sensor instead. Do not add new schemas here.
|
||||
# All schemas below use ZWaveLegacyDoorStateBinarySensor and are
|
||||
# disabled by default (entity_registry_enabled_default=False).
|
||||
# -------------------------------------------------------------------
|
||||
NewZWaveDiscoverySchema(
|
||||
platform=Platform.BINARY_SENSOR,
|
||||
primary_value=ZWaveValueDiscoverySchema(
|
||||
command_class={CommandClass.NOTIFICATION},
|
||||
property={"Access Control"},
|
||||
property_key={"Door state (simple)"},
|
||||
type={ValueType.NUMBER},
|
||||
any_available_states_keys={
|
||||
AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN
|
||||
},
|
||||
any_available_cc_specific={
|
||||
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL)
|
||||
},
|
||||
),
|
||||
required_values=[OPENING_STATE_NOTIFICATION_SCHEMA],
|
||||
allow_multi=True,
|
||||
entity_description=OpeningStateZWaveJSEntityDescription(
|
||||
key="legacy_access_control_door_state_simple_open",
|
||||
name="Window/door is open",
|
||||
state_key=AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN,
|
||||
parse_opening_state=_legacy_is_open_or_tilted,
|
||||
device_class=BinarySensorDeviceClass.DOOR,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
entity_class=ZWaveLegacyDoorStateBinarySensor,
|
||||
),
|
||||
NewZWaveDiscoverySchema(
|
||||
platform=Platform.BINARY_SENSOR,
|
||||
primary_value=ZWaveValueDiscoverySchema(
|
||||
command_class={CommandClass.NOTIFICATION},
|
||||
property={"Access Control"},
|
||||
property_key={"Door state (simple)"},
|
||||
type={ValueType.NUMBER},
|
||||
any_available_states_keys={
|
||||
AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_CLOSED
|
||||
},
|
||||
any_available_cc_specific={
|
||||
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL)
|
||||
},
|
||||
),
|
||||
required_values=[OPENING_STATE_NOTIFICATION_SCHEMA],
|
||||
allow_multi=True,
|
||||
entity_description=OpeningStateZWaveJSEntityDescription(
|
||||
key="legacy_access_control_door_state_simple_closed",
|
||||
name="Window/door is closed",
|
||||
state_key=AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_CLOSED,
|
||||
parse_opening_state=_legacy_is_closed,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
entity_class=ZWaveLegacyDoorStateBinarySensor,
|
||||
),
|
||||
NewZWaveDiscoverySchema(
|
||||
platform=Platform.BINARY_SENSOR,
|
||||
primary_value=ZWaveValueDiscoverySchema(
|
||||
command_class={CommandClass.NOTIFICATION},
|
||||
property={"Access Control"},
|
||||
property_key={"Door state"},
|
||||
type={ValueType.NUMBER},
|
||||
any_available_states_keys={
|
||||
AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN
|
||||
},
|
||||
any_available_cc_specific={
|
||||
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL)
|
||||
},
|
||||
),
|
||||
required_values=[OPENING_STATE_NOTIFICATION_SCHEMA],
|
||||
allow_multi=True,
|
||||
entity_description=OpeningStateZWaveJSEntityDescription(
|
||||
key="legacy_access_control_door_state_open",
|
||||
name="Window/door is open",
|
||||
state_key=AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN,
|
||||
parse_opening_state=_legacy_is_open,
|
||||
device_class=BinarySensorDeviceClass.DOOR,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
entity_class=ZWaveLegacyDoorStateBinarySensor,
|
||||
),
|
||||
NewZWaveDiscoverySchema(
|
||||
platform=Platform.BINARY_SENSOR,
|
||||
primary_value=ZWaveValueDiscoverySchema(
|
||||
command_class={CommandClass.NOTIFICATION},
|
||||
property={"Access Control"},
|
||||
property_key={"Door state"},
|
||||
type={ValueType.NUMBER},
|
||||
any_available_states_keys={
|
||||
AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_CLOSED
|
||||
},
|
||||
any_available_cc_specific={
|
||||
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL)
|
||||
},
|
||||
),
|
||||
required_values=[OPENING_STATE_NOTIFICATION_SCHEMA],
|
||||
allow_multi=True,
|
||||
entity_description=OpeningStateZWaveJSEntityDescription(
|
||||
key="legacy_access_control_door_state_closed",
|
||||
name="Window/door is closed",
|
||||
state_key=AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_CLOSED,
|
||||
parse_opening_state=_legacy_is_closed,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
entity_class=ZWaveLegacyDoorStateBinarySensor,
|
||||
),
|
||||
NewZWaveDiscoverySchema(
|
||||
platform=Platform.BINARY_SENSOR,
|
||||
primary_value=ZWaveValueDiscoverySchema(
|
||||
command_class={CommandClass.NOTIFICATION},
|
||||
property={"Access Control"},
|
||||
property_key={"Door state"},
|
||||
type={ValueType.NUMBER},
|
||||
any_available_states_keys={ACCESS_CONTROL_DOOR_STATE_OPEN_REGULAR},
|
||||
any_available_cc_specific={
|
||||
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL)
|
||||
},
|
||||
),
|
||||
required_values=[OPENING_STATE_NOTIFICATION_SCHEMA],
|
||||
allow_multi=True,
|
||||
entity_description=OpeningStateZWaveJSEntityDescription(
|
||||
key="legacy_access_control_door_state_open_regular",
|
||||
name="Window/door is open in regular position",
|
||||
state_key=ACCESS_CONTROL_DOOR_STATE_OPEN_REGULAR,
|
||||
parse_opening_state=_legacy_is_open,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
entity_class=ZWaveLegacyDoorStateBinarySensor,
|
||||
),
|
||||
NewZWaveDiscoverySchema(
|
||||
platform=Platform.BINARY_SENSOR,
|
||||
primary_value=ZWaveValueDiscoverySchema(
|
||||
command_class={CommandClass.NOTIFICATION},
|
||||
property={"Access Control"},
|
||||
property_key={"Door state"},
|
||||
type={ValueType.NUMBER},
|
||||
any_available_states_keys={ACCESS_CONTROL_DOOR_STATE_OPEN_TILT},
|
||||
any_available_cc_specific={
|
||||
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL)
|
||||
},
|
||||
),
|
||||
required_values=[OPENING_STATE_NOTIFICATION_SCHEMA],
|
||||
allow_multi=True,
|
||||
entity_description=OpeningStateZWaveJSEntityDescription(
|
||||
key="legacy_access_control_door_state_open_tilt",
|
||||
name="Window/door is open in tilt position",
|
||||
state_key=ACCESS_CONTROL_DOOR_STATE_OPEN_TILT,
|
||||
parse_opening_state=_legacy_is_tilted,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
entity_class=ZWaveLegacyDoorStateBinarySensor,
|
||||
),
|
||||
NewZWaveDiscoverySchema(
|
||||
platform=Platform.BINARY_SENSOR,
|
||||
primary_value=ZWaveValueDiscoverySchema(
|
||||
command_class={CommandClass.NOTIFICATION},
|
||||
property={"Access Control"},
|
||||
property_key={"Door tilt state"},
|
||||
type={ValueType.NUMBER},
|
||||
any_available_states_keys={OpeningState.OPEN},
|
||||
any_available_cc_specific={
|
||||
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL)
|
||||
},
|
||||
),
|
||||
required_values=[OPENING_STATE_NOTIFICATION_SCHEMA],
|
||||
allow_multi=True,
|
||||
entity_description=OpeningStateZWaveJSEntityDescription(
|
||||
key="legacy_access_control_door_tilt_state_tilted",
|
||||
name="Window/door is tilted",
|
||||
state_key=OpeningState.OPEN,
|
||||
parse_opening_state=_legacy_is_tilted,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
entity_class=ZWaveLegacyDoorStateBinarySensor,
|
||||
),
|
||||
# -------------------------------------------------------------------
|
||||
# Access Control door/window binary sensors for devices that do NOT have the
|
||||
# new "Opening state" notification value. These replace the old-style discovery
|
||||
# that used NOTIFICATION_SENSOR_MAPPINGS.
|
||||
#
|
||||
# Each property_key uses two schemas so that only the "open" state entity gets
|
||||
# device_class=DOOR, while the other state entities (e.g. "closed") do not.
|
||||
# The first schema uses allow_multi=True so it does not consume the value, allowing
|
||||
# the second schema to also match and create entities for the remaining states.
|
||||
NewZWaveDiscoverySchema(
|
||||
platform=Platform.BINARY_SENSOR,
|
||||
primary_value=ZWaveValueDiscoverySchema(
|
||||
command_class={CommandClass.NOTIFICATION},
|
||||
property={"Access Control"},
|
||||
property_key={"Door state (simple)"},
|
||||
type={ValueType.NUMBER},
|
||||
any_available_states_keys={
|
||||
AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN
|
||||
},
|
||||
any_available_cc_specific={
|
||||
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL)
|
||||
},
|
||||
),
|
||||
absent_values=[OPENING_STATE_NOTIFICATION_SCHEMA],
|
||||
allow_multi=True,
|
||||
entity_description=NotificationZWaveJSEntityDescription(
|
||||
key=NOTIFICATION_ACCESS_CONTROL,
|
||||
states={AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN},
|
||||
device_class=BinarySensorDeviceClass.DOOR,
|
||||
),
|
||||
entity_class=ZWaveNotificationBinarySensor,
|
||||
),
|
||||
NewZWaveDiscoverySchema(
|
||||
platform=Platform.BINARY_SENSOR,
|
||||
primary_value=ZWaveValueDiscoverySchema(
|
||||
command_class={CommandClass.NOTIFICATION},
|
||||
property={"Access Control"},
|
||||
property_key={"Door state (simple)"},
|
||||
type={ValueType.NUMBER},
|
||||
any_available_states_keys={
|
||||
AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN
|
||||
},
|
||||
any_available_cc_specific={
|
||||
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL)
|
||||
},
|
||||
),
|
||||
absent_values=[OPENING_STATE_NOTIFICATION_SCHEMA],
|
||||
entity_description=NotificationZWaveJSEntityDescription(
|
||||
key=NOTIFICATION_ACCESS_CONTROL,
|
||||
not_states={AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN},
|
||||
),
|
||||
entity_class=ZWaveNotificationBinarySensor,
|
||||
),
|
||||
NewZWaveDiscoverySchema(
|
||||
platform=Platform.BINARY_SENSOR,
|
||||
primary_value=ZWaveValueDiscoverySchema(
|
||||
command_class={CommandClass.NOTIFICATION},
|
||||
property={"Access Control"},
|
||||
property_key={"Door state"},
|
||||
type={ValueType.NUMBER},
|
||||
any_available_states_keys={
|
||||
AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN
|
||||
},
|
||||
any_available_cc_specific={
|
||||
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL)
|
||||
},
|
||||
),
|
||||
absent_values=[OPENING_STATE_NOTIFICATION_SCHEMA],
|
||||
allow_multi=True,
|
||||
entity_description=NotificationZWaveJSEntityDescription(
|
||||
key=NOTIFICATION_ACCESS_CONTROL,
|
||||
states={AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN},
|
||||
device_class=BinarySensorDeviceClass.DOOR,
|
||||
),
|
||||
entity_class=ZWaveNotificationBinarySensor,
|
||||
),
|
||||
NewZWaveDiscoverySchema(
|
||||
platform=Platform.BINARY_SENSOR,
|
||||
primary_value=ZWaveValueDiscoverySchema(
|
||||
command_class={CommandClass.NOTIFICATION},
|
||||
property={"Access Control"},
|
||||
property_key={"Door state"},
|
||||
type={ValueType.NUMBER},
|
||||
any_available_states_keys={
|
||||
AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN
|
||||
},
|
||||
any_available_cc_specific={
|
||||
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL)
|
||||
},
|
||||
),
|
||||
absent_values=[OPENING_STATE_NOTIFICATION_SCHEMA],
|
||||
entity_description=NotificationZWaveJSEntityDescription(
|
||||
key=NOTIFICATION_ACCESS_CONTROL,
|
||||
not_states={AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN},
|
||||
),
|
||||
entity_class=ZWaveNotificationBinarySensor,
|
||||
),
|
||||
NewZWaveDiscoverySchema(
|
||||
platform=Platform.BINARY_SENSOR,
|
||||
primary_value=ZWaveValueDiscoverySchema(
|
||||
command_class={CommandClass.NOTIFICATION},
|
||||
property={"Access Control"},
|
||||
property_key={"Door tilt state"},
|
||||
type={ValueType.NUMBER},
|
||||
any_available_states_keys={OpeningState.OPEN},
|
||||
any_available_cc_specific={
|
||||
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL)
|
||||
},
|
||||
),
|
||||
absent_values=[OPENING_STATE_NOTIFICATION_SCHEMA],
|
||||
entity_description=NotificationZWaveJSEntityDescription(
|
||||
key=NOTIFICATION_ACCESS_CONTROL,
|
||||
states={OpeningState.OPEN},
|
||||
),
|
||||
entity_class=ZWaveNotificationBinarySensor,
|
||||
),
|
||||
NewZWaveDiscoverySchema(
|
||||
platform=Platform.BINARY_SENSOR,
|
||||
primary_value=ZWaveValueDiscoverySchema(
|
||||
command_class={CommandClass.NOTIFICATION},
|
||||
property={"Access Control"},
|
||||
type={ValueType.NUMBER},
|
||||
any_available_cc_specific={
|
||||
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL)
|
||||
},
|
||||
),
|
||||
allow_multi=True,
|
||||
entity_description=NotificationZWaveJSEntityDescription(
|
||||
# NotificationType 6: Access Control - All other notification values.
|
||||
# not_states excludes states already handled by more specific schemas above,
|
||||
# so this catch-all only fires for genuinely unhandled property keys
|
||||
# (e.g. barrier, keypad, credential events).
|
||||
key=NOTIFICATION_ACCESS_CONTROL,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
not_states={
|
||||
0,
|
||||
# Lock state values (Lock state schemas consume the value when state 11 is
|
||||
# available, but may not when state 11 is absent)
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
11,
|
||||
# Door state (simple) / Door state values
|
||||
AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN,
|
||||
AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_CLOSED,
|
||||
ACCESS_CONTROL_DOOR_STATE_OPEN_REGULAR,
|
||||
ACCESS_CONTROL_DOOR_STATE_OPEN_TILT,
|
||||
},
|
||||
),
|
||||
entity_class=ZWaveNotificationBinarySensor,
|
||||
),
|
||||
# -------------------------------------------------------------------
|
||||
NewZWaveDiscoverySchema(
|
||||
# Hoppe eHandle ConnectSense (0x0313:0x0701:0x0002) - window tilt sensor.
|
||||
# The window tilt state is exposed as a binary sensor that is disabled by default
|
||||
|
||||
@@ -207,3 +207,7 @@ COVER_TILT_PROPERTY_KEYS: set[str | int | None] = {
|
||||
WindowCoveringPropertyKey.VERTICAL_SLATS_ANGLE,
|
||||
WindowCoveringPropertyKey.VERTICAL_SLATS_ANGLE_NO_POSITION,
|
||||
}
|
||||
|
||||
# notification
|
||||
NOTIFICATION_ACCESS_CONTROL_PROPERTY = "Access Control"
|
||||
OPENING_STATE_PROPERTY_KEY = "Opening state"
|
||||
|
||||
@@ -16,6 +16,10 @@ from zwave_js_server.const import (
|
||||
ConfigurationValueType,
|
||||
LogLevel,
|
||||
)
|
||||
from zwave_js_server.const.command_class.notification import (
|
||||
CC_SPECIFIC_NOTIFICATION_TYPE,
|
||||
NotificationType,
|
||||
)
|
||||
from zwave_js_server.model.controller import Controller, ProvisioningEntry
|
||||
from zwave_js_server.model.driver import Driver
|
||||
from zwave_js_server.model.log_config import LogConfig
|
||||
@@ -53,6 +57,8 @@ from .const import (
|
||||
DOMAIN,
|
||||
LIB_LOGGER,
|
||||
LOGGER,
|
||||
NOTIFICATION_ACCESS_CONTROL_PROPERTY,
|
||||
OPENING_STATE_PROPERTY_KEY,
|
||||
)
|
||||
from .models import ZwaveJSConfigEntry
|
||||
|
||||
@@ -126,6 +132,37 @@ def get_value_of_zwave_value(value: ZwaveValue | None) -> Any | None:
|
||||
return value.value if value else None
|
||||
|
||||
|
||||
def _get_notification_type(value: ZwaveValue) -> int | None:
|
||||
"""Return the notification type for a value, if available."""
|
||||
return value.metadata.cc_specific.get(CC_SPECIFIC_NOTIFICATION_TYPE)
|
||||
|
||||
|
||||
def is_opening_state_notification_value(value: ZwaveValue) -> bool:
|
||||
"""Return if the value is the Access Control Opening state notification."""
|
||||
if (
|
||||
value.command_class != CommandClass.NOTIFICATION
|
||||
or _get_notification_type(value) != NotificationType.ACCESS_CONTROL
|
||||
):
|
||||
return False
|
||||
|
||||
return (
|
||||
value.property_ == NOTIFICATION_ACCESS_CONTROL_PROPERTY
|
||||
and value.property_key == OPENING_STATE_PROPERTY_KEY
|
||||
)
|
||||
|
||||
|
||||
def get_opening_state_notification_value(node: ZwaveNode) -> ZwaveValue | None:
|
||||
"""Return the Access Control Opening state value for a node."""
|
||||
value_id = get_value_id_str(
|
||||
node,
|
||||
CommandClass.NOTIFICATION,
|
||||
NOTIFICATION_ACCESS_CONTROL_PROPERTY,
|
||||
None,
|
||||
OPENING_STATE_PROPERTY_KEY,
|
||||
)
|
||||
return node.values.get(value_id)
|
||||
|
||||
|
||||
async def async_enable_statistics(driver: Driver) -> None:
|
||||
"""Enable statistics on the driver."""
|
||||
await driver.async_enable_statistics("Home Assistant", HA_VERSION)
|
||||
|
||||
@@ -859,13 +859,22 @@ class ZWaveListSensor(ZwaveSensor):
|
||||
)
|
||||
|
||||
# Entity class attributes
|
||||
# Notification sensors have the following name mapping (variables are property
|
||||
# keys, name is property)
|
||||
# Notification sensors use the notification event label as the name
|
||||
# (property_key_name/metadata.label, falling back to property_name)
|
||||
# https://github.com/zwave-js/node-zwave-js/blob/master/packages/config/config/notifications.json
|
||||
self._attr_name = self.generate_name(
|
||||
alternate_value_name=self.info.primary_value.property_name,
|
||||
additional_info=[self.info.primary_value.property_key_name],
|
||||
)
|
||||
if info.platform_hint == "notification":
|
||||
self._attr_name = self.generate_name(
|
||||
alternate_value_name=(
|
||||
info.primary_value.property_key_name
|
||||
or info.primary_value.metadata.label
|
||||
or info.primary_value.property_name
|
||||
)
|
||||
)
|
||||
else:
|
||||
self._attr_name = self.generate_name(
|
||||
alternate_value_name=info.primary_value.property_name,
|
||||
additional_info=[info.primary_value.property_key_name],
|
||||
)
|
||||
if self.info.primary_value.metadata.states:
|
||||
self._attr_device_class = SensorDeviceClass.ENUM
|
||||
self._attr_options = list(info.primary_value.metadata.states.values())
|
||||
|
||||
@@ -584,7 +584,7 @@ _number_or_entity = vol.All(
|
||||
|
||||
NUMERICAL_ATTRIBUTE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_OPTIONS, default={}): vol.All(
|
||||
vol.Required(CONF_OPTIONS): vol.All(
|
||||
{
|
||||
vol.Optional(CONF_ABOVE): _number_or_entity,
|
||||
vol.Optional(CONF_BELOW): _number_or_entity,
|
||||
|
||||
@@ -37,7 +37,7 @@ fnv-hash-fast==1.6.0
|
||||
go2rtc-client==0.4.0
|
||||
ha-ffmpeg==3.2.2
|
||||
habluetooth==5.9.1
|
||||
hass-nabucasa==1.15.0
|
||||
hass-nabucasa==2.0.0
|
||||
hassil==3.5.0
|
||||
home-assistant-bluetooth==1.13.1
|
||||
home-assistant-frontend==20260304.0
|
||||
|
||||
@@ -51,7 +51,7 @@ dependencies = [
|
||||
"fnv-hash-fast==1.6.0",
|
||||
# hass-nabucasa is imported by helpers which don't depend on the cloud
|
||||
# integration
|
||||
"hass-nabucasa==1.15.0",
|
||||
"hass-nabucasa==2.0.0",
|
||||
# When bumping httpx, please check the version pins of
|
||||
# httpcore, anyio, and h11 in gen_requirements_all
|
||||
"httpx==0.28.1",
|
||||
|
||||
2
requirements.txt
generated
2
requirements.txt
generated
@@ -25,7 +25,7 @@ cronsim==2.7
|
||||
cryptography==46.0.5
|
||||
fnv-hash-fast==1.6.0
|
||||
ha-ffmpeg==3.2.2
|
||||
hass-nabucasa==1.15.0
|
||||
hass-nabucasa==2.0.0
|
||||
hassil==3.5.0
|
||||
home-assistant-bluetooth==1.13.1
|
||||
home-assistant-intents==2026.3.3
|
||||
|
||||
10
requirements_all.txt
generated
10
requirements_all.txt
generated
@@ -1026,7 +1026,7 @@ gTTS==2.5.3
|
||||
|
||||
# homeassistant.components.gardena_bluetooth
|
||||
# homeassistant.components.husqvarna_automower_ble
|
||||
gardena-bluetooth==1.6.0
|
||||
gardena-bluetooth==2.1.0
|
||||
|
||||
# homeassistant.components.google_assistant_sdk
|
||||
gassist-text==0.0.14
|
||||
@@ -1176,7 +1176,7 @@ habluetooth==5.9.1
|
||||
hanna-cloud==0.0.7
|
||||
|
||||
# homeassistant.components.cloud
|
||||
hass-nabucasa==1.15.0
|
||||
hass-nabucasa==2.0.0
|
||||
|
||||
# homeassistant.components.splunk
|
||||
hass-splunk==0.1.4
|
||||
@@ -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
|
||||
|
||||
10
requirements_test_all.txt
generated
10
requirements_test_all.txt
generated
@@ -905,7 +905,7 @@ gTTS==2.5.3
|
||||
|
||||
# homeassistant.components.gardena_bluetooth
|
||||
# homeassistant.components.husqvarna_automower_ble
|
||||
gardena-bluetooth==1.6.0
|
||||
gardena-bluetooth==2.1.0
|
||||
|
||||
# homeassistant.components.google_assistant_sdk
|
||||
gassist-text==0.0.14
|
||||
@@ -1046,7 +1046,7 @@ habluetooth==5.9.1
|
||||
hanna-cloud==0.0.7
|
||||
|
||||
# homeassistant.components.cloud
|
||||
hass-nabucasa==1.15.0
|
||||
hass-nabucasa==2.0.0
|
||||
|
||||
# homeassistant.components.splunk
|
||||
hass-splunk==0.1.4
|
||||
@@ -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
|
||||
|
||||
@@ -122,7 +122,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = {
|
||||
# pyblackbird > pyserial-asyncio
|
||||
"pyblackbird": {"pyserial-asyncio"}
|
||||
},
|
||||
"cloud": {"hass-nabucasa": {"async-timeout"}, "snitun": {"async-timeout"}},
|
||||
"cmus": {
|
||||
# https://github.com/mtreinish/pycmus/issues/4
|
||||
# pycmus > pbr > setuptools
|
||||
|
||||
@@ -5619,10 +5619,10 @@
|
||||
# name: test_generate[None].6
|
||||
dict({
|
||||
'event': dict({
|
||||
'agent_id': 'backup.local',
|
||||
'manager_state': 'create_backup',
|
||||
'reason': None,
|
||||
'stage': None,
|
||||
'state': 'completed',
|
||||
'total_bytes': 10240,
|
||||
'uploaded_bytes': 10240,
|
||||
}),
|
||||
'id': 1,
|
||||
'type': 'event',
|
||||
@@ -5694,10 +5694,10 @@
|
||||
# name: test_generate[data1].6
|
||||
dict({
|
||||
'event': dict({
|
||||
'agent_id': 'backup.local',
|
||||
'manager_state': 'create_backup',
|
||||
'reason': None,
|
||||
'stage': None,
|
||||
'state': 'completed',
|
||||
'total_bytes': 10240,
|
||||
'uploaded_bytes': 10240,
|
||||
}),
|
||||
'id': 1,
|
||||
'type': 'event',
|
||||
@@ -5769,10 +5769,10 @@
|
||||
# name: test_generate[data2].6
|
||||
dict({
|
||||
'event': dict({
|
||||
'agent_id': 'backup.local',
|
||||
'manager_state': 'create_backup',
|
||||
'reason': None,
|
||||
'stage': None,
|
||||
'state': 'completed',
|
||||
'total_bytes': 10240,
|
||||
'uploaded_bytes': 10240,
|
||||
}),
|
||||
'id': 1,
|
||||
'type': 'event',
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from collections.abc import Callable, Generator
|
||||
from dataclasses import replace
|
||||
from datetime import timedelta
|
||||
from io import StringIO
|
||||
import json
|
||||
from pathlib import Path
|
||||
@@ -47,12 +48,14 @@ from homeassistant.components.backup.manager import (
|
||||
ReceiveBackupStage,
|
||||
ReceiveBackupState,
|
||||
RestoreBackupState,
|
||||
UploadBackupEvent,
|
||||
WrittenBackup,
|
||||
)
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STARTED
|
||||
from homeassistant.core import CoreState, HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .common import (
|
||||
LOCAL_AGENT_ID,
|
||||
@@ -65,6 +68,7 @@ from .common import (
|
||||
setup_backup_platform,
|
||||
)
|
||||
|
||||
from tests.common import async_fire_time_changed
|
||||
from tests.typing import ClientSessionGenerator, WebSocketGenerator
|
||||
|
||||
_EXPECTED_FILES = [
|
||||
@@ -596,7 +600,10 @@ async def test_initiate_backup(
|
||||
"state": CreateBackupState.IN_PROGRESS,
|
||||
}
|
||||
|
||||
# Consume any upload progress events before the final state event
|
||||
result = await ws_client.receive_json()
|
||||
while "uploaded_bytes" in result["event"]:
|
||||
result = await ws_client.receive_json()
|
||||
assert result["event"] == {
|
||||
"manager_state": BackupManagerState.CREATE_BACKUP,
|
||||
"reason": None,
|
||||
@@ -843,7 +850,10 @@ async def test_initiate_backup_with_agent_error(
|
||||
"state": CreateBackupState.IN_PROGRESS,
|
||||
}
|
||||
|
||||
# Consume any upload progress events before the final state event
|
||||
result = await ws_client.receive_json()
|
||||
while "uploaded_bytes" in result["event"]:
|
||||
result = await ws_client.receive_json()
|
||||
assert result["event"] == {
|
||||
"manager_state": BackupManagerState.CREATE_BACKUP,
|
||||
"reason": "upload_failed",
|
||||
@@ -1401,7 +1411,10 @@ async def test_initiate_backup_non_agent_upload_error(
|
||||
"state": CreateBackupState.IN_PROGRESS,
|
||||
}
|
||||
|
||||
# Consume any upload progress events before the final state event
|
||||
result = await ws_client.receive_json()
|
||||
while "uploaded_bytes" in result["event"]:
|
||||
result = await ws_client.receive_json()
|
||||
assert result["event"] == {
|
||||
"manager_state": BackupManagerState.CREATE_BACKUP,
|
||||
"reason": "upload_failed",
|
||||
@@ -1594,7 +1607,10 @@ async def test_initiate_backup_file_error_upload_to_agents(
|
||||
"state": CreateBackupState.IN_PROGRESS,
|
||||
}
|
||||
|
||||
# Consume any upload progress events before the final state event
|
||||
result = await ws_client.receive_json()
|
||||
while "uploaded_bytes" in result["event"]:
|
||||
result = await ws_client.receive_json()
|
||||
assert result["event"] == {
|
||||
"manager_state": BackupManagerState.CREATE_BACKUP,
|
||||
"reason": "upload_failed",
|
||||
@@ -2709,7 +2725,10 @@ async def test_receive_backup_file_read_error(
|
||||
"state": ReceiveBackupState.IN_PROGRESS,
|
||||
}
|
||||
|
||||
# Consume any upload progress events before the final state event
|
||||
result = await ws_client.receive_json()
|
||||
while "uploaded_bytes" in result["event"]:
|
||||
result = await ws_client.receive_json()
|
||||
assert result["event"] == {
|
||||
"manager_state": BackupManagerState.RECEIVE_BACKUP,
|
||||
"reason": final_state_reason,
|
||||
@@ -3526,7 +3545,10 @@ async def test_initiate_backup_per_agent_encryption(
|
||||
"state": CreateBackupState.IN_PROGRESS,
|
||||
}
|
||||
|
||||
# Consume any upload progress events before the final state event
|
||||
result = await ws_client.receive_json()
|
||||
while "uploaded_bytes" in result["event"]:
|
||||
result = await ws_client.receive_json()
|
||||
assert result["event"] == {
|
||||
"manager_state": BackupManagerState.CREATE_BACKUP,
|
||||
"reason": None,
|
||||
@@ -3761,25 +3783,114 @@ async def test_upload_progress_event(
|
||||
result = await ws_client.receive_json()
|
||||
assert result["event"]["stage"] == CreateBackupStage.UPLOAD_TO_AGENTS
|
||||
|
||||
# Upload progress events for the remote agent
|
||||
# Collect all upload progress events until the final state event
|
||||
progress_events = []
|
||||
result = await ws_client.receive_json()
|
||||
assert result["event"] == {
|
||||
"manager_state": BackupManagerState.CREATE_BACKUP,
|
||||
"agent_id": "test.remote",
|
||||
"uploaded_bytes": 500,
|
||||
"total_bytes": ANY,
|
||||
}
|
||||
while "uploaded_bytes" in result["event"]:
|
||||
progress_events.append(result["event"])
|
||||
result = await ws_client.receive_json()
|
||||
|
||||
result = await ws_client.receive_json()
|
||||
assert result["event"] == {
|
||||
"manager_state": BackupManagerState.CREATE_BACKUP,
|
||||
"agent_id": "test.remote",
|
||||
"uploaded_bytes": 1000,
|
||||
"total_bytes": ANY,
|
||||
}
|
||||
# Verify progress events from the remote agent (500 from agent + final from manager)
|
||||
remote_progress = [e for e in progress_events if e["agent_id"] == "test.remote"]
|
||||
assert len(remote_progress) == 2
|
||||
assert remote_progress[0]["uploaded_bytes"] == 500
|
||||
assert remote_progress[1]["uploaded_bytes"] == remote_progress[1]["total_bytes"]
|
||||
|
||||
# Verify progress event from the local agent (final from manager)
|
||||
local_progress = [e for e in progress_events if e["agent_id"] == LOCAL_AGENT_ID]
|
||||
assert len(local_progress) == 1
|
||||
assert local_progress[0]["uploaded_bytes"] == local_progress[0]["total_bytes"]
|
||||
|
||||
result = await ws_client.receive_json()
|
||||
assert result["event"]["state"] == CreateBackupState.COMPLETED
|
||||
|
||||
result = await ws_client.receive_json()
|
||||
assert result["event"] == {"manager_state": BackupManagerState.IDLE}
|
||||
|
||||
|
||||
async def test_upload_progress_debounced(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
generate_backup_id: MagicMock,
|
||||
) -> None:
|
||||
"""Test that rapid upload progress events are debounced.
|
||||
|
||||
Verify that when the on_progress callback is called multiple times during
|
||||
the debounce cooldown period, only the latest event is fired.
|
||||
"""
|
||||
agent_ids = ["test.remote"]
|
||||
mock_agents = await setup_backup_integration(hass, remote_agents=["test.remote"])
|
||||
manager = hass.data[DATA_MANAGER]
|
||||
|
||||
remote_agent = mock_agents["test.remote"]
|
||||
|
||||
progress_done = asyncio.Event()
|
||||
upload_done = asyncio.Event()
|
||||
|
||||
async def upload_with_progress(**kwargs: Any) -> None:
|
||||
"""Upload and report progress."""
|
||||
on_progress = kwargs["on_progress"]
|
||||
# First call fires immediately
|
||||
on_progress(bytes_uploaded=100)
|
||||
# These two are buffered during cooldown; 1000 should replace 500
|
||||
on_progress(bytes_uploaded=500)
|
||||
on_progress(bytes_uploaded=1000)
|
||||
progress_done.set()
|
||||
await upload_done.wait()
|
||||
|
||||
remote_agent.async_upload_backup.side_effect = upload_with_progress
|
||||
|
||||
# Subscribe directly to collect all events
|
||||
events: list[Any] = []
|
||||
manager.async_subscribe_events(events.append)
|
||||
|
||||
ws_client = await hass_ws_client(hass)
|
||||
|
||||
with patch("pathlib.Path.open", mock_open(read_data=b"test")):
|
||||
await ws_client.send_json_auto_id(
|
||||
{"type": "backup/generate", "agent_ids": agent_ids}
|
||||
)
|
||||
result = await ws_client.receive_json()
|
||||
assert result["success"] is True
|
||||
|
||||
# Wait for upload to reach the sync point (progress reported, upload paused)
|
||||
await progress_done.wait()
|
||||
|
||||
# At this point the debouncer's cooldown timer is pending.
|
||||
# The first event (100 bytes) fired immediately, 500 and 1000 are buffered.
|
||||
remote_events = [
|
||||
e
|
||||
for e in events
|
||||
if isinstance(e, UploadBackupEvent) and e.agent_id == "test.remote"
|
||||
]
|
||||
assert len(remote_events) == 1
|
||||
assert remote_events[0].uploaded_bytes == 100
|
||||
|
||||
# Advance time past the cooldown to trigger the debouncer timer.
|
||||
# This fires the coalesced event: 500 was replaced by 1000.
|
||||
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2))
|
||||
|
||||
remote_events = [
|
||||
e
|
||||
for e in events
|
||||
if isinstance(e, UploadBackupEvent) and e.agent_id == "test.remote"
|
||||
]
|
||||
assert len(remote_events) == 2
|
||||
assert remote_events[0].uploaded_bytes == 100
|
||||
assert remote_events[1].uploaded_bytes == 1000
|
||||
|
||||
# Let the upload finish
|
||||
upload_done.set()
|
||||
# Fire pending timers so the backup task can complete
|
||||
async_fire_time_changed(
|
||||
hass, dt_util.utcnow() + timedelta(seconds=10), fire_all=True
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Check the final 100% progress event is sent, that is sent for every agent
|
||||
remote_events = [
|
||||
e
|
||||
for e in events
|
||||
if isinstance(e, UploadBackupEvent) and e.agent_id == "test.remote"
|
||||
]
|
||||
assert len(remote_events) == 3
|
||||
assert remote_events[2].uploaded_bytes == remote_events[2].total_bytes
|
||||
|
||||
101
tests/components/eheimdigital/snapshots/test_binary_sensor.ambr
Normal file
101
tests/components/eheimdigital/snapshots/test_binary_sensor.ambr
Normal file
@@ -0,0 +1,101 @@
|
||||
# serializer version: 1
|
||||
# name: test_setup[binary_sensor.mock_reeflex_light-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'binary_sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'binary_sensor.mock_reeflex_light',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Light',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <BinarySensorDeviceClass.LIGHT: 'light'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Light',
|
||||
'platform': 'eheimdigital',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'is_lighting',
|
||||
'unique_id': '00:00:00:00:00:05_is_lighting',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_setup[binary_sensor.mock_reeflex_light-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'light',
|
||||
'friendly_name': 'Mock reeflex Light',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.mock_reeflex_light',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_setup[binary_sensor.mock_reeflex_uvc_lamp_connected-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'binary_sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'binary_sensor.mock_reeflex_uvc_lamp_connected',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'UVC lamp connected',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <BinarySensorDeviceClass.CONNECTIVITY: 'connectivity'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'UVC lamp connected',
|
||||
'platform': 'eheimdigital',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'is_uvc_connected',
|
||||
'unique_id': '00:00:00:00:00:05_is_uvc_connected',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_setup[binary_sensor.mock_reeflex_uvc_lamp_connected-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'connectivity',
|
||||
'friendly_name': 'Mock reeflex UVC lamp connected',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.mock_reeflex_uvc_lamp_connected',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
109
tests/components/eheimdigital/test_binary_sensor.py
Normal file
109
tests/components/eheimdigital/test_binary_sensor.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Tests for the binary sensor module."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from .conftest import init_integration
|
||||
|
||||
from tests.common import MockConfigEntry, get_sensor_display_state, snapshot_platform
|
||||
|
||||
|
||||
async def test_setup(
|
||||
hass: HomeAssistant,
|
||||
eheimdigital_hub_mock: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test binary sensor platform setup."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.eheimdigital.PLATFORMS", [Platform.BINARY_SENSOR]
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.eheimdigital.coordinator.asyncio.Event",
|
||||
new=AsyncMock,
|
||||
),
|
||||
):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
|
||||
for device in eheimdigital_hub_mock.return_value.devices:
|
||||
await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"](
|
||||
device, eheimdigital_hub_mock.return_value.devices[device].device_type
|
||||
)
|
||||
await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]()
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("device_name", "entity_list"),
|
||||
[
|
||||
(
|
||||
"reeflex_mock",
|
||||
[
|
||||
(
|
||||
"binary_sensor.mock_reeflex_light",
|
||||
"reeflex_data",
|
||||
"isLighting",
|
||||
True,
|
||||
"on",
|
||||
),
|
||||
(
|
||||
"binary_sensor.mock_reeflex_light",
|
||||
"reeflex_data",
|
||||
"isLighting",
|
||||
False,
|
||||
"off",
|
||||
),
|
||||
(
|
||||
"binary_sensor.mock_reeflex_uvc_lamp_connected",
|
||||
"reeflex_data",
|
||||
"isUVCConnected",
|
||||
True,
|
||||
"on",
|
||||
),
|
||||
(
|
||||
"binary_sensor.mock_reeflex_uvc_lamp_connected",
|
||||
"reeflex_data",
|
||||
"isUVCConnected",
|
||||
False,
|
||||
"off",
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_state_update(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
eheimdigital_hub_mock: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
device_name: str,
|
||||
entity_list: list[tuple[str, str, str, bool | int, str]],
|
||||
request: pytest.FixtureRequest,
|
||||
) -> None:
|
||||
"""Test the binary sensor state update."""
|
||||
device: MagicMock = request.getfixturevalue(device_name)
|
||||
await init_integration(hass, mock_config_entry)
|
||||
|
||||
await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"](
|
||||
device.mac_address, device.device_type
|
||||
)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
for item in entity_list:
|
||||
getattr(device, item[1])[item[2]] = item[3]
|
||||
await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]()
|
||||
assert get_sensor_display_state(hass, entity_registry, item[0]) == str(item[4])
|
||||
@@ -8,7 +8,7 @@ from freezegun.api import FrozenDateTimeFactory
|
||||
from gardena_bluetooth.client import Client
|
||||
from gardena_bluetooth.const import DeviceInformation
|
||||
from gardena_bluetooth.exceptions import CharacteristicNotFound
|
||||
from gardena_bluetooth.parse import Characteristic
|
||||
from gardena_bluetooth.parse import Characteristic, Service
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.gardena_bluetooth.const import DOMAIN
|
||||
@@ -83,7 +83,7 @@ def mock_client(
|
||||
) -> Generator[Mock]:
|
||||
"""Auto mock bluetooth."""
|
||||
|
||||
client = Mock(spec_set=Client)
|
||||
client_class = Mock()
|
||||
|
||||
SENTINEL = object()
|
||||
|
||||
@@ -106,19 +106,32 @@ def mock_client(
|
||||
return default
|
||||
return val
|
||||
|
||||
def _all_char():
|
||||
def _all_char_uuid():
|
||||
return set(mock_read_char_raw.keys())
|
||||
|
||||
def _all_char():
|
||||
product_type = client_class.call_args.args[1]
|
||||
services = Service.services_for_product_type(product_type)
|
||||
return {
|
||||
char.unique_id: char
|
||||
for service in services
|
||||
for char in service.characteristics.values()
|
||||
if char.uuid in mock_read_char_raw
|
||||
}
|
||||
|
||||
client = Mock(spec_set=Client)
|
||||
client.read_char.side_effect = _read_char
|
||||
client.read_char_raw.side_effect = _read_char_raw
|
||||
client.get_all_characteristics_uuid.side_effect = _all_char
|
||||
client.get_all_characteristics_uuid.side_effect = _all_char_uuid
|
||||
client.get_all_characteristics.side_effect = _all_char
|
||||
client_class.return_value = client
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.gardena_bluetooth.config_flow.Client",
|
||||
return_value=client,
|
||||
new=client_class,
|
||||
),
|
||||
patch("homeassistant.components.gardena_bluetooth.Client", return_value=client),
|
||||
patch("homeassistant.components.gardena_bluetooth.Client", new=client_class),
|
||||
):
|
||||
yield client
|
||||
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
"""Test the Gardena Bluetooth setup."""
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
from unittest.mock import Mock
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from gardena_bluetooth.const import Battery
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.gardena_bluetooth import DeviceUnavailable
|
||||
from homeassistant.components.gardena_bluetooth.const import DOMAIN
|
||||
from homeassistant.components.gardena_bluetooth.util import (
|
||||
async_get_product_type as original_get_product_type,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.util import utcnow
|
||||
|
||||
from . import WATER_TIMER_SERVICE_INFO
|
||||
from . import MISSING_MANUFACTURER_DATA_SERVICE_INFO, WATER_TIMER_SERVICE_INFO
|
||||
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||
from tests.components.bluetooth import inject_bluetooth_service_info
|
||||
|
||||
|
||||
async def test_setup(
|
||||
@@ -28,12 +33,10 @@ async def test_setup(
|
||||
"""Test setup creates expected devices."""
|
||||
|
||||
mock_read_char_raw[Battery.battery_level.uuid] = Battery.battery_level.encode(100)
|
||||
inject_bluetooth_service_info(hass, WATER_TIMER_SERVICE_INFO)
|
||||
|
||||
mock_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_entry.state is ConfigEntryState.LOADED
|
||||
assert await hass.config_entries.async_setup(mock_entry.entry_id) is True
|
||||
|
||||
device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, WATER_TIMER_SERVICE_INFO.address)}
|
||||
@@ -41,11 +44,49 @@ async def test_setup(
|
||||
assert device == snapshot
|
||||
|
||||
|
||||
async def test_setup_delayed_product(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
mock_entry: MockConfigEntry,
|
||||
mock_read_char_raw: dict[str, bytes],
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test setup creates expected devices."""
|
||||
|
||||
mock_read_char_raw[Battery.battery_level.uuid] = Battery.battery_level.encode(100)
|
||||
|
||||
mock_entry.add_to_hass(hass)
|
||||
|
||||
event = asyncio.Event()
|
||||
|
||||
async def _get_product_type(*args, **kwargs):
|
||||
event.set()
|
||||
return await original_get_product_type(*args, **kwargs)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.gardena_bluetooth.async_get_product_type",
|
||||
wraps=_get_product_type,
|
||||
):
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
setup_task = tg.create_task(
|
||||
hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
)
|
||||
|
||||
await event.wait()
|
||||
assert mock_entry.state is ConfigEntryState.SETUP_IN_PROGRESS
|
||||
inject_bluetooth_service_info(hass, MISSING_MANUFACTURER_DATA_SERVICE_INFO)
|
||||
inject_bluetooth_service_info(hass, WATER_TIMER_SERVICE_INFO)
|
||||
|
||||
assert await setup_task is True
|
||||
|
||||
|
||||
async def test_setup_retry(
|
||||
hass: HomeAssistant, mock_entry: MockConfigEntry, mock_client: Mock
|
||||
) -> None:
|
||||
"""Test setup creates expected devices."""
|
||||
|
||||
inject_bluetooth_service_info(hass, WATER_TIMER_SERVICE_INFO)
|
||||
|
||||
original_read_char = mock_client.read_char.side_effect
|
||||
mock_client.read_char.side_effect = DeviceUnavailable
|
||||
mock_entry.add_to_hass(hass)
|
||||
|
||||
@@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable, Coroutine
|
||||
import http
|
||||
import time
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from freezegun import freeze_time
|
||||
from gspread.exceptions import APIError
|
||||
@@ -29,7 +29,12 @@ from homeassistant.components.google_sheets.services import (
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.exceptions import (
|
||||
HomeAssistantError,
|
||||
OAuth2TokenRequestReauthError,
|
||||
OAuth2TokenRequestTransientError,
|
||||
ServiceValidationError,
|
||||
)
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
@@ -199,6 +204,64 @@ async def test_expired_token_refresh_failure(
|
||||
assert entries[0].state is expected_state
|
||||
|
||||
|
||||
async def test_setup_oauth_reauth_error(
|
||||
hass: HomeAssistant, config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test a token refresh reauth error puts the config entry in setup error state."""
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
assert await async_setup_component(hass, APPLICATION_CREDENTIALS_DOMAIN, {})
|
||||
await async_import_client_credential(
|
||||
hass,
|
||||
DOMAIN,
|
||||
ClientCredential("client-id", "client-secret"),
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(config_entry, "async_start_reauth") as mock_async_start_reauth,
|
||||
patch(
|
||||
"homeassistant.components.google_sheets.OAuth2Session.async_ensure_token_valid",
|
||||
side_effect=OAuth2TokenRequestReauthError(
|
||||
domain=DOMAIN, request_info=Mock()
|
||||
),
|
||||
),
|
||||
):
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert config_entry.state is ConfigEntryState.SETUP_ERROR
|
||||
mock_async_start_reauth.assert_called_once_with(hass)
|
||||
|
||||
|
||||
async def test_setup_oauth_transient_error(
|
||||
hass: HomeAssistant, config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test a token refresh transient error sets the config entry to retry setup."""
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
assert await async_setup_component(hass, APPLICATION_CREDENTIALS_DOMAIN, {})
|
||||
await async_import_client_credential(
|
||||
hass,
|
||||
DOMAIN,
|
||||
ClientCredential("client-id", "client-secret"),
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.google_sheets.OAuth2Session.async_ensure_token_valid",
|
||||
side_effect=OAuth2TokenRequestTransientError(
|
||||
domain=DOMAIN, request_info=Mock()
|
||||
),
|
||||
):
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("add_created_column_param", "expected_row"),
|
||||
[
|
||||
|
||||
170
tests/components/growatt_server/snapshots/test_config_flow.ambr
Normal file
170
tests/components/growatt_server/snapshots/test_config_flow.ambr
Normal file
@@ -0,0 +1,170 @@
|
||||
# serializer version: 1
|
||||
# name: test_reauth_password_error_then_recovery[None-login_return_value0]
|
||||
FlowResultSnapshot({
|
||||
'description_placeholders': dict({
|
||||
'name': 'Mock Title',
|
||||
}),
|
||||
'errors': dict({
|
||||
'base': 'invalid_auth',
|
||||
}),
|
||||
'flow_id': <ANY>,
|
||||
'handler': 'growatt_server',
|
||||
'last_step': None,
|
||||
'preview': None,
|
||||
'step_id': 'reauth_confirm',
|
||||
'type': <FlowResultType.FORM: 'form'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_reauth_password_error_then_recovery[login_side_effect1-None]
|
||||
FlowResultSnapshot({
|
||||
'description_placeholders': dict({
|
||||
'name': 'Mock Title',
|
||||
}),
|
||||
'errors': dict({
|
||||
'base': 'cannot_connect',
|
||||
}),
|
||||
'flow_id': <ANY>,
|
||||
'handler': 'growatt_server',
|
||||
'last_step': None,
|
||||
'preview': None,
|
||||
'step_id': 'reauth_confirm',
|
||||
'type': <FlowResultType.FORM: 'form'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_reauth_password_exception
|
||||
dict({
|
||||
'auth_type': 'password',
|
||||
'name': 'Test Plant',
|
||||
'password': 'password',
|
||||
'plant_id': '123456',
|
||||
'url': 'https://openapi.growatt.com/',
|
||||
'username': 'username',
|
||||
})
|
||||
# ---
|
||||
# name: test_reauth_password_non_auth_login_failure
|
||||
dict({
|
||||
'auth_type': 'password',
|
||||
'name': 'Test Plant',
|
||||
'password': 'password',
|
||||
'plant_id': '123456',
|
||||
'url': 'https://openapi.growatt.com/',
|
||||
'username': 'username',
|
||||
})
|
||||
# ---
|
||||
# name: test_reauth_password_success[https://openapi-us.growatt.com/-user_input1-north_america]
|
||||
FlowResultSnapshot({
|
||||
'description_placeholders': dict({
|
||||
'name': 'Mock Title',
|
||||
}),
|
||||
'errors': dict({
|
||||
}),
|
||||
'flow_id': <ANY>,
|
||||
'handler': 'growatt_server',
|
||||
'last_step': None,
|
||||
'preview': None,
|
||||
'step_id': 'reauth_confirm',
|
||||
'type': <FlowResultType.FORM: 'form'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_reauth_password_success[https://openapi-us.growatt.com/-user_input1-north_america].1
|
||||
dict({
|
||||
'auth_type': 'password',
|
||||
'name': 'Test Plant',
|
||||
'password': 'password',
|
||||
'plant_id': '123456',
|
||||
'url': 'https://openapi-us.growatt.com/',
|
||||
'username': 'username',
|
||||
})
|
||||
# ---
|
||||
# name: test_reauth_password_success[https://openapi.growatt.com/-user_input0-other_regions]
|
||||
FlowResultSnapshot({
|
||||
'description_placeholders': dict({
|
||||
'name': 'Mock Title',
|
||||
}),
|
||||
'errors': dict({
|
||||
}),
|
||||
'flow_id': <ANY>,
|
||||
'handler': 'growatt_server',
|
||||
'last_step': None,
|
||||
'preview': None,
|
||||
'step_id': 'reauth_confirm',
|
||||
'type': <FlowResultType.FORM: 'form'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_reauth_password_success[https://openapi.growatt.com/-user_input0-other_regions].1
|
||||
dict({
|
||||
'auth_type': 'password',
|
||||
'name': 'Test Plant',
|
||||
'password': 'password',
|
||||
'plant_id': '123456',
|
||||
'url': 'https://openapi.growatt.com/',
|
||||
'username': 'username',
|
||||
})
|
||||
# ---
|
||||
# name: test_reauth_token_error_then_recovery[plant_list_side_effect0]
|
||||
FlowResultSnapshot({
|
||||
'description_placeholders': dict({
|
||||
'name': 'Mock Title',
|
||||
}),
|
||||
'errors': dict({
|
||||
'base': 'invalid_auth',
|
||||
}),
|
||||
'flow_id': <ANY>,
|
||||
'handler': 'growatt_server',
|
||||
'last_step': None,
|
||||
'preview': None,
|
||||
'step_id': 'reauth_confirm',
|
||||
'type': <FlowResultType.FORM: 'form'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_reauth_token_error_then_recovery[plant_list_side_effect1]
|
||||
FlowResultSnapshot({
|
||||
'description_placeholders': dict({
|
||||
'name': 'Mock Title',
|
||||
}),
|
||||
'errors': dict({
|
||||
'base': 'cannot_connect',
|
||||
}),
|
||||
'flow_id': <ANY>,
|
||||
'handler': 'growatt_server',
|
||||
'last_step': None,
|
||||
'preview': None,
|
||||
'step_id': 'reauth_confirm',
|
||||
'type': <FlowResultType.FORM: 'form'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_reauth_token_exception
|
||||
dict({
|
||||
'auth_type': 'api_token',
|
||||
'name': 'Test Plant',
|
||||
'plant_id': '123456',
|
||||
'token': 'test_api_token_12345',
|
||||
'url': 'https://openapi.growatt.com/',
|
||||
'user_id': '12345',
|
||||
})
|
||||
# ---
|
||||
# name: test_reauth_token_success
|
||||
FlowResultSnapshot({
|
||||
'description_placeholders': dict({
|
||||
'name': 'Mock Title',
|
||||
}),
|
||||
'errors': dict({
|
||||
}),
|
||||
'flow_id': <ANY>,
|
||||
'handler': 'growatt_server',
|
||||
'last_step': None,
|
||||
'preview': None,
|
||||
'step_id': 'reauth_confirm',
|
||||
'type': <FlowResultType.FORM: 'form'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_reauth_token_success.1
|
||||
dict({
|
||||
'auth_type': 'api_token',
|
||||
'name': 'Test Plant',
|
||||
'plant_id': '123456',
|
||||
'token': 'test_api_token_12345',
|
||||
'url': 'https://openapi.growatt.com/',
|
||||
'user_id': '12345',
|
||||
})
|
||||
# ---
|
||||
@@ -5,6 +5,9 @@ from copy import deepcopy
|
||||
import growattServer
|
||||
import pytest
|
||||
import requests
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
from syrupy.filters import props
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.growatt_server.const import (
|
||||
@@ -19,8 +22,17 @@ from homeassistant.components.growatt_server.const import (
|
||||
ERROR_CANNOT_CONNECT,
|
||||
ERROR_INVALID_AUTH,
|
||||
LOGIN_INVALID_AUTH_CODE,
|
||||
SERVER_URLS_NAMES,
|
||||
V1_API_ERROR_NO_PRIVILEGE,
|
||||
V1_API_ERROR_RATE_LIMITED,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
CONF_TOKEN,
|
||||
CONF_URL,
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
@@ -299,11 +311,21 @@ async def test_password_auth_multiple_plants(
|
||||
# Token authentication tests
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("error_code", "expected_error"),
|
||||
[
|
||||
(V1_API_ERROR_NO_PRIVILEGE, ERROR_INVALID_AUTH),
|
||||
(V1_API_ERROR_RATE_LIMITED, ERROR_CANNOT_CONNECT),
|
||||
],
|
||||
)
|
||||
async def test_token_auth_api_error(
|
||||
hass: HomeAssistant, mock_growatt_v1_api, mock_setup_entry
|
||||
hass: HomeAssistant,
|
||||
mock_growatt_v1_api,
|
||||
mock_setup_entry,
|
||||
error_code: int,
|
||||
expected_error: str,
|
||||
) -> None:
|
||||
"""Test token authentication with API error, then recovery."""
|
||||
|
||||
"""Test token authentication with V1 API error maps to correct error type."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
@@ -312,9 +334,8 @@ async def test_token_auth_api_error(
|
||||
result["flow_id"], {"next_step_id": "token_auth"}
|
||||
)
|
||||
|
||||
# Any GrowattV1ApiError during token verification should result in invalid_auth
|
||||
error = growattServer.GrowattV1ApiError("API error")
|
||||
error.error_code = 100
|
||||
error.error_code = error_code
|
||||
mock_growatt_v1_api.plant_list.side_effect = error
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
@@ -323,9 +344,9 @@ async def test_token_auth_api_error(
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "token_auth"
|
||||
assert result["errors"] == {"base": ERROR_INVALID_AUTH}
|
||||
assert result["errors"] == {"base": expected_error}
|
||||
|
||||
# Test recovery - reset side_effect and set normal return value
|
||||
# Test recovery
|
||||
mock_growatt_v1_api.plant_list.side_effect = None
|
||||
mock_growatt_v1_api.plant_list.return_value = GROWATT_V1_PLANT_LIST_RESPONSE
|
||||
|
||||
@@ -671,3 +692,362 @@ async def test_password_auth_plant_list_invalid_format(
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == ERROR_CANNOT_CONNECT
|
||||
|
||||
|
||||
# Reauthentication flow tests
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("stored_url", "user_input", "expected_region"),
|
||||
[
|
||||
(
|
||||
SERVER_URLS_NAMES["other_regions"],
|
||||
FIXTURE_USER_INPUT_PASSWORD,
|
||||
"other_regions",
|
||||
),
|
||||
(
|
||||
SERVER_URLS_NAMES["north_america"],
|
||||
{
|
||||
CONF_USERNAME: "username",
|
||||
CONF_PASSWORD: "password",
|
||||
CONF_REGION: "north_america",
|
||||
},
|
||||
"north_america",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_reauth_password_success(
|
||||
hass: HomeAssistant,
|
||||
mock_growatt_classic_api,
|
||||
snapshot: SnapshotAssertion,
|
||||
stored_url: str,
|
||||
user_input: dict,
|
||||
expected_region: str,
|
||||
) -> None:
|
||||
"""Test successful reauthentication with password auth for default and non-default regions."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_AUTH_TYPE: AUTH_PASSWORD,
|
||||
CONF_USERNAME: "test_user",
|
||||
CONF_PASSWORD: "test_password",
|
||||
CONF_URL: stored_url,
|
||||
CONF_PLANT_ID: "123456",
|
||||
"name": "Test Plant",
|
||||
},
|
||||
unique_id="123456",
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await entry.start_reauth_flow(hass)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert result == snapshot(exclude=props("data_schema"))
|
||||
region_key = next(
|
||||
k
|
||||
for k in result["data_schema"].schema
|
||||
if isinstance(k, vol.Required) and k.schema == CONF_REGION
|
||||
)
|
||||
assert region_key.default() == expected_region
|
||||
|
||||
mock_growatt_classic_api.login.return_value = GROWATT_LOGIN_RESPONSE
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
assert entry.data == snapshot
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("login_side_effect", "login_return_value"),
|
||||
[
|
||||
(
|
||||
None,
|
||||
{"msg": LOGIN_INVALID_AUTH_CODE, "success": False},
|
||||
),
|
||||
(
|
||||
requests.exceptions.ConnectionError("Connection failed"),
|
||||
None,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_reauth_password_error_then_recovery(
|
||||
hass: HomeAssistant,
|
||||
mock_growatt_classic_api,
|
||||
mock_config_entry_classic: MockConfigEntry,
|
||||
snapshot: SnapshotAssertion,
|
||||
login_side_effect: Exception | None,
|
||||
login_return_value: dict | None,
|
||||
) -> None:
|
||||
"""Test password reauth shows error then allows recovery."""
|
||||
mock_config_entry_classic.add_to_hass(hass)
|
||||
|
||||
result = await mock_config_entry_classic.start_reauth_flow(hass)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
|
||||
mock_growatt_classic_api.login.side_effect = login_side_effect
|
||||
if login_return_value is not None:
|
||||
mock_growatt_classic_api.login.return_value = login_return_value
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert result == snapshot(exclude=props("data_schema"))
|
||||
|
||||
# Recover with correct credentials
|
||||
mock_growatt_classic_api.login.side_effect = None
|
||||
mock_growatt_classic_api.login.return_value = GROWATT_LOGIN_RESPONSE
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
|
||||
|
||||
async def test_reauth_token_success(
|
||||
hass: HomeAssistant,
|
||||
mock_growatt_v1_api,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test successful reauthentication with token auth."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await mock_config_entry.start_reauth_flow(hass)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert result == snapshot(exclude=props("data_schema"))
|
||||
|
||||
mock_growatt_v1_api.plant_list.return_value = GROWATT_V1_PLANT_LIST_RESPONSE
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
assert mock_config_entry.data == snapshot
|
||||
|
||||
|
||||
def _make_no_privilege_error() -> growattServer.GrowattV1ApiError:
|
||||
error = growattServer.GrowattV1ApiError("No privilege access")
|
||||
error.error_code = V1_API_ERROR_NO_PRIVILEGE
|
||||
return error
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"plant_list_side_effect",
|
||||
[
|
||||
_make_no_privilege_error(),
|
||||
requests.exceptions.ConnectionError("Network error"),
|
||||
],
|
||||
)
|
||||
async def test_reauth_token_error_then_recovery(
|
||||
hass: HomeAssistant,
|
||||
mock_growatt_v1_api,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
snapshot: SnapshotAssertion,
|
||||
plant_list_side_effect: Exception,
|
||||
) -> None:
|
||||
"""Test token reauth shows error then allows recovery."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await mock_config_entry.start_reauth_flow(hass)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
|
||||
mock_growatt_v1_api.plant_list.side_effect = plant_list_side_effect
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert result == snapshot(exclude=props("data_schema"))
|
||||
|
||||
# Recover with a valid token
|
||||
mock_growatt_v1_api.plant_list.side_effect = None
|
||||
mock_growatt_v1_api.plant_list.return_value = GROWATT_V1_PLANT_LIST_RESPONSE
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
|
||||
|
||||
async def test_reauth_token_non_auth_api_error(
|
||||
hass: HomeAssistant,
|
||||
mock_growatt_v1_api,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test reauth token with non-auth V1 API error (e.g. rate limit) shows cannot_connect."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await mock_config_entry.start_reauth_flow(hass)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
|
||||
error = growattServer.GrowattV1ApiError("Rate limit exceeded")
|
||||
error.error_code = V1_API_ERROR_RATE_LIMITED
|
||||
mock_growatt_v1_api.plant_list.side_effect = error
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert result["errors"] == {"base": ERROR_CANNOT_CONNECT}
|
||||
|
||||
|
||||
async def test_reauth_password_invalid_response(
|
||||
hass: HomeAssistant,
|
||||
mock_growatt_classic_api,
|
||||
mock_config_entry_classic: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test reauth password flow with non-dict login response, then recovery."""
|
||||
mock_config_entry_classic.add_to_hass(hass)
|
||||
result = await mock_config_entry_classic.start_reauth_flow(hass)
|
||||
|
||||
mock_growatt_classic_api.login.return_value = "not_a_dict"
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert result["errors"] == {"base": ERROR_CANNOT_CONNECT}
|
||||
|
||||
# Recover with correct credentials
|
||||
mock_growatt_classic_api.login.return_value = GROWATT_LOGIN_RESPONSE
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
|
||||
|
||||
async def test_reauth_password_non_auth_login_failure(
|
||||
hass: HomeAssistant,
|
||||
mock_growatt_classic_api,
|
||||
mock_config_entry_classic: MockConfigEntry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test reauth password flow when login fails with a non-auth error."""
|
||||
mock_config_entry_classic.add_to_hass(hass)
|
||||
result = await mock_config_entry_classic.start_reauth_flow(hass)
|
||||
|
||||
mock_growatt_classic_api.login.return_value = {
|
||||
"success": False,
|
||||
"msg": "server_maintenance",
|
||||
}
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert result["errors"] == {"base": ERROR_CANNOT_CONNECT}
|
||||
|
||||
# Recover with correct credentials
|
||||
mock_growatt_classic_api.login.return_value = GROWATT_LOGIN_RESPONSE
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
assert mock_config_entry_classic.data == snapshot
|
||||
|
||||
|
||||
async def test_reauth_password_exception(
|
||||
hass: HomeAssistant,
|
||||
mock_growatt_classic_api,
|
||||
mock_config_entry_classic: MockConfigEntry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test reauth password flow with unexpected exception from login, then recovery."""
|
||||
mock_config_entry_classic.add_to_hass(hass)
|
||||
result = await mock_config_entry_classic.start_reauth_flow(hass)
|
||||
|
||||
mock_growatt_classic_api.login.side_effect = ValueError("Unexpected error")
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert result["errors"] == {"base": ERROR_CANNOT_CONNECT}
|
||||
|
||||
# Recover with correct credentials
|
||||
mock_growatt_classic_api.login.side_effect = None
|
||||
mock_growatt_classic_api.login.return_value = GROWATT_LOGIN_RESPONSE
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
assert mock_config_entry_classic.data == snapshot
|
||||
|
||||
|
||||
async def test_reauth_token_exception(
|
||||
hass: HomeAssistant,
|
||||
mock_growatt_v1_api,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test reauth token flow with unexpected exception from plant_list, then recovery."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
result = await mock_config_entry.start_reauth_flow(hass)
|
||||
|
||||
mock_growatt_v1_api.plant_list.side_effect = ValueError("Unexpected error")
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert result["errors"] == {"base": ERROR_CANNOT_CONNECT}
|
||||
|
||||
# Recover with a valid token
|
||||
mock_growatt_v1_api.plant_list.side_effect = None
|
||||
mock_growatt_v1_api.plant_list.return_value = GROWATT_V1_PLANT_LIST_RESPONSE
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
assert mock_config_entry.data == snapshot
|
||||
|
||||
|
||||
async def test_reauth_unknown_auth_type(hass: HomeAssistant) -> None:
|
||||
"""Test reauth aborts immediately when the config entry has an unknown auth type."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_AUTH_TYPE: "unknown_type",
|
||||
"plant_id": "123456",
|
||||
"name": "Test Plant",
|
||||
},
|
||||
unique_id="123456",
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
# The flow aborts immediately without showing a form
|
||||
result = await entry.start_reauth_flow(hass)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == ERROR_CANNOT_CONNECT
|
||||
|
||||
@@ -18,6 +18,9 @@ from homeassistant.components.growatt_server.const import (
|
||||
CONF_PLANT_ID,
|
||||
DEFAULT_PLANT_ID,
|
||||
DOMAIN,
|
||||
LOGIN_INVALID_AUTH_CODE,
|
||||
V1_API_ERROR_NO_PRIVILEGE,
|
||||
V1_API_ERROR_RATE_LIMITED,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import (
|
||||
@@ -26,6 +29,7 @@ from homeassistant.const import (
|
||||
CONF_TOKEN,
|
||||
CONF_URL,
|
||||
CONF_USERNAME,
|
||||
STATE_UNAVAILABLE,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
@@ -110,6 +114,149 @@ async def test_coordinator_update_failed(
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_coordinator_update_json_error(
|
||||
hass: HomeAssistant,
|
||||
mock_growatt_v1_api,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test coordinator handles JSONDecodeError gracefully."""
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
mock_growatt_v1_api.min_detail.side_effect = json.decoder.JSONDecodeError(
|
||||
"Invalid JSON", "", 0
|
||||
)
|
||||
|
||||
freezer.tick(timedelta(minutes=5))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
assert (
|
||||
hass.states.get("switch.min123456_charge_from_grid").state == STATE_UNAVAILABLE
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_coordinator_total_non_auth_api_error(
|
||||
hass: HomeAssistant,
|
||||
mock_growatt_v1_api,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test total coordinator handles non-auth V1 API errors as UpdateFailed."""
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
error = growattServer.GrowattV1ApiError("Rate limited")
|
||||
error.error_code = V1_API_ERROR_RATE_LIMITED
|
||||
mock_growatt_v1_api.plant_energy_overview.side_effect = error
|
||||
|
||||
freezer.tick(timedelta(minutes=5))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
# Should stay loaded (UpdateFailed is transient), no reauth flow started
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
flows = hass.config_entries.flow.async_progress()
|
||||
assert not any(flow["context"]["source"] == "reauth" for flow in flows)
|
||||
|
||||
|
||||
async def test_setup_auth_failed_on_permission_denied(
|
||||
hass: HomeAssistant,
|
||||
mock_growatt_v1_api,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test that error 10011 (no privilege) from device_list triggers reauth during setup."""
|
||||
error = growattServer.GrowattV1ApiError("Permission denied")
|
||||
error.error_code = V1_API_ERROR_NO_PRIVILEGE
|
||||
mock_growatt_v1_api.device_list.side_effect = error
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
|
||||
# Verify a reauth flow was started
|
||||
flows = hass.config_entries.flow.async_progress()
|
||||
assert any(
|
||||
flow["context"]["source"] == "reauth"
|
||||
and flow["context"]["entry_id"] == mock_config_entry.entry_id
|
||||
for flow in flows
|
||||
)
|
||||
|
||||
|
||||
async def test_coordinator_auth_failed_triggers_reauth(
|
||||
hass: HomeAssistant,
|
||||
mock_growatt_v1_api,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test that error 10011 (no privilege) from coordinator update triggers reauth."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
error = growattServer.GrowattV1ApiError("Permission denied")
|
||||
error.error_code = V1_API_ERROR_NO_PRIVILEGE
|
||||
mock_growatt_v1_api.min_detail.side_effect = error
|
||||
|
||||
freezer.tick(timedelta(minutes=5))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
# Verify a reauth flow was started
|
||||
flows = hass.config_entries.flow.async_progress()
|
||||
assert any(
|
||||
flow["context"]["source"] == "reauth"
|
||||
and flow["context"]["entry_id"] == mock_config_entry.entry_id
|
||||
for flow in flows
|
||||
)
|
||||
assert (
|
||||
hass.states.get("switch.min123456_charge_from_grid").state == STATE_UNAVAILABLE
|
||||
)
|
||||
|
||||
|
||||
async def test_classic_api_coordinator_auth_failed_triggers_reauth(
|
||||
hass: HomeAssistant,
|
||||
mock_growatt_classic_api,
|
||||
mock_config_entry_classic: MockConfigEntry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test that invalid classic API credentials during coordinator update trigger reauth."""
|
||||
mock_growatt_classic_api.device_list.return_value = [
|
||||
{"deviceSn": "TLX123456", "deviceType": "tlx"}
|
||||
]
|
||||
mock_growatt_classic_api.plant_info.return_value = {
|
||||
"deviceList": [],
|
||||
"totalEnergy": 1250.0,
|
||||
"todayEnergy": 12.5,
|
||||
"invTodayPpv": 2500,
|
||||
"plantMoneyText": "123.45/USD",
|
||||
}
|
||||
mock_growatt_classic_api.tlx_detail.return_value = {
|
||||
"data": {"deviceSn": "TLX123456"}
|
||||
}
|
||||
|
||||
await setup_integration(hass, mock_config_entry_classic)
|
||||
assert mock_config_entry_classic.state is ConfigEntryState.LOADED
|
||||
|
||||
# Credentials expire between updates
|
||||
mock_growatt_classic_api.login.return_value = {
|
||||
"success": False,
|
||||
"msg": LOGIN_INVALID_AUTH_CODE,
|
||||
}
|
||||
|
||||
freezer.tick(timedelta(minutes=5))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
flows = hass.config_entries.flow.async_progress()
|
||||
assert any(
|
||||
flow["context"]["source"] == "reauth"
|
||||
and flow["context"]["entry_id"] == mock_config_entry_classic.entry_id
|
||||
for flow in flows
|
||||
)
|
||||
assert hass.states.get("sensor.tlx123456_ac_frequency").state == STATE_UNAVAILABLE
|
||||
|
||||
|
||||
async def test_classic_api_setup(
|
||||
hass: HomeAssistant,
|
||||
snapshot: SnapshotAssertion,
|
||||
|
||||
@@ -980,7 +980,10 @@ async def test_reader_writer_create(
|
||||
"state": "in_progress",
|
||||
}
|
||||
|
||||
# Consume any upload progress events before the final state event
|
||||
response = await client.receive_json()
|
||||
while "uploaded_bytes" in response["event"]:
|
||||
response = await client.receive_json()
|
||||
assert response["event"] == {
|
||||
"manager_state": "create_backup",
|
||||
"reason": None,
|
||||
@@ -1094,7 +1097,10 @@ async def test_reader_writer_create_addon_folder_error(
|
||||
"state": "in_progress",
|
||||
}
|
||||
|
||||
# Consume any upload progress events before the final state event
|
||||
response = await client.receive_json()
|
||||
while "uploaded_bytes" in response["event"]:
|
||||
response = await client.receive_json()
|
||||
assert response["event"] == {
|
||||
"manager_state": "create_backup",
|
||||
"reason": None,
|
||||
@@ -1211,7 +1217,10 @@ async def test_reader_writer_create_report_progress(
|
||||
"state": "in_progress",
|
||||
}
|
||||
|
||||
# Consume any upload progress events before the final state event
|
||||
response = await client.receive_json()
|
||||
while "uploaded_bytes" in response["event"]:
|
||||
response = await client.receive_json()
|
||||
assert response["event"] == {
|
||||
"manager_state": "create_backup",
|
||||
"reason": None,
|
||||
@@ -1273,7 +1282,10 @@ async def test_reader_writer_create_job_done(
|
||||
"state": "in_progress",
|
||||
}
|
||||
|
||||
# Consume any upload progress events before the final state event
|
||||
response = await client.receive_json()
|
||||
while "uploaded_bytes" in response["event"]:
|
||||
response = await client.receive_json()
|
||||
assert response["event"] == {
|
||||
"manager_state": "create_backup",
|
||||
"reason": None,
|
||||
@@ -1536,7 +1548,10 @@ async def test_reader_writer_create_per_agent_encryption(
|
||||
"state": "in_progress",
|
||||
}
|
||||
|
||||
# Consume any upload progress events before the final state event
|
||||
response = await client.receive_json()
|
||||
while "uploaded_bytes" in response["event"]:
|
||||
response = await client.receive_json()
|
||||
assert response["event"] == {
|
||||
"manager_state": "create_backup",
|
||||
"reason": None,
|
||||
@@ -1788,7 +1803,10 @@ async def test_reader_writer_create_download_remove_error(
|
||||
"state": "in_progress",
|
||||
}
|
||||
|
||||
# Consume any upload progress events before the final state event
|
||||
response = await client.receive_json()
|
||||
while "uploaded_bytes" in response["event"]:
|
||||
response = await client.receive_json()
|
||||
assert response["event"] == {
|
||||
"manager_state": "create_backup",
|
||||
"reason": "upload_failed",
|
||||
@@ -1952,7 +1970,10 @@ async def test_reader_writer_create_remote_backup(
|
||||
"state": "in_progress",
|
||||
}
|
||||
|
||||
# Consume any upload progress events before the final state event
|
||||
response = await client.receive_json()
|
||||
while "uploaded_bytes" in response["event"]:
|
||||
response = await client.receive_json()
|
||||
assert response["event"] == {
|
||||
"manager_state": "create_backup",
|
||||
"reason": None,
|
||||
|
||||
@@ -241,8 +241,6 @@ def _get_set_setting_side_effect(
|
||||
def _get_set_program_options_side_effect(
|
||||
event_queue: asyncio.Queue[list[EventMessage | Exception]],
|
||||
):
|
||||
"""Set programs side effect."""
|
||||
|
||||
async def set_program_options_side_effect(ha_id: str, *_, **kwargs) -> None:
|
||||
await event_queue.put(
|
||||
[
|
||||
@@ -289,25 +287,9 @@ def _get_specific_appliance_side_effect(
|
||||
pytest.fail(f"Mock didn't include appliance with id {ha_id}")
|
||||
|
||||
|
||||
@pytest.fixture(name="client")
|
||||
def mock_client(
|
||||
appliances: list[HomeAppliance],
|
||||
appliance: HomeAppliance | None,
|
||||
request: pytest.FixtureRequest,
|
||||
) -> MagicMock:
|
||||
"""Fixture to mock Client from HomeConnect."""
|
||||
|
||||
mock = MagicMock(
|
||||
autospec=HomeConnectClient,
|
||||
)
|
||||
|
||||
event_queue: asyncio.Queue[list[EventMessage | Exception]] = asyncio.Queue()
|
||||
|
||||
async def add_events(events: list[EventMessage | Exception]) -> None:
|
||||
await event_queue.put(events)
|
||||
|
||||
mock.add_events = add_events
|
||||
|
||||
def _get_set_program_option_side_effect(
|
||||
event_queue: asyncio.Queue[list[EventMessage]],
|
||||
):
|
||||
async def set_program_option_side_effect(ha_id: str, *_, **kwargs) -> None:
|
||||
event_key = EventKey(kwargs["option_key"])
|
||||
await event_queue.put(
|
||||
@@ -331,6 +313,28 @@ def mock_client(
|
||||
]
|
||||
)
|
||||
|
||||
return set_program_option_side_effect
|
||||
|
||||
|
||||
@pytest.fixture(name="client")
|
||||
def mock_client(
|
||||
appliances: list[HomeAppliance],
|
||||
appliance: HomeAppliance | None,
|
||||
request: pytest.FixtureRequest,
|
||||
) -> MagicMock:
|
||||
"""Fixture to mock Client from HomeConnect."""
|
||||
|
||||
mock = MagicMock(
|
||||
autospec=HomeConnectClient,
|
||||
)
|
||||
|
||||
event_queue: asyncio.Queue[list[EventMessage | Exception]] = asyncio.Queue()
|
||||
|
||||
async def add_events(events: list[EventMessage | Exception]) -> None:
|
||||
await event_queue.put(events)
|
||||
|
||||
mock.add_events = add_events
|
||||
|
||||
appliances = [appliance] if appliance else appliances
|
||||
|
||||
async def stream_all_events() -> AsyncGenerator[EventMessage]:
|
||||
@@ -408,15 +412,9 @@ def mock_client(
|
||||
),
|
||||
)
|
||||
mock.stop_program = AsyncMock()
|
||||
mock.set_active_program_option = AsyncMock(
|
||||
side_effect=_get_set_program_options_side_effect(event_queue),
|
||||
)
|
||||
mock.set_active_program_options = AsyncMock(
|
||||
side_effect=_get_set_program_options_side_effect(event_queue),
|
||||
)
|
||||
mock.set_selected_program_option = AsyncMock(
|
||||
side_effect=_get_set_program_options_side_effect(event_queue),
|
||||
)
|
||||
mock.set_selected_program_options = AsyncMock(
|
||||
side_effect=_get_set_program_options_side_effect(event_queue),
|
||||
)
|
||||
@@ -437,10 +435,10 @@ def mock_client(
|
||||
mock.get_active_program_options = AsyncMock(return_value=ArrayOfOptions([]))
|
||||
mock.get_selected_program_options = AsyncMock(return_value=ArrayOfOptions([]))
|
||||
mock.set_active_program_option = AsyncMock(
|
||||
side_effect=set_program_option_side_effect
|
||||
side_effect=_get_set_program_option_side_effect(event_queue)
|
||||
)
|
||||
mock.set_selected_program_option = AsyncMock(
|
||||
side_effect=set_program_option_side_effect
|
||||
side_effect=_get_set_program_option_side_effect(event_queue)
|
||||
)
|
||||
|
||||
mock.side_effect = mock
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
"haId": "123456789012345678"
|
||||
},
|
||||
{
|
||||
"name": "AirConditioner",
|
||||
"name": "Air conditioner",
|
||||
"brand": "BOSCH",
|
||||
"vib": "HCS000006",
|
||||
"connected": true,
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
'connected': True,
|
||||
'e_number': 'HCS000000/07',
|
||||
'ha_id': '8765432109876543210',
|
||||
'name': 'AirConditioner',
|
||||
'name': 'Air conditioner',
|
||||
'programs': list([
|
||||
'HeatingVentilationAirConditioning.AirConditioner.Program.ActiveClean',
|
||||
'HeatingVentilationAirConditioning.AirConditioner.Program.Auto',
|
||||
|
||||
665
tests/components/home_connect/test_fan.py
Normal file
665
tests/components/home_connect/test_fan.py
Normal file
@@ -0,0 +1,665 @@
|
||||
"""Tests for home_connect fan entities."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from aiohomeconnect.model import (
|
||||
ArrayOfEvents,
|
||||
Event,
|
||||
EventKey,
|
||||
EventMessage,
|
||||
EventType,
|
||||
HomeAppliance,
|
||||
OptionKey,
|
||||
ProgramDefinition,
|
||||
ProgramKey,
|
||||
)
|
||||
from aiohomeconnect.model.error import (
|
||||
ActiveProgramNotSetError,
|
||||
HomeConnectApiError,
|
||||
HomeConnectError,
|
||||
SelectedProgramNotSetError,
|
||||
)
|
||||
from aiohomeconnect.model.program import (
|
||||
ProgramDefinitionConstraints,
|
||||
ProgramDefinitionOption,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.fan import (
|
||||
ATTR_PERCENTAGE,
|
||||
ATTR_PRESET_MODE,
|
||||
ATTR_PRESET_MODES,
|
||||
DOMAIN as FAN_DOMAIN,
|
||||
SERVICE_SET_PERCENTAGE,
|
||||
SERVICE_SET_PRESET_MODE,
|
||||
FanEntityFeature,
|
||||
)
|
||||
from homeassistant.components.home_connect.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
STATE_UNAVAILABLE,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def platforms() -> list[str]:
|
||||
"""Fixture to specify platforms to test."""
|
||||
return [Platform.FAN]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def get_available_program_fixture(
|
||||
client: MagicMock,
|
||||
) -> None:
|
||||
"""Mock get_available_program."""
|
||||
client.get_available_program = AsyncMock(
|
||||
return_value=ProgramDefinition(
|
||||
ProgramKey.UNKNOWN,
|
||||
options=[
|
||||
ProgramDefinitionOption(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
"Enumeration",
|
||||
),
|
||||
ProgramDefinitionOption(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE,
|
||||
"Enumeration",
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
|
||||
async def test_paired_depaired_devices_flow(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
appliance: HomeAppliance,
|
||||
) -> None:
|
||||
"""Test that removed devices are correctly removed from and added to hass on API events."""
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)})
|
||||
assert device
|
||||
entity_entries = entity_registry.entities.get_entries_for_device_id(device.id)
|
||||
assert entity_entries
|
||||
|
||||
await client.add_events(
|
||||
[
|
||||
EventMessage(
|
||||
appliance.ha_id,
|
||||
EventType.DEPAIRED,
|
||||
data=ArrayOfEvents([]),
|
||||
)
|
||||
]
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)})
|
||||
assert not device
|
||||
for entity_entry in entity_entries:
|
||||
assert not entity_registry.async_get(entity_entry.entity_id)
|
||||
|
||||
# Now that everything related to the device is removed, pair it again
|
||||
await client.add_events(
|
||||
[
|
||||
EventMessage(
|
||||
appliance.ha_id,
|
||||
EventType.PAIRED,
|
||||
data=ArrayOfEvents([]),
|
||||
)
|
||||
]
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)})
|
||||
for entity_entry in entity_entries:
|
||||
assert entity_registry.async_get(entity_entry.entity_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
|
||||
async def test_connected_devices(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
appliance: HomeAppliance,
|
||||
) -> None:
|
||||
"""Test that devices reconnect.
|
||||
|
||||
Specifically those devices whose settings, status, etc. could
|
||||
not be obtained while disconnected and once connected, the entities are added.
|
||||
"""
|
||||
get_settings_original_mock = client.get_settings
|
||||
get_all_programs_mock = client.get_all_programs
|
||||
|
||||
async def get_settings_side_effect(ha_id: str):
|
||||
if ha_id == appliance.ha_id:
|
||||
raise HomeConnectApiError(
|
||||
"SDK.Error.HomeAppliance.Connection.Initialization.Failed"
|
||||
)
|
||||
return await get_settings_original_mock.side_effect(ha_id)
|
||||
|
||||
async def get_all_programs_side_effect(ha_id: str):
|
||||
if ha_id == appliance.ha_id:
|
||||
raise HomeConnectApiError(
|
||||
"SDK.Error.HomeAppliance.Connection.Initialization.Failed"
|
||||
)
|
||||
return await get_all_programs_mock.side_effect(ha_id)
|
||||
|
||||
client.get_settings = AsyncMock(side_effect=get_settings_side_effect)
|
||||
client.get_all_programs = AsyncMock(side_effect=get_all_programs_side_effect)
|
||||
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
client.get_settings = get_settings_original_mock
|
||||
client.get_all_programs = get_all_programs_mock
|
||||
|
||||
device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)})
|
||||
assert device
|
||||
assert not entity_registry.async_get_entity_id(
|
||||
Platform.FAN,
|
||||
DOMAIN,
|
||||
f"{appliance.ha_id}-air_conditioner",
|
||||
)
|
||||
|
||||
await client.add_events(
|
||||
[
|
||||
EventMessage(
|
||||
appliance.ha_id,
|
||||
EventType.CONNECTED,
|
||||
data=ArrayOfEvents([]),
|
||||
)
|
||||
]
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entity_registry.async_get_entity_id(
|
||||
Platform.FAN,
|
||||
DOMAIN,
|
||||
f"{appliance.ha_id}-air_conditioner",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
|
||||
async def test_fan_entity_availability(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
appliance: HomeAppliance,
|
||||
) -> None:
|
||||
"""Test if fan entities availability are based on the appliance connection state."""
|
||||
entity_ids = [
|
||||
"fan.air_conditioner",
|
||||
]
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
for entity_id in entity_ids:
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.state != STATE_UNAVAILABLE
|
||||
|
||||
await client.add_events(
|
||||
[
|
||||
EventMessage(
|
||||
appliance.ha_id,
|
||||
EventType.DISCONNECTED,
|
||||
ArrayOfEvents([]),
|
||||
)
|
||||
]
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
for entity_id in entity_ids:
|
||||
assert hass.states.is_state(entity_id, STATE_UNAVAILABLE)
|
||||
|
||||
await client.add_events(
|
||||
[
|
||||
EventMessage(
|
||||
appliance.ha_id,
|
||||
EventType.CONNECTED,
|
||||
ArrayOfEvents([]),
|
||||
)
|
||||
]
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
for entity_id in entity_ids:
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.state != STATE_UNAVAILABLE
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"set_active_program_options_side_effect",
|
||||
"set_selected_program_options_side_effect",
|
||||
"called_mock_method",
|
||||
),
|
||||
[
|
||||
(
|
||||
None,
|
||||
SelectedProgramNotSetError("error.key"),
|
||||
"set_active_program_option",
|
||||
),
|
||||
(
|
||||
ActiveProgramNotSetError("error.key"),
|
||||
None,
|
||||
"set_selected_program_option",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_speed_percentage_functionality(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
appliance: HomeAppliance,
|
||||
set_active_program_options_side_effect: ActiveProgramNotSetError | None,
|
||||
set_selected_program_options_side_effect: SelectedProgramNotSetError | None,
|
||||
called_mock_method: str,
|
||||
) -> None:
|
||||
"""Test speed percentage functionality."""
|
||||
entity_id = "fan.air_conditioner"
|
||||
option_key = OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE
|
||||
if set_active_program_options_side_effect:
|
||||
client.set_active_program_option.side_effect = (
|
||||
set_active_program_options_side_effect
|
||||
)
|
||||
else:
|
||||
assert set_selected_program_options_side_effect
|
||||
client.set_selected_program_option.side_effect = (
|
||||
set_selected_program_options_side_effect
|
||||
)
|
||||
called_mock: AsyncMock = getattr(client, called_mock_method)
|
||||
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
assert not hass.states.is_state(entity_id, "50")
|
||||
|
||||
await hass.services.async_call(
|
||||
FAN_DOMAIN,
|
||||
SERVICE_SET_PERCENTAGE,
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
ATTR_PERCENTAGE: 50,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
called_mock.assert_called_once_with(
|
||||
appliance.ha_id,
|
||||
option_key=option_key,
|
||||
value=50,
|
||||
)
|
||||
entity_state = hass.states.get(entity_id)
|
||||
assert entity_state
|
||||
assert entity_state.attributes[ATTR_PERCENTAGE] == 50
|
||||
|
||||
|
||||
async def test_set_speed_raises_home_assistant_error_on_api_errors(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
) -> None:
|
||||
"""Test that setting a fan mode raises HomeAssistantError on API errors."""
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
client.set_active_program_option.side_effect = HomeConnectError("Test error")
|
||||
with pytest.raises(HomeAssistantError, match="Test error"):
|
||||
await hass.services.async_call(
|
||||
FAN_DOMAIN,
|
||||
SERVICE_SET_PERCENTAGE,
|
||||
{
|
||||
ATTR_ENTITY_ID: "fan.air_conditioner",
|
||||
ATTR_PERCENTAGE: 50,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"set_active_program_options_side_effect",
|
||||
"set_selected_program_options_side_effect",
|
||||
"called_mock_method",
|
||||
),
|
||||
[
|
||||
(
|
||||
None,
|
||||
SelectedProgramNotSetError("error.key"),
|
||||
"set_active_program_option",
|
||||
),
|
||||
(
|
||||
ActiveProgramNotSetError("error.key"),
|
||||
None,
|
||||
"set_selected_program_option",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("allowed_values", "expected_fan_modes"),
|
||||
[
|
||||
(
|
||||
None,
|
||||
["auto", "manual"],
|
||||
),
|
||||
(
|
||||
[
|
||||
"HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Automatic",
|
||||
"HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Manual",
|
||||
],
|
||||
["auto", "manual"],
|
||||
),
|
||||
(
|
||||
[
|
||||
"HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Automatic",
|
||||
],
|
||||
["auto"],
|
||||
),
|
||||
(
|
||||
[
|
||||
"HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Manual",
|
||||
"A.Non.Documented.Option",
|
||||
],
|
||||
["manual"],
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_preset_mode_functionality(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
appliance: HomeAppliance,
|
||||
allowed_values: list[str | None] | None,
|
||||
expected_fan_modes: list[str],
|
||||
set_active_program_options_side_effect: ActiveProgramNotSetError | None,
|
||||
set_selected_program_options_side_effect: SelectedProgramNotSetError | None,
|
||||
called_mock_method: str,
|
||||
) -> None:
|
||||
"""Test preset mode functionality."""
|
||||
entity_id = "fan.air_conditioner"
|
||||
option_key = (
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
|
||||
)
|
||||
if set_active_program_options_side_effect:
|
||||
client.set_active_program_option.side_effect = (
|
||||
set_active_program_options_side_effect
|
||||
)
|
||||
else:
|
||||
assert set_selected_program_options_side_effect
|
||||
client.set_selected_program_option.side_effect = (
|
||||
set_selected_program_options_side_effect
|
||||
)
|
||||
called_mock: AsyncMock = getattr(client, called_mock_method)
|
||||
client.get_available_program = AsyncMock(
|
||||
return_value=ProgramDefinition(
|
||||
ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO,
|
||||
options=[
|
||||
ProgramDefinitionOption(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
"Enumeration",
|
||||
constraints=ProgramDefinitionConstraints(
|
||||
allowed_values=allowed_values
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
entity_state = hass.states.get(entity_id)
|
||||
assert entity_state
|
||||
assert entity_state.attributes[ATTR_PRESET_MODES] == expected_fan_modes
|
||||
assert entity_state.attributes[ATTR_PRESET_MODE] != expected_fan_modes[0]
|
||||
|
||||
await hass.services.async_call(
|
||||
FAN_DOMAIN,
|
||||
SERVICE_SET_PRESET_MODE,
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
ATTR_PRESET_MODE: expected_fan_modes[0],
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
called_mock.assert_called_once_with(
|
||||
appliance.ha_id,
|
||||
option_key=option_key,
|
||||
value=allowed_values[0]
|
||||
if allowed_values
|
||||
else "HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Automatic",
|
||||
)
|
||||
entity_state = hass.states.get(entity_id)
|
||||
assert entity_state
|
||||
assert entity_state.attributes[ATTR_PRESET_MODE] == expected_fan_modes[0]
|
||||
|
||||
|
||||
async def test_set_preset_mode_raises_home_assistant_error_on_api_errors(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
) -> None:
|
||||
"""Test that setting a fan mode raises HomeAssistantError on API errors."""
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
client.set_active_program_option.side_effect = HomeConnectError("Test error")
|
||||
with pytest.raises(HomeAssistantError, match="Test error"):
|
||||
await hass.services.async_call(
|
||||
FAN_DOMAIN,
|
||||
SERVICE_SET_PRESET_MODE,
|
||||
{
|
||||
ATTR_ENTITY_ID: "fan.air_conditioner",
|
||||
ATTR_PRESET_MODE: "auto",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
|
||||
@pytest.mark.parametrize(
|
||||
("option_key", "expected_fan_feature"),
|
||||
[
|
||||
(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
FanEntityFeature.PRESET_MODE,
|
||||
),
|
||||
(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE,
|
||||
FanEntityFeature.SET_SPEED,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_supported_features(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
appliance: HomeAppliance,
|
||||
option_key: OptionKey,
|
||||
expected_fan_feature: FanEntityFeature,
|
||||
) -> None:
|
||||
"""Test that supported features are detected correctly."""
|
||||
client.get_available_program = AsyncMock(
|
||||
return_value=ProgramDefinition(
|
||||
ProgramKey.UNKNOWN,
|
||||
options=[
|
||||
ProgramDefinitionOption(
|
||||
option_key,
|
||||
"Enumeration",
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
entity_id = "fan.air_conditioner"
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
|
||||
assert state.attributes[ATTR_SUPPORTED_FEATURES] & expected_fan_feature
|
||||
|
||||
client.get_available_program = AsyncMock(
|
||||
return_value=ProgramDefinition(
|
||||
ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO,
|
||||
options=[],
|
||||
)
|
||||
)
|
||||
await client.add_events(
|
||||
[
|
||||
EventMessage(
|
||||
appliance.ha_id,
|
||||
EventType.NOTIFY,
|
||||
data=ArrayOfEvents(
|
||||
[
|
||||
Event(
|
||||
key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
|
||||
raw_key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM.value,
|
||||
timestamp=0,
|
||||
level="",
|
||||
handling="",
|
||||
value=ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO,
|
||||
)
|
||||
]
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert not state.attributes[ATTR_SUPPORTED_FEATURES] & expected_fan_feature
|
||||
|
||||
client.get_available_program = AsyncMock(
|
||||
return_value=ProgramDefinition(
|
||||
ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO,
|
||||
options=[
|
||||
ProgramDefinitionOption(
|
||||
option_key,
|
||||
"Enumeration",
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
await client.add_events(
|
||||
[
|
||||
EventMessage(
|
||||
appliance.ha_id,
|
||||
EventType.NOTIFY,
|
||||
data=ArrayOfEvents(
|
||||
[
|
||||
Event(
|
||||
key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
|
||||
raw_key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM.value,
|
||||
timestamp=0,
|
||||
level="",
|
||||
handling="",
|
||||
value=ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO.value,
|
||||
)
|
||||
]
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.attributes[ATTR_SUPPORTED_FEATURES] & expected_fan_feature
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
|
||||
async def test_added_entity_automatically(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
appliance: HomeAppliance,
|
||||
) -> None:
|
||||
"""Test that no fan entity is created if no fan options are available but when they are added later, the entity is created."""
|
||||
entity_id = "fan.air_conditioner"
|
||||
client.get_available_program = AsyncMock(
|
||||
return_value=ProgramDefinition(
|
||||
ProgramKey.UNKNOWN,
|
||||
options=[
|
||||
ProgramDefinitionOption(
|
||||
OptionKey.LAUNDRY_CARE_WASHER_SPIN_SPEED,
|
||||
"Enumeration",
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
assert not hass.states.get(entity_id)
|
||||
|
||||
client.get_available_program = AsyncMock(
|
||||
return_value=ProgramDefinition(
|
||||
ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO,
|
||||
options=[
|
||||
ProgramDefinitionOption(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
"Enumeration",
|
||||
),
|
||||
ProgramDefinitionOption(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE,
|
||||
"Enumeration",
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
await client.add_events(
|
||||
[
|
||||
EventMessage(
|
||||
appliance.ha_id,
|
||||
EventType.NOTIFY,
|
||||
data=ArrayOfEvents(
|
||||
[
|
||||
Event(
|
||||
key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
|
||||
raw_key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM.value,
|
||||
timestamp=0,
|
||||
level="",
|
||||
handling="",
|
||||
value=ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO.value,
|
||||
)
|
||||
]
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get(entity_id)
|
||||
@@ -42,7 +42,7 @@ def mock_lutron() -> Generator[MagicMock]:
|
||||
# Mock a light
|
||||
light = MagicMock()
|
||||
light.name = "Test Light"
|
||||
light.id = "light_id"
|
||||
light.id = 1
|
||||
light.uuid = "light_uuid"
|
||||
light.legacy_uuid = "light_legacy_uuid"
|
||||
light.is_dimmable = True
|
||||
@@ -53,7 +53,7 @@ def mock_lutron() -> Generator[MagicMock]:
|
||||
# Mock a switch
|
||||
switch = MagicMock()
|
||||
switch.name = "Test Switch"
|
||||
switch.id = "switch_id"
|
||||
switch.id = 2
|
||||
switch.uuid = "switch_uuid"
|
||||
switch.legacy_uuid = "switch_legacy_uuid"
|
||||
switch.is_dimmable = False
|
||||
@@ -64,7 +64,7 @@ def mock_lutron() -> Generator[MagicMock]:
|
||||
# Mock a cover
|
||||
cover = MagicMock()
|
||||
cover.name = "Test Cover"
|
||||
cover.id = "cover_id"
|
||||
cover.id = 3
|
||||
cover.uuid = "cover_uuid"
|
||||
cover.legacy_uuid = "cover_legacy_uuid"
|
||||
cover.type = "SYSTEM_SHADE"
|
||||
@@ -74,6 +74,7 @@ def mock_lutron() -> Generator[MagicMock]:
|
||||
# Mock a fan
|
||||
fan = MagicMock()
|
||||
fan.name = "Test Fan"
|
||||
fan.id = 4
|
||||
fan.uuid = "fan_uuid"
|
||||
fan.legacy_uuid = "fan_legacy_uuid"
|
||||
fan.type = "CEILING_FAN_TYPE"
|
||||
@@ -108,7 +109,7 @@ def mock_lutron() -> Generator[MagicMock]:
|
||||
# Mock an occupancy group
|
||||
occ_group = MagicMock()
|
||||
occ_group.name = "Test Occupancy"
|
||||
occ_group.id = "occ_id"
|
||||
occ_group.id = 5
|
||||
occ_group.uuid = "occ_uuid"
|
||||
occ_group.legacy_uuid = "occ_legacy_uuid"
|
||||
occ_group.state = OccupancyGroup.State.VACANT
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'occupancy',
|
||||
'friendly_name': 'Test Occupancy Occupancy',
|
||||
'lutron_integration_id': 'occ_id',
|
||||
'lutron_integration_id': 5,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.test_occupancy_occupancy',
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
'current_position': 0,
|
||||
'friendly_name': 'Test Cover',
|
||||
'is_closed': True,
|
||||
'lutron_integration_id': 'cover_id',
|
||||
'lutron_integration_id': 3,
|
||||
'supported_features': <CoverEntityFeature: 7>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
'brightness': None,
|
||||
'color_mode': None,
|
||||
'friendly_name': 'Test Light',
|
||||
'lutron_integration_id': 'light_id',
|
||||
'lutron_integration_id': 1,
|
||||
'supported_color_modes': list([
|
||||
<ColorMode.BRIGHTNESS: 'brightness'>,
|
||||
]),
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Test Switch',
|
||||
'lutron_integration_id': 'switch_id',
|
||||
'lutron_integration_id': 2,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'switch.test_switch',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from pylutron import OccupancyGroup
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON, Platform
|
||||
@@ -12,6 +13,13 @@ from homeassistant.helpers import entity_registry as er
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_platforms():
|
||||
"""Patch PLATFORMS for all tests in this file."""
|
||||
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.BINARY_SENSOR]):
|
||||
yield
|
||||
|
||||
|
||||
async def test_binary_sensor_setup(
|
||||
hass: HomeAssistant,
|
||||
mock_lutron: MagicMock,
|
||||
@@ -25,9 +33,8 @@ async def test_binary_sensor_setup(
|
||||
occ_group = mock_lutron.areas[0].occupancy_group
|
||||
occ_group.state = OccupancyGroup.State.VACANT
|
||||
|
||||
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.BINARY_SENSOR]):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.cover import DOMAIN as COVER_DOMAIN
|
||||
@@ -20,6 +21,13 @@ from homeassistant.helpers import entity_registry as er
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_platforms():
|
||||
"""Patch PLATFORMS for all tests in this file."""
|
||||
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.COVER]):
|
||||
yield
|
||||
|
||||
|
||||
async def test_cover_setup(
|
||||
hass: HomeAssistant,
|
||||
mock_lutron: MagicMock,
|
||||
@@ -34,9 +42,8 @@ async def test_cover_setup(
|
||||
cover.level = 0
|
||||
cover.last_level.return_value = 0
|
||||
|
||||
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.COVER]):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from pylutron import Button
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.const import Platform
|
||||
@@ -12,6 +13,13 @@ from homeassistant.helpers import entity_registry as er
|
||||
from tests.common import MockConfigEntry, async_capture_events, snapshot_platform
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_platforms():
|
||||
"""Patch PLATFORMS for all tests in this file."""
|
||||
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.EVENT]):
|
||||
yield
|
||||
|
||||
|
||||
async def test_event_setup(
|
||||
hass: HomeAssistant,
|
||||
mock_lutron: MagicMock,
|
||||
@@ -22,9 +30,8 @@ async def test_event_setup(
|
||||
"""Test event setup."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.EVENT]):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.fan import (
|
||||
@@ -18,6 +19,13 @@ from homeassistant.helpers import entity_registry as er
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_platforms():
|
||||
"""Patch PLATFORMS for all tests in this file."""
|
||||
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.FAN]):
|
||||
yield
|
||||
|
||||
|
||||
async def test_fan_setup(
|
||||
hass: HomeAssistant,
|
||||
mock_lutron: MagicMock,
|
||||
@@ -32,9 +40,8 @@ async def test_fan_setup(
|
||||
fan.level = 0
|
||||
fan.last_level.return_value = 0
|
||||
|
||||
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.FAN]):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@@ -25,6 +25,13 @@ from homeassistant.helpers import entity_registry as er
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_platforms():
|
||||
"""Patch PLATFORMS for all tests in this file."""
|
||||
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.LIGHT]):
|
||||
yield
|
||||
|
||||
|
||||
async def test_light_setup(
|
||||
hass: HomeAssistant,
|
||||
mock_lutron: MagicMock,
|
||||
@@ -39,9 +46,8 @@ async def test_light_setup(
|
||||
light.level = 0
|
||||
light.last_level.return_value = 0
|
||||
|
||||
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.LIGHT]):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN
|
||||
@@ -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.SCENE]):
|
||||
yield
|
||||
|
||||
|
||||
async def test_scene_setup(
|
||||
hass: HomeAssistant,
|
||||
mock_lutron: MagicMock,
|
||||
@@ -22,9 +30,8 @@ async def test_scene_setup(
|
||||
"""Test scene setup."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.SCENE]):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||
@@ -17,6 +18,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.SWITCH]):
|
||||
yield
|
||||
|
||||
|
||||
async def test_switch_setup(
|
||||
hass: HomeAssistant,
|
||||
mock_lutron: MagicMock,
|
||||
@@ -35,9 +43,8 @@ async def test_switch_setup(
|
||||
led.state = 0
|
||||
led.last_state = 0
|
||||
|
||||
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.SWITCH]):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
# name: test_diagnostics
|
||||
dict({
|
||||
'cap_available': True,
|
||||
'ct_connected': True,
|
||||
'device_info': dict({
|
||||
'model': 'Home Pro',
|
||||
'name': 'Ohme Home Pro',
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Tests for the Opower coordinator."""
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from opower import CostRead
|
||||
from opower import AggregateType, CostRead
|
||||
from opower.exceptions import ApiException
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
@@ -241,3 +242,267 @@ async def test_coordinator_migration(
|
||||
issue = issue_registry.async_get_issue(DOMAIN, "return_to_grid_migration_111111")
|
||||
assert issue is not None
|
||||
assert issue.severity == ir.IssueSeverity.WARNING
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "aggregate_type"),
|
||||
[
|
||||
("async_get_accounts", None),
|
||||
("async_get_forecast", None),
|
||||
("async_get_cost_reads", AggregateType.BILL),
|
||||
("async_get_cost_reads", AggregateType.DAY),
|
||||
("async_get_cost_reads", AggregateType.HOUR),
|
||||
],
|
||||
)
|
||||
async def test_coordinator_api_exceptions(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_opower_api: AsyncMock,
|
||||
method: str,
|
||||
aggregate_type: AggregateType | None,
|
||||
) -> None:
|
||||
"""Test the coordinator handles API exceptions during data fetching."""
|
||||
coordinator = OpowerCoordinator(hass, mock_config_entry)
|
||||
|
||||
if method == "async_get_cost_reads":
|
||||
|
||||
async def side_effect(account, agg_type, start, end):
|
||||
if agg_type == aggregate_type:
|
||||
raise ApiException(message="Error", url="http://example.com")
|
||||
# For other calls, return some dummy data to proceed if needed
|
||||
return [
|
||||
CostRead(
|
||||
start_time=dt_util.utcnow() - timedelta(days=1),
|
||||
end_time=dt_util.utcnow(),
|
||||
consumption=1.0,
|
||||
provided_cost=0.1,
|
||||
)
|
||||
]
|
||||
|
||||
mock_opower_api.async_get_cost_reads.side_effect = side_effect
|
||||
else:
|
||||
getattr(mock_opower_api, method).side_effect = ApiException(
|
||||
message="Error", url="http://example.com"
|
||||
)
|
||||
|
||||
with pytest.raises(ApiException):
|
||||
await coordinator._async_update_data()
|
||||
|
||||
|
||||
async def test_coordinator_updates_with_finer_grained_data(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_opower_api: AsyncMock,
|
||||
) -> None:
|
||||
"""Test that coarse data is updated when finer-grained data becomes available."""
|
||||
coordinator = OpowerCoordinator(hass, mock_config_entry)
|
||||
|
||||
# Mock accounts to return only one account to simplify
|
||||
account = mock_opower_api.async_get_accounts.return_value[0]
|
||||
mock_opower_api.async_get_accounts.return_value = [account]
|
||||
|
||||
t1 = dt_util.as_utc(datetime(2023, 1, 1, 0))
|
||||
t2 = dt_util.as_utc(datetime(2023, 1, 2, 0))
|
||||
|
||||
def mock_get_cost_reads(acc, aggregate_type, start, end):
|
||||
if aggregate_type == AggregateType.BILL:
|
||||
# Coarse bill data
|
||||
return [
|
||||
CostRead(
|
||||
start_time=t1, end_time=t2, consumption=10.0, provided_cost=2.0
|
||||
)
|
||||
]
|
||||
if aggregate_type == AggregateType.DAY:
|
||||
# Finer day data starting at the same time
|
||||
return [
|
||||
CostRead(
|
||||
start_time=t1,
|
||||
end_time=t1 + timedelta(hours=12),
|
||||
consumption=5.0,
|
||||
provided_cost=1.0,
|
||||
)
|
||||
]
|
||||
if aggregate_type == AggregateType.HOUR:
|
||||
# Even finer hour data starting later
|
||||
return [
|
||||
CostRead(
|
||||
start_time=t1 + timedelta(hours=12),
|
||||
end_time=t1 + timedelta(hours=13),
|
||||
consumption=1.0,
|
||||
provided_cost=0.2,
|
||||
)
|
||||
]
|
||||
return []
|
||||
|
||||
mock_opower_api.async_get_cost_reads.side_effect = mock_get_cost_reads
|
||||
|
||||
await coordinator._async_update_data()
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
# Verify that we have statistics for the electric account
|
||||
statistic_id = "opower:pge_elec_111111_energy_consumption"
|
||||
# Check the last statistic to ensure data was written at all
|
||||
last_stats = await hass.async_add_executor_job(
|
||||
get_last_statistics, hass, 1, statistic_id, True, {"sum"}
|
||||
)
|
||||
assert statistic_id in last_stats
|
||||
assert last_stats[statistic_id][0]["sum"] > 0
|
||||
# Check statistics over the full period to ensure finer-grained data was stored
|
||||
period_stats = await hass.async_add_executor_job(
|
||||
statistics_during_period,
|
||||
hass,
|
||||
t1,
|
||||
t2,
|
||||
{statistic_id},
|
||||
"hour",
|
||||
None,
|
||||
{"sum"},
|
||||
)
|
||||
assert statistic_id in period_stats
|
||||
# If only a single coarse (e.g., monthly) point were stored for this 1-day
|
||||
# interval, we would see at most one data point here. More than one point
|
||||
# indicates that finer-grained reads have been merged into the statistics.
|
||||
assert len(period_stats[statistic_id]) > 1
|
||||
|
||||
|
||||
async def test_coordinator_migration_empty_source_stats(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_opower_api: AsyncMock,
|
||||
) -> None:
|
||||
"""Test migration logic when source statistics are unexpectedly missing."""
|
||||
statistic_id = "opower:pge_elec_111111_energy_consumption"
|
||||
target_id = "opower:pge_elec_111111_energy_return"
|
||||
|
||||
coordinator = OpowerCoordinator(hass, mock_config_entry)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.opower.coordinator.statistics_during_period",
|
||||
return_value={statistic_id: []},
|
||||
):
|
||||
migrated = await coordinator._async_maybe_migrate_statistics(
|
||||
"111111",
|
||||
{statistic_id: target_id},
|
||||
{
|
||||
statistic_id: StatisticMetaData(
|
||||
has_sum=True,
|
||||
mean_type=StatisticMeanType.NONE,
|
||||
name="c",
|
||||
source=DOMAIN,
|
||||
statistic_id=statistic_id,
|
||||
unit_class=EnergyConverter.UNIT_CLASS,
|
||||
unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
),
|
||||
target_id: StatisticMetaData(
|
||||
has_sum=True,
|
||||
mean_type=StatisticMeanType.NONE,
|
||||
name="r",
|
||||
source=DOMAIN,
|
||||
statistic_id=target_id,
|
||||
unit_class=EnergyConverter.UNIT_CLASS,
|
||||
unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
# Migration should return False and not create an issue if no individual stats were found
|
||||
assert migrated is False
|
||||
|
||||
issue_registry = ir.async_get(hass)
|
||||
issue = issue_registry.async_get_issue(DOMAIN, "return_to_grid_migration_111111")
|
||||
assert issue is None
|
||||
|
||||
|
||||
async def test_coordinator_migration_negative_state(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_opower_api: AsyncMock,
|
||||
) -> None:
|
||||
"""Test that negative consumption states are correctly migrated to return-to-grid statistics."""
|
||||
statistic_id = "opower:pge_elec_111111_energy_consumption"
|
||||
target_id = "opower:pge_elec_111111_energy_return"
|
||||
metadata = StatisticMetaData(
|
||||
has_sum=True,
|
||||
mean_type=StatisticMeanType.NONE,
|
||||
name="Opower pge elec 111111 consumption",
|
||||
source=DOMAIN,
|
||||
statistic_id=statistic_id,
|
||||
unit_class=EnergyConverter.UNIT_CLASS,
|
||||
unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
)
|
||||
statistics_to_add = [
|
||||
StatisticData(
|
||||
start=dt_util.as_utc(datetime(2023, 1, 1, 8)), state=1.5, sum=1.5
|
||||
),
|
||||
StatisticData(
|
||||
start=dt_util.as_utc(datetime(2023, 1, 1, 9)),
|
||||
state=-0.5,
|
||||
sum=1.0, # Negative consumption state
|
||||
),
|
||||
]
|
||||
async_add_external_statistics(hass, metadata, statistics_to_add)
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
mock_opower_api.async_get_cost_reads.return_value = []
|
||||
coordinator = OpowerCoordinator(hass, mock_config_entry)
|
||||
await coordinator._async_update_data()
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
# Check that the return-to-grid stat was created with the absolute value of the negative consumption
|
||||
stats = await hass.async_add_executor_job(
|
||||
statistics_during_period,
|
||||
hass,
|
||||
dt_util.as_utc(datetime(2023, 1, 1, 9)),
|
||||
dt_util.as_utc(datetime(2023, 1, 1, 10)),
|
||||
{target_id},
|
||||
"hour",
|
||||
None,
|
||||
{"state"},
|
||||
)
|
||||
assert stats[target_id][0]["state"] == 0.5
|
||||
|
||||
|
||||
async def test_coordinator_no_new_cost_reads_after_initial_load(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_opower_api: AsyncMock,
|
||||
) -> None:
|
||||
"""Test that the coordinator correctly identifies when no new data is available."""
|
||||
# First run to get some stats
|
||||
t1 = dt_util.as_utc(datetime(2023, 1, 1, 8))
|
||||
t2 = dt_util.as_utc(datetime(2023, 1, 1, 9))
|
||||
mock_opower_api.async_get_cost_reads.return_value = [
|
||||
CostRead(
|
||||
start_time=t1,
|
||||
end_time=t2,
|
||||
consumption=1.5,
|
||||
provided_cost=0.5,
|
||||
),
|
||||
]
|
||||
coordinator = OpowerCoordinator(hass, mock_config_entry)
|
||||
await coordinator._async_update_data()
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
# Second run: API returns data that has already been recorded
|
||||
mock_opower_api.async_get_cost_reads.return_value = [
|
||||
CostRead(
|
||||
start_time=t1,
|
||||
end_time=t2,
|
||||
consumption=1.5,
|
||||
provided_cost=0.5,
|
||||
),
|
||||
]
|
||||
await coordinator._async_update_data()
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
# Sum should still be 1.5
|
||||
statistic_id = "opower:pge_elec_111111_energy_consumption"
|
||||
stats = await hass.async_add_executor_job(
|
||||
get_last_statistics, hass, 1, statistic_id, True, {"sum"}
|
||||
)
|
||||
assert stats[statistic_id][0]["sum"] == 1.5
|
||||
|
||||
@@ -168,5 +168,31 @@
|
||||
],
|
||||
"State": "running",
|
||||
"Status": "Up 6 hours"
|
||||
},
|
||||
{
|
||||
"Id": "ff31facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf",
|
||||
"Names": ["/dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05"],
|
||||
"Image": "docker.io/lissy93/dashy:latest",
|
||||
"ImageID": "sha256:7f8a4339a5c5d5d37d1262630108ab308d3e9ec7ed1c3286a32fe09856619a782",
|
||||
"Command": "node server",
|
||||
"Created": "1739816096",
|
||||
"Ports": [
|
||||
{
|
||||
"PrivatePort": 8080,
|
||||
"PublicPort": 4000,
|
||||
"Type": "tcp"
|
||||
}
|
||||
],
|
||||
"Labels": {
|
||||
"com.docker.stack.namespace": "dashy",
|
||||
"com.docker.swarm.node.id": "nggd3w8ntk2ivzkka6ecprjkh",
|
||||
"com.docker.swarm.service.id": "nk8zud67mpr6nyln0vhko65zb",
|
||||
"com.docker.swarm.service.name": "dashy_dashy",
|
||||
"com.docker.swarm.task": "",
|
||||
"com.docker.swarm.task.id": "qgza68hnz4n1qvyz3iohynx05",
|
||||
"com.docker.swarm.task.name": "dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05"
|
||||
},
|
||||
"State": "running",
|
||||
"Status": "Up 3 hours"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -10,5 +10,17 @@
|
||||
"CreatedBy": "admin",
|
||||
"CreationDate": 1739700000,
|
||||
"FromAppTemplate": false
|
||||
},
|
||||
{
|
||||
"Id": 2,
|
||||
"Name": "dashy",
|
||||
"Type": 1,
|
||||
"EndpointId": 1,
|
||||
"Status": 1,
|
||||
"EntryPoint": "docker-stack.yml",
|
||||
"ProjectPath": "/data/compose/dashy",
|
||||
"CreatedBy": "admin",
|
||||
"CreationDate": 1739710000,
|
||||
"FromAppTemplate": false
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,4 +1,104 @@
|
||||
# serializer version: 1
|
||||
# name: test_all_entities[binary_sensor.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_status-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.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_status',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Status',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <BinarySensorDeviceClass.RUNNING: 'running'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Status',
|
||||
'platform': 'portainer',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'status',
|
||||
'unique_id': 'portainer_test_entry_123_dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05_status',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[binary_sensor.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_status-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'running',
|
||||
'friendly_name': 'dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05 Status',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_status',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[binary_sensor.dashy_status-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.dashy_status',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Status',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <BinarySensorDeviceClass.RUNNING: 'running'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Status',
|
||||
'platform': 'portainer',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'status',
|
||||
'unique_id': 'portainer_test_entry_123_2_stack_status',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[binary_sensor.dashy_status-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'running',
|
||||
'friendly_name': 'dashy Status',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.dashy_status',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[binary_sensor.focused_einstein_status-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
|
||||
@@ -1,4 +1,54 @@
|
||||
# serializer version: 1
|
||||
# name: test_all_button_entities_snapshot[button.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_restart_container-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': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'button.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_restart_container',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Restart container',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <ButtonDeviceClass.RESTART: 'restart'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Restart container',
|
||||
'platform': 'portainer',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'restart_container',
|
||||
'unique_id': 'portainer_test_entry_123_dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05_restart',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_button_entities_snapshot[button.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_restart_container-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'restart',
|
||||
'friendly_name': 'dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05 Restart container',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_restart_container',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_button_entities_snapshot[button.focused_einstein_restart_container-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
|
||||
@@ -73,6 +73,15 @@
|
||||
'state': 'running',
|
||||
'status': 'Up 6 hours',
|
||||
}),
|
||||
dict({
|
||||
'id': 'ff31facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf',
|
||||
'image': 'docker.io/lissy93/dashy:latest',
|
||||
'names': list([
|
||||
'/dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05',
|
||||
]),
|
||||
'state': 'running',
|
||||
'status': 'Up 3 hours',
|
||||
}),
|
||||
]),
|
||||
'endpoint': dict({
|
||||
'public_url': 'docker.mydomain.tld:2375',
|
||||
|
||||
@@ -146,6 +146,35 @@
|
||||
'sw_version': None,
|
||||
'via_device_id': <ANY>,
|
||||
}),
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': 'https://127.0.0.1:9000/#!/1/docker/stacks/dashy',
|
||||
'connections': set({
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'portainer',
|
||||
'portainer_test_entry_123_1_stack_2',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Portainer',
|
||||
'model': 'Stack',
|
||||
'model_id': None,
|
||||
'name': 'dashy',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': None,
|
||||
'via_device_id': <ANY>,
|
||||
}),
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
@@ -204,5 +233,34 @@
|
||||
'sw_version': None,
|
||||
'via_device_id': <ANY>,
|
||||
}),
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': 'https://127.0.0.1:9000/#!/1/docker/containers/ff31facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf',
|
||||
'connections': set({
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': <DeviceEntryType.SERVICE: 'service'>,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'portainer',
|
||||
'portainer_test_entry_123_1_dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Portainer',
|
||||
'model': 'Container',
|
||||
'model_id': None,
|
||||
'name': 'dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': None,
|
||||
'via_device_id': <ANY>,
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
|
||||
@@ -1,4 +1,466 @@
|
||||
# serializer version: 1
|
||||
# name: test_all_entities[sensor.dashy_containers-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.dashy_containers',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Containers',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Containers',
|
||||
'platform': 'portainer',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'stack_containers_count',
|
||||
'unique_id': 'portainer_test_entry_123_2_stack_containers_count',
|
||||
'unit_of_measurement': 'containers',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[sensor.dashy_containers-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'dashy Containers',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': 'containers',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.dashy_containers',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '1',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[sensor.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_cpu_usage_total-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_cpu_usage_total',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'CPU usage total',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 2,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'CPU usage total',
|
||||
'platform': 'portainer',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'cpu_usage_total',
|
||||
'unique_id': 'portainer_test_entry_123_dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05_cpu_usage_total',
|
||||
'unit_of_measurement': '%',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[sensor.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_cpu_usage_total-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05 CPU usage total',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': '%',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_cpu_usage_total',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '0.0',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[sensor.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_image-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': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_image',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Image',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Image',
|
||||
'platform': 'portainer',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'image',
|
||||
'unique_id': 'portainer_test_entry_123_dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05_image',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[sensor.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_image-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05 Image',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_image',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'docker.io/lissy93/dashy:latest',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[sensor.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_memory_limit-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_memory_limit',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Memory limit',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 1,
|
||||
}),
|
||||
'sensor.private': dict({
|
||||
'suggested_unit_of_measurement': <UnitOfInformation.MEGABYTES: 'MB'>,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.DATA_SIZE: 'data_size'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Memory limit',
|
||||
'platform': 'portainer',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'memory_limit',
|
||||
'unique_id': 'portainer_test_entry_123_dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05_memory_limit',
|
||||
'unit_of_measurement': <UnitOfInformation.MEGABYTES: 'MB'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[sensor.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_memory_limit-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'data_size',
|
||||
'friendly_name': 'dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05 Memory limit',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfInformation.MEGABYTES: 'MB'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_memory_limit',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '67.108864',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[sensor.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_memory_usage-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_memory_usage',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Memory usage',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 1,
|
||||
}),
|
||||
'sensor.private': dict({
|
||||
'suggested_unit_of_measurement': <UnitOfInformation.MEGABYTES: 'MB'>,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.DATA_SIZE: 'data_size'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Memory usage',
|
||||
'platform': 'portainer',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'memory_usage',
|
||||
'unique_id': 'portainer_test_entry_123_dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05_memory_usage',
|
||||
'unit_of_measurement': <UnitOfInformation.MEGABYTES: 'MB'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[sensor.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_memory_usage-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'data_size',
|
||||
'friendly_name': 'dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05 Memory usage',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfInformation.MEGABYTES: 'MB'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_memory_usage',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '6.537216',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[sensor.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_memory_usage_percentage-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_memory_usage_percentage',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Memory usage percentage',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 2,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Memory usage percentage',
|
||||
'platform': 'portainer',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'memory_usage_percentage',
|
||||
'unique_id': 'portainer_test_entry_123_dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05_memory_usage_percentage',
|
||||
'unit_of_measurement': '%',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[sensor.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_memory_usage_percentage-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05 Memory usage percentage',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': '%',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_memory_usage_percentage',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '9.7412109375',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[sensor.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_state-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'options': list([
|
||||
'running',
|
||||
'exited',
|
||||
'paused',
|
||||
'restarting',
|
||||
'created',
|
||||
'dead',
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_state',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'State',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'State',
|
||||
'platform': 'portainer',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'container_state',
|
||||
'unique_id': 'portainer_test_entry_123_dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05_container_state',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[sensor.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_state-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'enum',
|
||||
'friendly_name': 'dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05 State',
|
||||
'options': list([
|
||||
'running',
|
||||
'exited',
|
||||
'paused',
|
||||
'restarting',
|
||||
'created',
|
||||
'dead',
|
||||
]),
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_state',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'running',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[sensor.dashy_type-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'options': list([
|
||||
'swarm',
|
||||
'compose',
|
||||
'kubernetes',
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.dashy_type',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Type',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Type',
|
||||
'platform': 'portainer',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'stack_type',
|
||||
'unique_id': 'portainer_test_entry_123_2_stack_type',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[sensor.dashy_type-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'enum',
|
||||
'friendly_name': 'dashy Type',
|
||||
'options': list([
|
||||
'swarm',
|
||||
'compose',
|
||||
'kubernetes',
|
||||
]),
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.dashy_type',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'swarm',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[sensor.focused_einstein_cpu_usage_total-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
|
||||
@@ -1,4 +1,104 @@
|
||||
# serializer version: 1
|
||||
# name: test_all_switch_entities_snapshot[switch.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_container-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': 'switch',
|
||||
'entity_category': None,
|
||||
'entity_id': 'switch.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_container',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Container',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SwitchDeviceClass.SWITCH: 'switch'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Container',
|
||||
'platform': 'portainer',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'container',
|
||||
'unique_id': 'portainer_test_entry_123_dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05_container',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_switch_entities_snapshot[switch.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_container-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'switch',
|
||||
'friendly_name': 'dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05 Container',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'switch.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_container',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_switch_entities_snapshot[switch.dashy_stack-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': 'switch',
|
||||
'entity_category': None,
|
||||
'entity_id': 'switch.dashy_stack',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Stack',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SwitchDeviceClass.SWITCH: 'switch'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Stack',
|
||||
'platform': 'portainer',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'stack',
|
||||
'unique_id': 'portainer_test_entry_123_2_stack',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_switch_entities_snapshot[switch.dashy_stack-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'switch',
|
||||
'friendly_name': 'dashy Stack',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'switch.dashy_stack',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_switch_entities_snapshot[switch.focused_einstein_container-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
|
||||
@@ -183,3 +183,54 @@ async def test_device_registry(
|
||||
device_registry, mock_config_entry.entry_id
|
||||
)
|
||||
assert device_entries == snapshot
|
||||
|
||||
|
||||
async def test_container_stack_device_links(
|
||||
hass: HomeAssistant,
|
||||
mock_portainer_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
) -> None:
|
||||
"""Test that stack-linked containers are nested under the correct stack device."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
endpoint_device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, f"{mock_config_entry.entry_id}_1")}
|
||||
)
|
||||
assert endpoint_device is not None
|
||||
|
||||
dashy_stack_device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, f"{mock_config_entry.entry_id}_1_stack_2")}
|
||||
)
|
||||
assert dashy_stack_device is not None
|
||||
assert dashy_stack_device.via_device_id == endpoint_device.id
|
||||
|
||||
webstack_device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, f"{mock_config_entry.entry_id}_1_stack_1")}
|
||||
)
|
||||
assert webstack_device is not None
|
||||
assert webstack_device.via_device_id == endpoint_device.id
|
||||
|
||||
swarm_container_device = device_registry.async_get_device(
|
||||
identifiers={
|
||||
(
|
||||
DOMAIN,
|
||||
f"{mock_config_entry.entry_id}_1_dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05",
|
||||
)
|
||||
}
|
||||
)
|
||||
assert swarm_container_device is not None
|
||||
assert swarm_container_device.via_device_id == dashy_stack_device.id
|
||||
|
||||
compose_container_device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, f"{mock_config_entry.entry_id}_1_serene_banach")}
|
||||
)
|
||||
assert compose_container_device is not None
|
||||
assert compose_container_device.via_device_id == webstack_device.id
|
||||
|
||||
standalone_container_device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, f"{mock_config_entry.entry_id}_1_focused_einstein")}
|
||||
)
|
||||
|
||||
assert standalone_container_device is not None
|
||||
assert standalone_container_device.via_device_id == endpoint_device.id
|
||||
|
||||
@@ -112,7 +112,7 @@ async def test_notify(hass: HomeAssistant) -> None:
|
||||
call(
|
||||
"telegram_bot",
|
||||
"send_message",
|
||||
{"target": 1, "title": "mock title", "message": "mock message"},
|
||||
{"chat_id": 1, "title": "mock title", "message": "mock message"},
|
||||
False,
|
||||
None,
|
||||
None,
|
||||
@@ -151,7 +151,7 @@ async def test_notify(hass: HomeAssistant) -> None:
|
||||
"telegram_bot",
|
||||
"send_photo",
|
||||
{
|
||||
"target": 1,
|
||||
"chat_id": 1,
|
||||
"url": "https://mock/photo.jpg",
|
||||
"caption": "mock caption",
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user