Compare commits

..

21 Commits

Author SHA1 Message Date
Ville Skyttä
2294b7dfee Switch to actions/attest for build provenance
https://github.com/actions/attest-build-provenance#usage
> As of version 4, actions/attest-build-provenance is simply a wrapper
> on top of actions/attest.
2026-03-11 21:54:36 +02:00
Oluwatobi Mustapha
30aec4d2ab Migrate OAuth helper token request exception handling in Google Sheets (#165000)
Signed-off-by: Oluwatobi Mustapha <oluwatobimustapha539@gmail.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-11 20:33:26 +01:00
AlCalzone
335abd7002 Support new Z-Wave JS "Opening state" notification variable (#165236) 2026-03-11 20:13:54 +01:00
Joakim Sørensen
3b3f0e9240 Bump hass-nabucasa from 1.15.0 to 2.0.0 (#165335) 2026-03-11 20:02:28 +01:00
Simone Chemelli
49586d1519 Fix dnd switch status for Alexa Devices (#164953) 2026-03-11 19:21:51 +01:00
Erwin Douna
c63ded3522 Add Swarm stack to Portainer (#164991) 2026-03-11 18:14:05 +01:00
Josef Zweck
2eb65ab314 Buffer backup upload progress events (#165249)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-03-11 17:29:35 +01:00
ams2990
402a37b435 Change light.toggle service call to invoke LightEntity.async_toggle (#156196)
Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2026-03-11 17:17:10 +01:00
Erik Montnemery
aa66e8ef0c Improve humidity triggers (#165323) 2026-03-11 17:11:27 +01:00
noambav
f1a1e284b7 Add support for Fish Audio s2-pro model (#165269) 2026-03-11 17:07:56 +01:00
hanwg
08594f4e0c Update migration message for Telegram bot (#165299) 2026-03-11 17:04:16 +01:00
Joakim Plate
8d810588f8 Move secondary zone of arcam to sub-device (#165336) 2026-03-11 16:57:47 +01:00
Sid
70faad15d5 Add binary_sensor to eheimdigital (#165035)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-11 16:21:16 +01:00
TheJulianJES
d447843687 Bump python-otbr-api to 2.9.0 (#165298) 2026-03-11 16:15:35 +01:00
Steve Easley
83b64e29fa Bump pyjvcprojector to 2.0.3 (#165327) 2026-03-11 16:13:26 +01:00
tronikos
4558a10e05 Improve test coverage in Opower to make it silver (#165124) 2026-03-11 15:56:31 +01:00
johanzander
5ad9e81082 Add reauthentication flow to growatt_server (silver quality scale) (#164993)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 15:51:25 +01:00
cdheiser
ba00a14772 Fix flakiness in lutron tests and isolate platforms per test file (#165328) 2026-03-11 15:08:00 +01:00
J. Diego Rodríguez Royo
49f4d07eeb Add fan entity for air conditioner to Home Connect (#155983)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-03-11 14:29:01 +01:00
Dan Raper
5d271a0d30 Bump ohme to 1.7.0 (#165318) 2026-03-11 12:49:07 +01:00
Joakim Plate
474b683d3c Update gardena to 2.1.0 (#165322) 2026-03-11 12:48:24 +01:00
103 changed files with 5149 additions and 424 deletions

View File

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

View File

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

View File

@@ -11,8 +11,11 @@ from arcam.fmj.state import State
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -50,6 +53,23 @@ class ArcamFmjCoordinator(DataUpdateCoordinator[None]):
self.state = State(client, zone)
self.last_update_success = False
name = config_entry.title
unique_id = config_entry.unique_id or config_entry.entry_id
unique_id_device = unique_id
if zone != 1:
unique_id_device += f"-{zone}"
name += f" Zone {zone}"
self.device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id_device)},
manufacturer="Arcam",
model="Arcam FMJ AVR",
name=name,
)
if zone != 1:
self.device_info["via_device"] = (DOMAIN, unique_id)
async def _async_update_data(self) -> None:
"""Fetch data for manual refresh."""
try:

View File

@@ -21,11 +21,10 @@ from homeassistant.components.media_player import (
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, EVENT_TURN_ON
from .const import EVENT_TURN_ON
from .coordinator import ArcamFmjConfigEntry, ArcamFmjCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -97,14 +96,7 @@ class ArcamFmj(CoordinatorEntity[ArcamFmjCoordinator], MediaPlayerEntity):
self._attr_supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE
self._attr_unique_id = f"{uuid}-{self._state.zn}"
self._attr_entity_registry_enabled_default = self._state.zn == 1
self._attr_device_info = DeviceInfo(
identifiers={
(DOMAIN, uuid),
},
manufacturer="Arcam",
model="Arcam FMJ AVR",
name=device_name,
)
self._attr_device_info = coordinator.device_info
@property
def state(self) -> MediaPlayerState:

View File

@@ -32,6 +32,7 @@ from homeassistant.helpers import (
issue_registry as ir,
start,
)
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.json import json_bytes
from homeassistant.util import dt as dt_util, json as json_util
from homeassistant.util.async_iterator import AsyncIteratorReader
@@ -78,6 +79,8 @@ from .util import (
validate_password_stream,
)
UPLOAD_PROGRESS_DEBOUNCE_SECONDS = 1
@dataclass(frozen=True, kw_only=True, slots=True)
class NewBackup:
@@ -590,23 +593,49 @@ class BackupManager:
)
agent = self.backup_agents[agent_id]
latest_uploaded_bytes = 0
@callback
def on_upload_progress(*, bytes_uploaded: int, **kwargs: Any) -> None:
"""Handle upload progress."""
def _emit_upload_progress() -> None:
"""Emit the latest upload progress event."""
self.async_on_backup_event(
UploadBackupEvent(
manager_state=self.state,
agent_id=agent_id,
uploaded_bytes=bytes_uploaded,
uploaded_bytes=latest_uploaded_bytes,
total_bytes=_backup.size,
)
)
upload_progress_debouncer: Debouncer[None] = Debouncer(
self.hass,
LOGGER,
cooldown=UPLOAD_PROGRESS_DEBOUNCE_SECONDS,
immediate=True,
function=_emit_upload_progress,
)
@callback
def on_upload_progress(*, bytes_uploaded: int, **kwargs: Any) -> None:
"""Handle upload progress."""
nonlocal latest_uploaded_bytes
latest_uploaded_bytes = bytes_uploaded
upload_progress_debouncer.async_schedule_call()
await agent.async_upload_backup(
open_stream=open_stream_func,
backup=_backup,
on_progress=on_upload_progress,
)
upload_progress_debouncer.async_cancel()
self.async_on_backup_event(
UploadBackupEvent(
manager_state=self.state,
agent_id=agent_id,
uploaded_bytes=_backup.size,
total_bytes=_backup.size,
)
)
if streamer:
await streamer.wait()

View File

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

View File

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

View File

@@ -0,0 +1,101 @@
"""EHEIM Digital binary sensors."""
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from eheimdigital.device import EheimDigitalDevice
from eheimdigital.reeflex import EheimDigitalReeflexUV
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
from .entity import EheimDigitalEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class EheimDigitalBinarySensorDescription[_DeviceT: EheimDigitalDevice](
BinarySensorEntityDescription
):
"""Class describing EHEIM Digital binary sensor entities."""
value_fn: Callable[[_DeviceT], bool | None]
REEFLEX_DESCRIPTIONS: tuple[
EheimDigitalBinarySensorDescription[EheimDigitalReeflexUV], ...
] = (
EheimDigitalBinarySensorDescription[EheimDigitalReeflexUV](
key="is_lighting",
translation_key="is_lighting",
value_fn=lambda device: device.is_lighting,
device_class=BinarySensorDeviceClass.LIGHT,
),
EheimDigitalBinarySensorDescription[EheimDigitalReeflexUV](
key="is_uvc_connected",
translation_key="is_uvc_connected",
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda device: device.is_uvc_connected,
device_class=BinarySensorDeviceClass.CONNECTIVITY,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: EheimDigitalConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the callbacks for the coordinator so binary sensors can be added as devices are found."""
coordinator = entry.runtime_data
def async_setup_device_entities(
device_address: dict[str, EheimDigitalDevice],
) -> None:
"""Set up the binary sensor entities for one or multiple devices."""
entities: list[EheimDigitalBinarySensor[Any]] = []
for device in device_address.values():
if isinstance(device, EheimDigitalReeflexUV):
entities += [
EheimDigitalBinarySensor[EheimDigitalReeflexUV](
coordinator, device, description
)
for description in REEFLEX_DESCRIPTIONS
]
async_add_entities(entities)
coordinator.add_platform_callback(async_setup_device_entities)
async_setup_device_entities(coordinator.hub.devices)
class EheimDigitalBinarySensor[_DeviceT: EheimDigitalDevice](
EheimDigitalEntity[_DeviceT], BinarySensorEntity
):
"""Represent an EHEIM Digital binary sensor entity."""
entity_description: EheimDigitalBinarySensorDescription[_DeviceT]
def __init__(
self,
coordinator: EheimDigitalUpdateCoordinator,
device: _DeviceT,
description: EheimDigitalBinarySensorDescription[_DeviceT],
) -> None:
"""Initialize an EHEIM Digital binary sensor entity."""
super().__init__(coordinator, device)
self.entity_description = description
self._attr_unique_id = f"{self._device_address}_{description.key}"
def _async_update_attrs(self) -> None:
self._attr_is_on = self.entity_description.value_fn(self._device)

View File

@@ -1,5 +1,19 @@
{
"entity": {
"binary_sensor": {
"is_lighting": {
"default": "mdi:lightbulb-outline",
"state": {
"on": "mdi:lightbulb-on"
}
},
"is_uvc_connected": {
"default": "mdi:lightbulb-off",
"state": {
"on": "mdi:lightbulb-outline"
}
}
},
"number": {
"day_speed": {
"default": "mdi:weather-sunny"

View File

@@ -33,6 +33,17 @@
}
},
"entity": {
"binary_sensor": {
"is_lighting": {
"state": {
"off": "[%key:common::state::off%]",
"on": "[%key:common::state::on%]"
}
},
"is_uvc_connected": {
"name": "UVC lamp connected"
}
},
"climate": {
"heater": {
"state_attributes": {

View File

@@ -111,7 +111,7 @@ def get_model_selection_schema(
),
vol.Required(
CONF_BACKEND,
default=options.get(CONF_BACKEND, "s1"),
default=options.get(CONF_BACKEND, "s2-pro"),
): SelectSelector(
SelectSelectorConfig(
options=[

View File

@@ -31,7 +31,7 @@ TTS_SUPPORTED_LANGUAGES = [
]
BACKEND_MODELS = ["s1", "speech-1.5", "speech-1.6"]
BACKEND_MODELS = ["s2-pro", "s1", "speech-1.5", "speech-1.6"]
SORT_BY_OPTIONS = ["task_count", "score", "created_at"]
LATENCY_OPTIONS = ["normal", "balanced"]

View File

@@ -2,12 +2,18 @@
from __future__ import annotations
import asyncio
import logging
from bleak.backends.device import BLEDevice
from gardena_bluetooth.client import CachedConnection, Client
from gardena_bluetooth.const import DeviceConfiguration, DeviceInformation
from gardena_bluetooth.exceptions import CommunicationFailure
from gardena_bluetooth.exceptions import (
CharacteristicNoAccess,
CharacteristicNotFound,
CommunicationFailure,
)
from gardena_bluetooth.parse import CharacteristicTime
from homeassistant.components import bluetooth
from homeassistant.const import CONF_ADDRESS, Platform
@@ -23,6 +29,7 @@ from .coordinator import (
GardenaBluetoothConfigEntry,
GardenaBluetoothCoordinator,
)
from .util import async_get_product_type
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
@@ -51,22 +58,41 @@ def get_connection(hass: HomeAssistant, address: str) -> CachedConnection:
return CachedConnection(DISCONNECT_DELAY, _device_lookup)
async def _update_timestamp(client: Client, characteristics: CharacteristicTime):
try:
await client.update_timestamp(characteristics, dt_util.now())
except CharacteristicNotFound:
pass
except CharacteristicNoAccess:
LOGGER.debug("No access to update internal time")
async def async_setup_entry(
hass: HomeAssistant, entry: GardenaBluetoothConfigEntry
) -> bool:
"""Set up Gardena Bluetooth from a config entry."""
address = entry.data[CONF_ADDRESS]
client = Client(get_connection(hass, address))
try:
async with asyncio.timeout(TIMEOUT):
product_type = await async_get_product_type(hass, address)
except TimeoutError as exception:
raise ConfigEntryNotReady("Unable to find product type") from exception
client = Client(get_connection(hass, address), product_type)
try:
chars = await client.get_all_characteristics()
sw_version = await client.read_char(DeviceInformation.firmware_version, None)
manufacturer = await client.read_char(DeviceInformation.manufacturer_name, None)
model = await client.read_char(DeviceInformation.model_number, None)
name = await client.read_char(
DeviceConfiguration.custom_device_name, entry.title
)
uuids = await client.get_all_characteristics_uuid()
await client.update_timestamp(dt_util.now())
name = entry.title
name = await client.read_char(DeviceConfiguration.custom_device_name, name)
await _update_timestamp(client, DeviceConfiguration.unix_timestamp)
except (TimeoutError, CommunicationFailure, DeviceUnavailable) as exception:
await client.disconnect()
raise ConfigEntryNotReady(
@@ -83,7 +109,7 @@ async def async_setup_entry(
)
coordinator = GardenaBluetoothCoordinator(
hass, entry, LOGGER, client, uuids, device, address
hass, entry, LOGGER, client, set(chars.keys()), device, address
)
entry.runtime_data = coordinator

View File

@@ -34,14 +34,14 @@ class GardenaBluetoothBinarySensorEntityDescription(BinarySensorEntityDescriptio
DESCRIPTIONS = (
GardenaBluetoothBinarySensorEntityDescription(
key=Valve.connected_state.uuid,
key=Valve.connected_state.unique_id,
translation_key="valve_connected_state",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
char=Valve.connected_state,
),
GardenaBluetoothBinarySensorEntityDescription(
key=Sensor.connected_state.uuid,
key=Sensor.connected_state.unique_id,
translation_key="sensor_connected_state",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -60,7 +60,7 @@ async def async_setup_entry(
entities = [
GardenaBluetoothBinarySensor(coordinator, description, description.context)
for description in DESCRIPTIONS
if description.key in coordinator.characteristics
if description.char.unique_id in coordinator.characteristics
]
async_add_entities(entities)

View File

@@ -30,7 +30,7 @@ class GardenaBluetoothButtonEntityDescription(ButtonEntityDescription):
DESCRIPTIONS = (
GardenaBluetoothButtonEntityDescription(
key=Reset.factory_reset.uuid,
key=Reset.factory_reset.unique_id,
translation_key="factory_reset",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
@@ -49,7 +49,7 @@ async def async_setup_entry(
entities = [
GardenaBluetoothButton(coordinator, description, description.context)
for description in DESCRIPTIONS
if description.key in coordinator.characteristics
if description.char.unique_id in coordinator.characteristics
]
async_add_entities(entities)

View File

@@ -15,5 +15,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["bleak", "bleak_esphome", "gardena_bluetooth"],
"requirements": ["gardena-bluetooth==1.6.0"]
"requirements": ["gardena-bluetooth==2.1.0"]
}

View File

@@ -46,7 +46,7 @@ class GardenaBluetoothNumberEntityDescription(NumberEntityDescription):
DESCRIPTIONS = (
GardenaBluetoothNumberEntityDescription(
key=Valve.manual_watering_time.uuid,
key=Valve.manual_watering_time.unique_id,
translation_key="manual_watering_time",
native_unit_of_measurement=UnitOfTime.SECONDS,
mode=NumberMode.BOX,
@@ -58,7 +58,7 @@ DESCRIPTIONS = (
device_class=NumberDeviceClass.DURATION,
),
GardenaBluetoothNumberEntityDescription(
key=Valve.remaining_open_time.uuid,
key=Valve.remaining_open_time.unique_id,
translation_key="remaining_open_time",
native_unit_of_measurement=UnitOfTime.SECONDS,
native_min_value=0.0,
@@ -69,7 +69,7 @@ DESCRIPTIONS = (
device_class=NumberDeviceClass.DURATION,
),
GardenaBluetoothNumberEntityDescription(
key=DeviceConfiguration.rain_pause.uuid,
key=DeviceConfiguration.rain_pause.unique_id,
translation_key="rain_pause",
native_unit_of_measurement=UnitOfTime.MINUTES,
mode=NumberMode.BOX,
@@ -81,7 +81,7 @@ DESCRIPTIONS = (
device_class=NumberDeviceClass.DURATION,
),
GardenaBluetoothNumberEntityDescription(
key=DeviceConfiguration.seasonal_adjust.uuid,
key=DeviceConfiguration.seasonal_adjust.unique_id,
translation_key="seasonal_adjust",
native_unit_of_measurement=UnitOfTime.DAYS,
mode=NumberMode.BOX,
@@ -93,7 +93,7 @@ DESCRIPTIONS = (
device_class=NumberDeviceClass.DURATION,
),
GardenaBluetoothNumberEntityDescription(
key=Sensor.threshold.uuid,
key=Sensor.threshold.unique_id,
translation_key="sensor_threshold",
native_unit_of_measurement=PERCENTAGE,
mode=NumberMode.BOX,
@@ -117,9 +117,9 @@ async def async_setup_entry(
entities: list[NumberEntity] = [
GardenaBluetoothNumber(coordinator, description, description.context)
for description in DESCRIPTIONS
if description.key in coordinator.characteristics
if description.char.unique_id in coordinator.characteristics
]
if Valve.remaining_open_time.uuid in coordinator.characteristics:
if Valve.remaining_open_time.unique_id in coordinator.characteristics:
entities.append(GardenaBluetoothRemainingOpenSetNumber(coordinator))
async_add_entities(entities)

View File

@@ -41,7 +41,7 @@ class GardenaBluetoothSensorEntityDescription(SensorEntityDescription):
DESCRIPTIONS = (
GardenaBluetoothSensorEntityDescription(
key=Valve.activation_reason.uuid,
key=Valve.activation_reason.unique_id,
translation_key="activation_reason",
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -49,7 +49,7 @@ DESCRIPTIONS = (
char=Valve.activation_reason,
),
GardenaBluetoothSensorEntityDescription(
key=Battery.battery_level.uuid,
key=Battery.battery_level.unique_id,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -57,7 +57,7 @@ DESCRIPTIONS = (
char=Battery.battery_level,
),
GardenaBluetoothSensorEntityDescription(
key=Sensor.battery_level.uuid,
key=Sensor.battery_level.unique_id,
translation_key="sensor_battery_level",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.BATTERY,
@@ -67,7 +67,7 @@ DESCRIPTIONS = (
connected_state=Sensor.connected_state,
),
GardenaBluetoothSensorEntityDescription(
key=Sensor.value.uuid,
key=Sensor.value.unique_id,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.MOISTURE,
native_unit_of_measurement=PERCENTAGE,
@@ -75,14 +75,14 @@ DESCRIPTIONS = (
connected_state=Sensor.connected_state,
),
GardenaBluetoothSensorEntityDescription(
key=Sensor.type.uuid,
key=Sensor.type.unique_id,
translation_key="sensor_type",
entity_category=EntityCategory.DIAGNOSTIC,
char=Sensor.type,
connected_state=Sensor.connected_state,
),
GardenaBluetoothSensorEntityDescription(
key=Sensor.measurement_timestamp.uuid,
key=Sensor.measurement_timestamp.unique_id,
translation_key="sensor_measurement_timestamp",
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -102,9 +102,9 @@ async def async_setup_entry(
entities: list[GardenaBluetoothEntity] = [
GardenaBluetoothSensor(coordinator, description, description.context)
for description in DESCRIPTIONS
if description.key in coordinator.characteristics
if description.char.unique_id in coordinator.characteristics
]
if Valve.remaining_open_time.uuid in coordinator.characteristics:
if Valve.remaining_open_time.unique_id in coordinator.characteristics:
entities.append(GardenaBluetoothRemainSensor(coordinator))
async_add_entities(entities)

View File

@@ -35,9 +35,9 @@ class GardenaBluetoothValveSwitch(GardenaBluetoothEntity, SwitchEntity):
"""Representation of a valve switch."""
characteristics = {
Valve.state.uuid,
Valve.manual_watering_time.uuid,
Valve.remaining_open_time.uuid,
Valve.state.unique_id,
Valve.manual_watering_time.unique_id,
Valve.remaining_open_time.unique_id,
}
def __init__(
@@ -48,7 +48,7 @@ class GardenaBluetoothValveSwitch(GardenaBluetoothEntity, SwitchEntity):
super().__init__(
coordinator, {Valve.state.uuid, Valve.manual_watering_time.uuid}
)
self._attr_unique_id = f"{coordinator.address}-{Valve.state.uuid}"
self._attr_unique_id = f"{coordinator.address}-{Valve.state.unique_id}"
self._attr_translation_key = "state"
self._attr_is_on = None
self._attr_entity_registry_enabled_default = False

View File

@@ -0,0 +1,51 @@
"""Utility functions for Gardena Bluetooth integration."""
import asyncio
from collections.abc import AsyncIterator
from gardena_bluetooth.parse import ManufacturerData, ProductType
from homeassistant.components import bluetooth
async def _async_service_info(
hass, address
) -> AsyncIterator[bluetooth.BluetoothServiceInfoBleak]:
queue = asyncio.Queue[bluetooth.BluetoothServiceInfoBleak]()
def _callback(
service_info: bluetooth.BluetoothServiceInfoBleak,
change: bluetooth.BluetoothChange,
) -> None:
if change != bluetooth.BluetoothChange.ADVERTISEMENT:
return
queue.put_nowait(service_info)
service_info = bluetooth.async_last_service_info(hass, address, True)
if service_info:
yield service_info
cancel = bluetooth.async_register_callback(
hass,
_callback,
{bluetooth.match.ADDRESS: address},
bluetooth.BluetoothScanningMode.ACTIVE,
)
try:
while True:
yield await queue.get()
finally:
cancel()
async def async_get_product_type(hass, address: str) -> ProductType:
"""Wait for enough packets of manufacturer data to get the product type."""
data = ManufacturerData()
async for service_info in _async_service_info(hass, address):
data.update(service_info.manufacturer_data.get(ManufacturerData.company, b""))
product_type = ProductType.from_manufacturer_data(data)
if product_type is not ProductType.UNKNOWN:
return product_type
raise AssertionError("Iterator should have been infinite")

View File

@@ -44,9 +44,9 @@ class GardenaBluetoothValve(GardenaBluetoothEntity, ValveEntity):
_attr_device_class = ValveDeviceClass.WATER
characteristics = {
Valve.state.uuid,
Valve.manual_watering_time.uuid,
Valve.remaining_open_time.uuid,
Valve.state.unique_id,
Valve.manual_watering_time.unique_id,
Valve.remaining_open_time.unique_id,
}
def __init__(
@@ -57,7 +57,7 @@ class GardenaBluetoothValve(GardenaBluetoothEntity, ValveEntity):
super().__init__(
coordinator, {Valve.state.uuid, Valve.manual_watering_time.uuid}
)
self._attr_unique_id = f"{coordinator.address}-{Valve.state.uuid}"
self._attr_unique_id = f"{coordinator.address}-{Valve.state.unique_id}"
def _handle_coordinator_update(self) -> None:
self._attr_is_closed = not self.coordinator.get_cached(Valve.state)

View File

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

View File

@@ -1,4 +1,28 @@
"""The Growatt server PV inverter sensor integration."""
"""The Growatt server PV inverter sensor integration.
This integration supports two distinct Growatt APIs with different auth models:
Classic API (username/password):
- Authenticates via api.login(), which returns a dict with a "success" key.
- Auth failure is signalled by success=False and msg="502" (LOGIN_INVALID_AUTH_CODE).
- A failed login does NOT raise an exception — the return value must be checked.
- The coordinator calls api.login() on every update cycle to maintain the session.
Open API V1 (API token):
- Stateless — no login call, token is sent as a Bearer header on every request.
- Auth failure is signalled by raising GrowattV1ApiError with error_code=10011
(V1_API_ERROR_NO_PRIVILEGE). The library NEVER returns a failure silently;
any non-zero error_code raises an exception via _process_response().
- Because the library always raises on error, return-value validation after a
successful V1 API call is unnecessary — if it returned, the token was valid.
Error handling pattern for reauth:
- Classic API: check NOT login_response["success"] and msg == LOGIN_INVALID_AUTH_CODE
→ raise ConfigEntryAuthFailed
- V1 API: catch GrowattV1ApiError with error_code == V1_API_ERROR_NO_PRIVILEGE
→ raise ConfigEntryAuthFailed
- All other errors → ConfigEntryError (setup) or UpdateFailed (coordinator)
"""
from collections.abc import Mapping
from json import JSONDecodeError
@@ -25,6 +49,7 @@ from .const import (
DOMAIN,
LOGIN_INVALID_AUTH_CODE,
PLATFORMS,
V1_API_ERROR_NO_PRIVILEGE,
)
from .coordinator import GrowattConfigEntry, GrowattCoordinator
from .models import GrowattRuntimeData
@@ -227,8 +252,12 @@ def get_device_list_v1(
try:
devices_dict = api.device_list(plant_id)
except growattServer.GrowattV1ApiError as e:
if e.error_code == V1_API_ERROR_NO_PRIVILEGE:
raise ConfigEntryAuthFailed(
f"Authentication failed for Growatt API: {e.error_msg or str(e)}"
) from e
raise ConfigEntryError(
f"API error during device list: {e} (Code: {getattr(e, 'error_code', None)}, Message: {getattr(e, 'error_msg', None)})"
f"API error during device list: {e.error_msg or str(e)} (Code: {e.error_code})"
) from e
devices = devices_dict.get("devices", [])
# Only MIN device (type = 7) support implemented in current V1 API
@@ -272,6 +301,7 @@ async def async_setup_entry(
# V1 API (token-based, no login needed)
token = config[CONF_TOKEN]
api = growattServer.OpenApiV1(token=token)
api.server_url = url
devices, plant_id = await hass.async_add_executor_job(
get_device_list_v1, api, config
)

View File

@@ -1,5 +1,6 @@
"""Config flow for growatt server integration."""
from collections.abc import Mapping
import logging
from typing import Any
@@ -31,8 +32,11 @@ from .const import (
ERROR_INVALID_AUTH,
LOGIN_INVALID_AUTH_CODE,
SERVER_URLS_NAMES,
V1_API_ERROR_NO_PRIVILEGE,
)
_URL_TO_REGION = {v: k for k, v in SERVER_URLS_NAMES.items()}
_LOGGER = logging.getLogger(__name__)
@@ -60,6 +64,137 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
menu_options=["password_auth", "token_auth"],
)
async def async_step_reauth(self, _: Mapping[str, Any]) -> ConfigFlowResult:
"""Handle reauth."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauth confirmation."""
errors: dict[str, str] = {}
reauth_entry = self._get_reauth_entry()
if user_input is not None:
auth_type = reauth_entry.data.get(CONF_AUTH_TYPE)
if auth_type == AUTH_PASSWORD:
server_url = SERVER_URLS_NAMES[user_input[CONF_REGION]]
api = growattServer.GrowattApi(
add_random_user_id=True,
agent_identifier=user_input[CONF_USERNAME],
)
api.server_url = server_url
try:
login_response = await self.hass.async_add_executor_job(
api.login, user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
)
except requests.exceptions.RequestException as ex:
_LOGGER.debug("Network error during reauth login: %s", ex)
errors["base"] = ERROR_CANNOT_CONNECT
except (ValueError, KeyError, TypeError, AttributeError) as ex:
_LOGGER.debug("Invalid response format during reauth login: %s", ex)
errors["base"] = ERROR_CANNOT_CONNECT
else:
if not isinstance(login_response, dict):
errors["base"] = ERROR_CANNOT_CONNECT
elif login_response.get("success"):
return self.async_update_reload_and_abort(
reauth_entry,
data_updates={
CONF_USERNAME: user_input[CONF_USERNAME],
CONF_PASSWORD: user_input[CONF_PASSWORD],
CONF_URL: server_url,
},
)
elif login_response.get("msg") == LOGIN_INVALID_AUTH_CODE:
errors["base"] = ERROR_INVALID_AUTH
else:
errors["base"] = ERROR_CANNOT_CONNECT
elif auth_type == AUTH_API_TOKEN:
server_url = SERVER_URLS_NAMES[user_input[CONF_REGION]]
api = growattServer.OpenApiV1(token=user_input[CONF_TOKEN])
api.server_url = server_url
try:
await self.hass.async_add_executor_job(api.plant_list)
except requests.exceptions.RequestException as ex:
_LOGGER.debug(
"Network error during reauth token validation: %s", ex
)
errors["base"] = ERROR_CANNOT_CONNECT
except growattServer.GrowattV1ApiError as err:
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
errors["base"] = ERROR_INVALID_AUTH
else:
_LOGGER.debug(
"Growatt V1 API error during reauth: %s (Code: %s)",
err.error_msg or str(err),
err.error_code,
)
errors["base"] = ERROR_CANNOT_CONNECT
except (ValueError, KeyError, TypeError, AttributeError) as ex:
_LOGGER.debug(
"Invalid response format during reauth token validation: %s", ex
)
errors["base"] = ERROR_CANNOT_CONNECT
else:
return self.async_update_reload_and_abort(
reauth_entry,
data_updates={
CONF_TOKEN: user_input[CONF_TOKEN],
CONF_URL: server_url,
},
)
# Determine the current region key from the stored config value.
# Legacy entries may store the region key directly; newer entries store the URL.
stored_url = reauth_entry.data.get(CONF_URL, "")
if stored_url in SERVER_URLS_NAMES:
current_region = stored_url
else:
current_region = _URL_TO_REGION.get(stored_url, DEFAULT_URL)
auth_type = reauth_entry.data.get(CONF_AUTH_TYPE)
if auth_type == AUTH_PASSWORD:
data_schema = vol.Schema(
{
vol.Required(
CONF_USERNAME,
default=reauth_entry.data.get(CONF_USERNAME),
): str,
vol.Required(CONF_PASSWORD): str,
vol.Required(CONF_REGION, default=current_region): SelectSelector(
SelectSelectorConfig(
options=list(SERVER_URLS_NAMES.keys()),
translation_key="region",
)
),
}
)
elif auth_type == AUTH_API_TOKEN:
data_schema = vol.Schema(
{
vol.Required(CONF_TOKEN): str,
vol.Required(CONF_REGION, default=current_region): SelectSelector(
SelectSelectorConfig(
options=list(SERVER_URLS_NAMES.keys()),
translation_key="region",
)
),
}
)
else:
return self.async_abort(reason=ERROR_CANNOT_CONNECT)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=data_schema,
errors=errors,
)
async def async_step_password_auth(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -129,9 +264,11 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.error(
"Growatt V1 API error: %s (Code: %s)",
e.error_msg or str(e),
getattr(e, "error_code", None),
e.error_code,
)
return self._async_show_token_form({"base": ERROR_INVALID_AUTH})
if e.error_code == V1_API_ERROR_NO_PRIVILEGE:
return self._async_show_token_form({"base": ERROR_INVALID_AUTH})
return self._async_show_token_form({"base": ERROR_CANNOT_CONNECT})
except (ValueError, KeyError, TypeError, AttributeError) as ex:
_LOGGER.error(
"Invalid response format during Growatt V1 API plant list: %s", ex

View File

@@ -40,8 +40,17 @@ DOMAIN = "growatt_server"
PLATFORMS = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH]
# Growatt Classic API error codes
LOGIN_INVALID_AUTH_CODE = "502"
# Growatt Open API V1 error codes
# Reference: https://www.showdoc.com.cn/262556420217021/1494055648380019
V1_API_ERROR_WRONG_DOMAIN = -1 # Use correct regional domain
V1_API_ERROR_NO_PRIVILEGE = 10011 # No privilege access — invalid or expired token
V1_API_ERROR_RATE_LIMITED = 10012 # Access frequency limit (5 minutes per call)
V1_API_ERROR_PAGE_SIZE = 10013 # Page size cannot exceed 100
V1_API_ERROR_PAGE_COUNT = 10014 # Page count cannot exceed 250
# Config flow error types (also used as abort reasons)
ERROR_CANNOT_CONNECT = "cannot_connect" # Used for both form errors and aborts
ERROR_INVALID_AUTH = "invalid_auth"

View File

@@ -13,7 +13,11 @@ from homeassistant.components.sensor import SensorStateClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
HomeAssistantError,
ServiceValidationError,
)
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
@@ -23,6 +27,8 @@ from .const import (
BATT_MODE_LOAD_FIRST,
DEFAULT_URL,
DOMAIN,
LOGIN_INVALID_AUTH_CODE,
V1_API_ERROR_NO_PRIVILEGE,
)
from .models import GrowattRuntimeData
@@ -63,6 +69,7 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.url = config_entry.data.get(CONF_URL, DEFAULT_URL)
self.token = config_entry.data["token"]
self.api = growattServer.OpenApiV1(token=self.token)
self.api.server_url = self.url
elif self.api_version == "classic":
self.username = config_entry.data.get(CONF_USERNAME)
self.password = config_entry.data[CONF_PASSWORD]
@@ -88,7 +95,14 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# login only required for classic API
if self.api_version == "classic":
self.api.login(self.username, self.password)
login_response = self.api.login(self.username, self.password)
if not login_response.get("success"):
msg = login_response.get("msg", "Unknown error")
if msg == LOGIN_INVALID_AUTH_CODE:
raise ConfigEntryAuthFailed(
"Username, password, or URL may be incorrect"
)
raise UpdateFailed(f"Growatt login failed: {msg}")
if self.device_type == "total":
if self.api_version == "v1":
@@ -100,7 +114,16 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# todayEnergy -> today_energy
# totalEnergy -> total_energy
# invTodayPpv -> current_power
total_info = self.api.plant_energy_overview(self.plant_id)
try:
total_info = self.api.plant_energy_overview(self.plant_id)
except growattServer.GrowattV1ApiError as err:
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
raise ConfigEntryAuthFailed(
f"Authentication failed for Growatt API: {err.error_msg or str(err)}"
) from err
raise UpdateFailed(
f"Error fetching plant energy overview: {err}"
) from err
total_info["todayEnergy"] = total_info["today_energy"]
total_info["totalEnergy"] = total_info["total_energy"]
total_info["invTodayPpv"] = total_info["current_power"]
@@ -122,6 +145,10 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
min_settings = self.api.min_settings(self.device_id)
min_energy = self.api.min_energy(self.device_id)
except growattServer.GrowattV1ApiError as err:
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
raise ConfigEntryAuthFailed(
f"Authentication failed for Growatt API: {err.error_msg or str(err)}"
) from err
raise UpdateFailed(f"Error fetching min device data: {err}") from err
min_info = {**min_details, **min_settings, **min_energy}

View File

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

View File

@@ -3,7 +3,8 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"no_plants": "No plants have been found on this account"
"no_plants": "No plants have been found on this account",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
"cannot_connect": "Cannot connect to Growatt servers. Please check your internet connection and try again.",
@@ -13,7 +14,7 @@
"password_auth": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"url": "Server region",
"region": "Server region",
"username": "[%key:common::config_flow::data::username%]"
},
"title": "Enter your Growatt login credentials"
@@ -24,10 +25,20 @@
},
"title": "Select your plant"
},
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"region": "[%key:component::growatt_server::config::step::password_auth::data::region%]",
"token": "[%key:component::growatt_server::config::step::token_auth::data::token%]",
"username": "[%key:common::config_flow::data::username%]"
},
"description": "Re-enter your credentials to continue using this integration.",
"title": "Re-authenticate with Growatt"
},
"token_auth": {
"data": {
"token": "API Token",
"url": "Server region"
"region": "[%key:component::growatt_server::config::step::password_auth::data::region%]",
"token": "API Token"
},
"description": "Token authentication is only supported for MIN/TLX devices. For other device types, please use username/password authentication.",
"title": "Enter your API token"

View File

@@ -38,6 +38,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.FAN,
Platform.LIGHT,
Platform.NUMBER,
Platform.SELECT,

View File

@@ -19,7 +19,7 @@ from .coordinator import (
HomeConnectApplianceData,
HomeConnectConfigEntry,
)
from .entity import HomeConnectEntity, HomeConnectOptionEntity
from .entity import HomeConnectEntity
def should_add_option_entity(
@@ -48,7 +48,7 @@ def _create_option_entities(
known_entity_unique_ids: dict[str, str],
get_option_entities_for_appliance: Callable[
[HomeConnectApplianceCoordinator, er.EntityRegistry],
list[HomeConnectOptionEntity],
list[HomeConnectEntity],
],
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
@@ -78,7 +78,7 @@ def _handle_paired_or_connected_appliance(
],
get_option_entities_for_appliance: Callable[
[HomeConnectApplianceCoordinator, er.EntityRegistry],
list[HomeConnectOptionEntity],
list[HomeConnectEntity],
]
| None,
changed_options_listener_remove_callbacks: dict[str, list[Callable[[], None]]],
@@ -161,7 +161,7 @@ def setup_home_connect_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
get_option_entities_for_appliance: Callable[
[HomeConnectApplianceCoordinator, er.EntityRegistry],
list[HomeConnectOptionEntity],
list[HomeConnectEntity],
]
| None = None,
) -> None:

View File

@@ -0,0 +1,235 @@
"""Provides fan entities for Home Connect."""
import contextlib
import logging
from typing import cast
from aiohomeconnect.model import EventKey, OptionKey
from aiohomeconnect.model.error import ActiveProgramNotSetError, HomeConnectError
from homeassistant.components.fan import (
FanEntity,
FanEntityDescription,
FanEntityFeature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .common import setup_home_connect_entry
from .const import DOMAIN
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
from .entity import HomeConnectEntity
from .utils import get_dict_from_home_connect_error
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1
FAN_SPEED_MODE_OPTIONS = {
"auto": "HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Automatic",
"manual": "HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Manual",
}
FAN_SPEED_MODE_OPTIONS_INVERTED = {v: k for k, v in FAN_SPEED_MODE_OPTIONS.items()}
AIR_CONDITIONER_ENTITY_DESCRIPTION = FanEntityDescription(
key="air_conditioner",
translation_key="air_conditioner",
name=None,
)
def _get_entities_for_appliance(
appliance_coordinator: HomeConnectApplianceCoordinator,
) -> list[HomeConnectEntity]:
"""Get a list of entities."""
return (
[HomeConnectAirConditioningFanEntity(appliance_coordinator)]
if appliance_coordinator.data.options
and any(
option in appliance_coordinator.data.options
for option in (
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE,
)
)
else []
)
async def async_setup_entry(
hass: HomeAssistant,
entry: HomeConnectConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Home Connect fan entities."""
setup_home_connect_entry(
hass,
entry,
_get_entities_for_appliance,
async_add_entities,
lambda appliance_coordinator, _: _get_entities_for_appliance(
appliance_coordinator
),
)
class HomeConnectAirConditioningFanEntity(HomeConnectEntity, FanEntity):
"""Representation of a Home Connect fan entity."""
def __init__(
self,
coordinator: HomeConnectApplianceCoordinator,
) -> None:
"""Initialize the entity."""
self._attr_preset_modes = list(FAN_SPEED_MODE_OPTIONS.keys())
self._original_speed_modes_keys = set(FAN_SPEED_MODE_OPTIONS_INVERTED)
super().__init__(
coordinator,
AIR_CONDITIONER_ENTITY_DESCRIPTION,
context_override=(
EventKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE
),
)
self.update_preset_mode()
@callback
def _handle_coordinator_update_preset_mode(self) -> None:
"""Handle updated data from the coordinator."""
self.update_preset_mode()
self.async_write_ha_state()
_LOGGER.debug(
"Updated %s (fan mode), new state: %s", self.entity_id, self.preset_mode
)
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await super().async_added_to_hass()
self.async_on_remove(
self.coordinator.async_add_listener(
self._handle_coordinator_update_preset_mode,
EventKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
)
)
def update_native_value(self) -> None:
"""Set the speed percentage and speed mode values."""
option_value = None
option_key = OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE
if event := self.appliance.events.get(EventKey(option_key)):
option_value = event.value
self._attr_percentage = (
cast(int, option_value) if option_value is not None else None
)
@property
def supported_features(self) -> FanEntityFeature:
"""Return the supported features for this fan entity."""
features = FanEntityFeature(0)
if (
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE
in self.appliance.options
):
features |= FanEntityFeature.SET_SPEED
if (
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
in self.appliance.options
):
features |= FanEntityFeature.PRESET_MODE
return features
def update_preset_mode(self) -> None:
"""Set the preset mode value."""
option_value = None
option_key = OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
if event := self.appliance.events.get(EventKey(option_key)):
option_value = event.value
self._attr_preset_mode = (
FAN_SPEED_MODE_OPTIONS_INVERTED.get(cast(str, option_value))
if option_value is not None
else None
)
if (
(
option_definition := self.appliance.options.get(
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
)
)
and (option_constraints := option_definition.constraints)
and option_constraints.allowed_values
and (
allowed_values_without_none := {
value
for value in option_constraints.allowed_values
if value is not None
}
)
and self._original_speed_modes_keys != allowed_values_without_none
):
self._original_speed_modes_keys = allowed_values_without_none
self._attr_preset_modes = [
key
for key, value in FAN_SPEED_MODE_OPTIONS.items()
if value in self._original_speed_modes_keys
]
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed of the fan, as a percentage."""
await self._async_set_option(
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE,
percentage,
)
_LOGGER.debug(
"Updated %s's speed percentage option, new state: %s",
self.entity_id,
percentage,
)
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new target fan mode."""
await self._async_set_option(
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
FAN_SPEED_MODE_OPTIONS[preset_mode],
)
_LOGGER.debug(
"Updated %s's speed mode option, new state: %s",
self.entity_id,
self.state,
)
async def _async_set_option(self, key: OptionKey, value: str | int) -> None:
"""Set an option for the entity."""
try:
# We try to set the active program option first,
# if it fails we try to set the selected program option
with contextlib.suppress(ActiveProgramNotSetError):
await self.coordinator.client.set_active_program_option(
self.appliance.info.ha_id,
option_key=key,
value=value,
)
return
await self.coordinator.client.set_selected_program_option(
self.appliance.info.ha_id,
option_key=key,
value=value,
)
except HomeConnectError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_option",
translation_placeholders=get_dict_from_home_connect_error(err),
) from err
@property
def available(self) -> bool:
"""Return True if entity is available."""
return super().available and any(
option in self.appliance.options
for option in (
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE,
)
)

View File

@@ -136,7 +136,7 @@ def _get_entities_for_appliance(
def _get_option_entities_for_appliance(
appliance_coordinator: HomeConnectApplianceCoordinator,
entity_registry: er.EntityRegistry,
) -> list[HomeConnectOptionEntity]:
) -> list[HomeConnectEntity]:
"""Get a list of currently available option entities."""
return [
HomeConnectOptionNumberEntity(appliance_coordinator, description)

View File

@@ -355,7 +355,7 @@ def _get_entities_for_appliance(
def _get_option_entities_for_appliance(
appliance_coordinator: HomeConnectApplianceCoordinator,
entity_registry: er.EntityRegistry,
) -> list[HomeConnectOptionEntity]:
) -> list[HomeConnectEntity]:
"""Get a list of entities."""
return [
HomeConnectSelectOptionEntity(appliance_coordinator, desc)

View File

@@ -119,6 +119,18 @@
"name": "Stop program"
}
},
"fan": {
"air_conditioner": {
"state_attributes": {
"preset_mode": {
"state": {
"auto": "[%key:common::state::auto%]",
"manual": "[%key:common::state::manual%]"
}
}
}
}
},
"light": {
"ambient_light": {
"name": "Ambient light"

View File

@@ -189,7 +189,7 @@ def _get_entities_for_appliance(
def _get_option_entities_for_appliance(
appliance_coordinator: HomeConnectApplianceCoordinator,
entity_registry: er.EntityRegistry,
) -> list[HomeConnectOptionEntity]:
) -> list[HomeConnectEntity]:
"""Get a list of currently available option entities."""
return [
HomeConnectSwitchOptionEntity(appliance_coordinator, description)

View File

@@ -29,21 +29,21 @@
"title": "Humidity",
"triggers": {
"changed": {
"description": "Triggers when the humidity changes.",
"description": "Triggers when the relative humidity changes.",
"fields": {
"above": {
"description": "Only trigger when humidity is above this value.",
"description": "Only trigger when relative humidity is above this value.",
"name": "Above"
},
"below": {
"description": "Only trigger when humidity is below this value.",
"description": "Only trigger when relative humidity is below this value.",
"name": "Below"
}
},
"name": "Humidity changed"
"name": "Relative humidity changed"
},
"crossed_threshold": {
"description": "Triggers when the humidity crosses a threshold.",
"description": "Triggers when the relative humidity crosses a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::humidity::common::trigger_behavior_description%]",
@@ -62,7 +62,7 @@
"name": "Upper limit"
}
},
"name": "Humidity crossed threshold"
"name": "Relative humidity crossed threshold"
}
}
}

View File

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

View File

@@ -58,7 +58,7 @@ def _is_supported(discovery_info: BluetoothServiceInfo):
# Some mowers only expose the serial number in the manufacturer data
# and not the product type, so we allow None here as well.
if product_type not in (ProductType.MOWER, None):
if product_type not in (ProductType.MOWER, ProductType.UNKNOWN):
LOGGER.debug("Unsupported device: %s (%s)", manufacturer_data, discovery_info)
return False

View File

@@ -13,5 +13,5 @@
"documentation": "https://www.home-assistant.io/integrations/husqvarna_automower_ble",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==1.6.0"]
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.1.0"]
}

View File

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

View File

@@ -7,7 +7,7 @@ import csv
import dataclasses
import logging
import os
from typing import TYPE_CHECKING, Any, Self, cast, final
from typing import TYPE_CHECKING, Any, Self, cast, final, override
from propcache.api import cached_property
import voluptuous as vol
@@ -272,6 +272,18 @@ def filter_turn_off_params(
return {k: v for k, v in params.items() if k in (ATTR_TRANSITION, ATTR_FLASH)}
def process_turn_off_params(
hass: HomeAssistant, light: LightEntity, params: dict[str, Any]
) -> dict[str, Any]:
"""Process light turn off params."""
params = dict(params)
if ATTR_TRANSITION not in params:
hass.data[DATA_PROFILES].apply_default(light.entity_id, True, params)
return params
def filter_turn_on_params(light: LightEntity, params: dict[str, Any]) -> dict[str, Any]:
"""Filter out params not supported by the light."""
supported_features = light.supported_features
@@ -306,7 +318,171 @@ def filter_turn_on_params(light: LightEntity, params: dict[str, Any]) -> dict[st
return params
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: C901
def process_turn_on_params( # noqa: C901
hass: HomeAssistant, light: LightEntity, params: dict[str, Any]
) -> dict[str, Any]:
"""Process light turn on params."""
params = dict(params)
# Only process params once we processed brightness step
if params and (
ATTR_BRIGHTNESS_STEP in params or ATTR_BRIGHTNESS_STEP_PCT in params
):
brightness = light.brightness if light.is_on and light.brightness else 0
if ATTR_BRIGHTNESS_STEP in params:
brightness += params.pop(ATTR_BRIGHTNESS_STEP)
else:
brightness_pct = round(brightness / 255 * 100)
brightness = round(
(brightness_pct + params.pop(ATTR_BRIGHTNESS_STEP_PCT)) / 100 * 255
)
params[ATTR_BRIGHTNESS] = max(0, min(255, brightness))
preprocess_turn_on_alternatives(hass, params)
if (not params or not light.is_on) or (params and ATTR_TRANSITION not in params):
hass.data[DATA_PROFILES].apply_default(light.entity_id, light.is_on, params)
supported_color_modes = light._light_internal_supported_color_modes # noqa: SLF001
# If a color temperature is specified, emulate it if not supported by the light
if ATTR_COLOR_TEMP_KELVIN in params:
if (
ColorMode.COLOR_TEMP not in supported_color_modes
and ColorMode.RGBWW in supported_color_modes
):
color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN)
brightness = cast(int, params.get(ATTR_BRIGHTNESS, light.brightness))
params[ATTR_RGBWW_COLOR] = color_util.color_temperature_to_rgbww(
color_temp,
brightness,
light.min_color_temp_kelvin,
light.max_color_temp_kelvin,
)
elif ColorMode.COLOR_TEMP not in supported_color_modes:
color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN)
if color_supported(supported_color_modes):
params[ATTR_HS_COLOR] = color_util.color_temperature_to_hs(color_temp)
# If a color is specified, convert to the color space supported by the light
rgb_color: tuple[int, int, int] | None
rgbww_color: tuple[int, int, int, int, int] | None
if ATTR_HS_COLOR in params and ColorMode.HS not in supported_color_modes:
hs_color = params.pop(ATTR_HS_COLOR)
if ColorMode.RGB in supported_color_modes:
params[ATTR_RGB_COLOR] = color_util.color_hs_to_RGB(*hs_color)
elif ColorMode.RGBW in supported_color_modes:
rgb_color = color_util.color_hs_to_RGB(*hs_color)
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
elif ColorMode.RGBWW in supported_color_modes:
rgb_color = color_util.color_hs_to_RGB(*hs_color)
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
*rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
)
elif ColorMode.XY in supported_color_modes:
params[ATTR_XY_COLOR] = color_util.color_hs_to_xy(*hs_color)
elif ColorMode.COLOR_TEMP in supported_color_modes:
xy_color = color_util.color_hs_to_xy(*hs_color)
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
elif ATTR_RGB_COLOR in params and ColorMode.RGB not in supported_color_modes:
rgb_color = params.pop(ATTR_RGB_COLOR)
assert rgb_color is not None
if TYPE_CHECKING:
rgb_color = cast(tuple[int, int, int], rgb_color)
if ColorMode.RGBW in supported_color_modes:
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
elif ColorMode.RGBWW in supported_color_modes:
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
*rgb_color,
light.min_color_temp_kelvin,
light.max_color_temp_kelvin,
)
elif ColorMode.HS in supported_color_modes:
params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color)
elif ColorMode.XY in supported_color_modes:
params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color)
elif ColorMode.COLOR_TEMP in supported_color_modes:
xy_color = color_util.color_RGB_to_xy(*rgb_color)
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
elif ATTR_XY_COLOR in params and ColorMode.XY not in supported_color_modes:
xy_color = params.pop(ATTR_XY_COLOR)
if ColorMode.HS in supported_color_modes:
params[ATTR_HS_COLOR] = color_util.color_xy_to_hs(*xy_color)
elif ColorMode.RGB in supported_color_modes:
params[ATTR_RGB_COLOR] = color_util.color_xy_to_RGB(*xy_color)
elif ColorMode.RGBW in supported_color_modes:
rgb_color = color_util.color_xy_to_RGB(*xy_color)
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
elif ColorMode.RGBWW in supported_color_modes:
rgb_color = color_util.color_xy_to_RGB(*xy_color)
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
*rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
)
elif ColorMode.COLOR_TEMP in supported_color_modes:
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
elif ATTR_RGBW_COLOR in params and ColorMode.RGBW not in supported_color_modes:
rgbw_color = params.pop(ATTR_RGBW_COLOR)
rgb_color = color_util.color_rgbw_to_rgb(*rgbw_color)
if ColorMode.RGB in supported_color_modes:
params[ATTR_RGB_COLOR] = rgb_color
elif ColorMode.RGBWW in supported_color_modes:
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
*rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
)
elif ColorMode.HS in supported_color_modes:
params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color)
elif ColorMode.XY in supported_color_modes:
params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color)
elif ColorMode.COLOR_TEMP in supported_color_modes:
xy_color = color_util.color_RGB_to_xy(*rgb_color)
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
elif ATTR_RGBWW_COLOR in params and ColorMode.RGBWW not in supported_color_modes:
rgbww_color = params.pop(ATTR_RGBWW_COLOR)
assert rgbww_color is not None
if TYPE_CHECKING:
rgbww_color = cast(tuple[int, int, int, int, int], rgbww_color)
rgb_color = color_util.color_rgbww_to_rgb(
*rgbww_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
)
if ColorMode.RGB in supported_color_modes:
params[ATTR_RGB_COLOR] = rgb_color
elif ColorMode.RGBW in supported_color_modes:
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
elif ColorMode.HS in supported_color_modes:
params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color)
elif ColorMode.XY in supported_color_modes:
params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color)
elif ColorMode.COLOR_TEMP in supported_color_modes:
xy_color = color_util.color_RGB_to_xy(*rgb_color)
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
# If white is set to True, set it to the light's brightness
# Add a warning in Home Assistant Core 2024.3 if the brightness is set to an
# integer.
if params.get(ATTR_WHITE) is True:
params[ATTR_WHITE] = light.brightness
# If both white and brightness are specified, override white
if ATTR_WHITE in params and ColorMode.WHITE in supported_color_modes:
params[ATTR_WHITE] = params.pop(ATTR_BRIGHTNESS, params[ATTR_WHITE])
return params
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Expose light control via state machine and services."""
component = hass.data[DATA_COMPONENT] = EntityComponent[LightEntity](
_LOGGER, DOMAIN, hass, SCAN_INTERVAL
@@ -330,177 +506,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
base["params"] = data
return base
async def async_handle_light_on_service( # noqa: C901
async def async_handle_light_on_service(
light: LightEntity, call: ServiceCall
) -> None:
"""Handle turning a light on.
If brightness is set to 0, this service will turn the light off.
"""
params: dict[str, Any] = dict(call.data["params"])
params = process_turn_on_params(hass, light, call.data["params"])
# Only process params once we processed brightness step
if params and (
ATTR_BRIGHTNESS_STEP in params or ATTR_BRIGHTNESS_STEP_PCT in params
):
brightness = light.brightness if light.is_on and light.brightness else 0
if ATTR_BRIGHTNESS_STEP in params:
brightness += params.pop(ATTR_BRIGHTNESS_STEP)
else:
brightness_pct = round(brightness / 255 * 100)
brightness = round(
(brightness_pct + params.pop(ATTR_BRIGHTNESS_STEP_PCT)) / 100 * 255
)
params[ATTR_BRIGHTNESS] = max(0, min(255, brightness))
preprocess_turn_on_alternatives(hass, params)
if (not params or not light.is_on) or (
params and ATTR_TRANSITION not in params
):
profiles.apply_default(light.entity_id, light.is_on, params)
supported_color_modes = light._light_internal_supported_color_modes # noqa: SLF001
# If a color temperature is specified, emulate it if not supported by the light
if ATTR_COLOR_TEMP_KELVIN in params:
if (
ColorMode.COLOR_TEMP not in supported_color_modes
and ColorMode.RGBWW in supported_color_modes
):
color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN)
brightness = cast(int, params.get(ATTR_BRIGHTNESS, light.brightness))
params[ATTR_RGBWW_COLOR] = color_util.color_temperature_to_rgbww(
color_temp,
brightness,
light.min_color_temp_kelvin,
light.max_color_temp_kelvin,
)
elif ColorMode.COLOR_TEMP not in supported_color_modes:
color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN)
if color_supported(supported_color_modes):
params[ATTR_HS_COLOR] = color_util.color_temperature_to_hs(
color_temp
)
# If a color is specified, convert to the color space supported by the light
rgb_color: tuple[int, int, int] | None
rgbww_color: tuple[int, int, int, int, int] | None
if ATTR_HS_COLOR in params and ColorMode.HS not in supported_color_modes:
hs_color = params.pop(ATTR_HS_COLOR)
if ColorMode.RGB in supported_color_modes:
params[ATTR_RGB_COLOR] = color_util.color_hs_to_RGB(*hs_color)
elif ColorMode.RGBW in supported_color_modes:
rgb_color = color_util.color_hs_to_RGB(*hs_color)
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
elif ColorMode.RGBWW in supported_color_modes:
rgb_color = color_util.color_hs_to_RGB(*hs_color)
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
*rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
)
elif ColorMode.XY in supported_color_modes:
params[ATTR_XY_COLOR] = color_util.color_hs_to_xy(*hs_color)
elif ColorMode.COLOR_TEMP in supported_color_modes:
xy_color = color_util.color_hs_to_xy(*hs_color)
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
elif ATTR_RGB_COLOR in params and ColorMode.RGB not in supported_color_modes:
rgb_color = params.pop(ATTR_RGB_COLOR)
assert rgb_color is not None
if TYPE_CHECKING:
rgb_color = cast(tuple[int, int, int], rgb_color)
if ColorMode.RGBW in supported_color_modes:
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
elif ColorMode.RGBWW in supported_color_modes:
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
*rgb_color,
light.min_color_temp_kelvin,
light.max_color_temp_kelvin,
)
elif ColorMode.HS in supported_color_modes:
params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color)
elif ColorMode.XY in supported_color_modes:
params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color)
elif ColorMode.COLOR_TEMP in supported_color_modes:
xy_color = color_util.color_RGB_to_xy(*rgb_color)
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
elif ATTR_XY_COLOR in params and ColorMode.XY not in supported_color_modes:
xy_color = params.pop(ATTR_XY_COLOR)
if ColorMode.HS in supported_color_modes:
params[ATTR_HS_COLOR] = color_util.color_xy_to_hs(*xy_color)
elif ColorMode.RGB in supported_color_modes:
params[ATTR_RGB_COLOR] = color_util.color_xy_to_RGB(*xy_color)
elif ColorMode.RGBW in supported_color_modes:
rgb_color = color_util.color_xy_to_RGB(*xy_color)
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
elif ColorMode.RGBWW in supported_color_modes:
rgb_color = color_util.color_xy_to_RGB(*xy_color)
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
*rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
)
elif ColorMode.COLOR_TEMP in supported_color_modes:
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
elif ATTR_RGBW_COLOR in params and ColorMode.RGBW not in supported_color_modes:
rgbw_color = params.pop(ATTR_RGBW_COLOR)
rgb_color = color_util.color_rgbw_to_rgb(*rgbw_color)
if ColorMode.RGB in supported_color_modes:
params[ATTR_RGB_COLOR] = rgb_color
elif ColorMode.RGBWW in supported_color_modes:
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
*rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
)
elif ColorMode.HS in supported_color_modes:
params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color)
elif ColorMode.XY in supported_color_modes:
params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color)
elif ColorMode.COLOR_TEMP in supported_color_modes:
xy_color = color_util.color_RGB_to_xy(*rgb_color)
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
elif (
ATTR_RGBWW_COLOR in params and ColorMode.RGBWW not in supported_color_modes
):
rgbww_color = params.pop(ATTR_RGBWW_COLOR)
assert rgbww_color is not None
if TYPE_CHECKING:
rgbww_color = cast(tuple[int, int, int, int, int], rgbww_color)
rgb_color = color_util.color_rgbww_to_rgb(
*rgbww_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
)
if ColorMode.RGB in supported_color_modes:
params[ATTR_RGB_COLOR] = rgb_color
elif ColorMode.RGBW in supported_color_modes:
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
elif ColorMode.HS in supported_color_modes:
params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color)
elif ColorMode.XY in supported_color_modes:
params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color)
elif ColorMode.COLOR_TEMP in supported_color_modes:
xy_color = color_util.color_RGB_to_xy(*rgb_color)
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
# If white is set to True, set it to the light's brightness
# Add a warning in Home Assistant Core 2024.3 if the brightness is set to an
# integer.
if params.get(ATTR_WHITE) is True:
params[ATTR_WHITE] = light.brightness
# If both white and brightness are specified, override white
if ATTR_WHITE in params and ColorMode.WHITE in supported_color_modes:
params[ATTR_WHITE] = params.pop(ATTR_BRIGHTNESS, params[ATTR_WHITE])
# Remove deprecated white value if the light supports color mode
if params.get(ATTR_BRIGHTNESS) == 0 or params.get(ATTR_WHITE) == 0:
await async_handle_light_off_service(light, call)
else:
@@ -510,10 +524,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
light: LightEntity, call: ServiceCall
) -> None:
"""Handle turning off a light."""
params = dict(call.data["params"])
if ATTR_TRANSITION not in params:
profiles.apply_default(light.entity_id, True, params)
params = process_turn_off_params(hass, light, call.data["params"])
await light.async_turn_off(**filter_turn_off_params(light, params))
@@ -521,10 +532,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
light: LightEntity, call: ServiceCall
) -> None:
"""Handle toggling a light."""
if light.is_on:
await async_handle_light_off_service(light, call)
else:
await async_handle_light_on_service(light, call)
await light.async_toggle(**call.data["params"])
# Listen for light on and light off service calls.
@@ -1046,3 +1054,15 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
def supported_features(self) -> LightEntityFeature:
"""Flag supported features."""
return self._attr_supported_features
@override
async def async_toggle(self, **kwargs: Any) -> None:
"""Toggle the entity."""
if not self.is_on:
params = process_turn_on_params(self.hass, self, kwargs)
if params.get(ATTR_BRIGHTNESS) != 0 and params.get(ATTR_WHITE) != 0:
await self.async_turn_on(**filter_turn_on_params(self, params))
return
params = process_turn_off_params(self.hass, self, kwargs)
await self.async_turn_off(**filter_turn_off_params(self, params))

View File

@@ -19,6 +19,5 @@ async def async_get_config_entry_diagnostics(
return {
"device_info": client.device_info,
"vehicles": client.vehicles,
"ct_connected": client.ct_connected,
"cap_available": client.cap_available,
}

View File

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

View File

@@ -431,6 +431,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, OpowerData]]):
for source_id, source_stats in existing_stats.items():
_LOGGER.debug("Found %d statistics for %s", len(source_stats), source_id)
if not source_stats:
need_migration_source_ids.remove(source_id)
continue
target_id = migration_map[source_id]

View File

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

View File

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

View File

@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/otbr",
"integration_type": "service",
"iot_class": "local_polling",
"requirements": ["python-otbr-api==2.8.0"]
"requirements": ["python-otbr-api==2.9.0"]
}

View File

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

View File

@@ -16,6 +16,7 @@ from homeassistant.components.notify import (
BaseNotificationService,
)
from homeassistant.components.telegram_bot import (
ATTR_CHAT_ID,
ATTR_DISABLE_NOTIF,
ATTR_DISABLE_WEB_PREV,
ATTR_MESSAGE_TAG,
@@ -58,7 +59,7 @@ async def async_get_service(
hass,
DOMAIN,
"migrate_notify",
breaks_in_ha_version="2026.5.0",
breaks_in_ha_version="2026.8.0",
is_fixable=False,
translation_key="migrate_notify",
severity=ir.IssueSeverity.WARNING,
@@ -80,7 +81,7 @@ class TelegramNotificationService(BaseNotificationService):
def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to a user."""
service_data = {ATTR_TARGET: kwargs.get(ATTR_TARGET, self._chat_id)}
service_data = {ATTR_CHAT_ID: kwargs.get(ATTR_TARGET, self._chat_id)}
data = kwargs.get(ATTR_DATA)
# Set message tag

View File

@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/thread",
"integration_type": "service",
"iot_class": "local_polling",
"requirements": ["python-otbr-api==2.8.0", "pyroute2==0.7.5"],
"requirements": ["python-otbr-api==2.9.0", "pyroute2==0.7.5"],
"single_config_entry": true,
"zeroconf": ["_meshcop._udp.local."]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -1026,7 +1026,7 @@ gTTS==2.5.3
# homeassistant.components.gardena_bluetooth
# homeassistant.components.husqvarna_automower_ble
gardena-bluetooth==1.6.0
gardena-bluetooth==2.1.0
# homeassistant.components.google_assistant_sdk
gassist-text==0.0.14
@@ -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

View File

@@ -905,7 +905,7 @@ gTTS==2.5.3
# homeassistant.components.gardena_bluetooth
# homeassistant.components.husqvarna_automower_ble
gardena-bluetooth==1.6.0
gardena-bluetooth==2.1.0
# homeassistant.components.google_assistant_sdk
gassist-text==0.0.14
@@ -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

View File

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

View File

@@ -5619,10 +5619,10 @@
# name: test_generate[None].6
dict({
'event': dict({
'agent_id': 'backup.local',
'manager_state': 'create_backup',
'reason': None,
'stage': None,
'state': 'completed',
'total_bytes': 10240,
'uploaded_bytes': 10240,
}),
'id': 1,
'type': 'event',
@@ -5694,10 +5694,10 @@
# name: test_generate[data1].6
dict({
'event': dict({
'agent_id': 'backup.local',
'manager_state': 'create_backup',
'reason': None,
'stage': None,
'state': 'completed',
'total_bytes': 10240,
'uploaded_bytes': 10240,
}),
'id': 1,
'type': 'event',
@@ -5769,10 +5769,10 @@
# name: test_generate[data2].6
dict({
'event': dict({
'agent_id': 'backup.local',
'manager_state': 'create_backup',
'reason': None,
'stage': None,
'state': 'completed',
'total_bytes': 10240,
'uploaded_bytes': 10240,
}),
'id': 1,
'type': 'event',

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
from collections.abc import Callable, Generator
from dataclasses import replace
from datetime import timedelta
from io import StringIO
import json
from pathlib import Path
@@ -47,12 +48,14 @@ from homeassistant.components.backup.manager import (
ReceiveBackupStage,
ReceiveBackupState,
RestoreBackupState,
UploadBackupEvent,
WrittenBackup,
)
from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import CoreState, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import issue_registry as ir
from homeassistant.util import dt as dt_util
from .common import (
LOCAL_AGENT_ID,
@@ -65,6 +68,7 @@ from .common import (
setup_backup_platform,
)
from tests.common import async_fire_time_changed
from tests.typing import ClientSessionGenerator, WebSocketGenerator
_EXPECTED_FILES = [
@@ -596,7 +600,10 @@ async def test_initiate_backup(
"state": CreateBackupState.IN_PROGRESS,
}
# Consume any upload progress events before the final state event
result = await ws_client.receive_json()
while "uploaded_bytes" in result["event"]:
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
"reason": None,
@@ -843,7 +850,10 @@ async def test_initiate_backup_with_agent_error(
"state": CreateBackupState.IN_PROGRESS,
}
# Consume any upload progress events before the final state event
result = await ws_client.receive_json()
while "uploaded_bytes" in result["event"]:
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
"reason": "upload_failed",
@@ -1401,7 +1411,10 @@ async def test_initiate_backup_non_agent_upload_error(
"state": CreateBackupState.IN_PROGRESS,
}
# Consume any upload progress events before the final state event
result = await ws_client.receive_json()
while "uploaded_bytes" in result["event"]:
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
"reason": "upload_failed",
@@ -1594,7 +1607,10 @@ async def test_initiate_backup_file_error_upload_to_agents(
"state": CreateBackupState.IN_PROGRESS,
}
# Consume any upload progress events before the final state event
result = await ws_client.receive_json()
while "uploaded_bytes" in result["event"]:
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
"reason": "upload_failed",
@@ -2709,7 +2725,10 @@ async def test_receive_backup_file_read_error(
"state": ReceiveBackupState.IN_PROGRESS,
}
# Consume any upload progress events before the final state event
result = await ws_client.receive_json()
while "uploaded_bytes" in result["event"]:
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.RECEIVE_BACKUP,
"reason": final_state_reason,
@@ -3526,7 +3545,10 @@ async def test_initiate_backup_per_agent_encryption(
"state": CreateBackupState.IN_PROGRESS,
}
# Consume any upload progress events before the final state event
result = await ws_client.receive_json()
while "uploaded_bytes" in result["event"]:
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
"reason": None,
@@ -3761,25 +3783,114 @@ async def test_upload_progress_event(
result = await ws_client.receive_json()
assert result["event"]["stage"] == CreateBackupStage.UPLOAD_TO_AGENTS
# Upload progress events for the remote agent
# Collect all upload progress events until the final state event
progress_events = []
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
"agent_id": "test.remote",
"uploaded_bytes": 500,
"total_bytes": ANY,
}
while "uploaded_bytes" in result["event"]:
progress_events.append(result["event"])
result = await ws_client.receive_json()
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
"agent_id": "test.remote",
"uploaded_bytes": 1000,
"total_bytes": ANY,
}
# Verify progress events from the remote agent (500 from agent + final from manager)
remote_progress = [e for e in progress_events if e["agent_id"] == "test.remote"]
assert len(remote_progress) == 2
assert remote_progress[0]["uploaded_bytes"] == 500
assert remote_progress[1]["uploaded_bytes"] == remote_progress[1]["total_bytes"]
# Verify progress event from the local agent (final from manager)
local_progress = [e for e in progress_events if e["agent_id"] == LOCAL_AGENT_ID]
assert len(local_progress) == 1
assert local_progress[0]["uploaded_bytes"] == local_progress[0]["total_bytes"]
result = await ws_client.receive_json()
assert result["event"]["state"] == CreateBackupState.COMPLETED
result = await ws_client.receive_json()
assert result["event"] == {"manager_state": BackupManagerState.IDLE}
async def test_upload_progress_debounced(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
generate_backup_id: MagicMock,
) -> None:
"""Test that rapid upload progress events are debounced.
Verify that when the on_progress callback is called multiple times during
the debounce cooldown period, only the latest event is fired.
"""
agent_ids = ["test.remote"]
mock_agents = await setup_backup_integration(hass, remote_agents=["test.remote"])
manager = hass.data[DATA_MANAGER]
remote_agent = mock_agents["test.remote"]
progress_done = asyncio.Event()
upload_done = asyncio.Event()
async def upload_with_progress(**kwargs: Any) -> None:
"""Upload and report progress."""
on_progress = kwargs["on_progress"]
# First call fires immediately
on_progress(bytes_uploaded=100)
# These two are buffered during cooldown; 1000 should replace 500
on_progress(bytes_uploaded=500)
on_progress(bytes_uploaded=1000)
progress_done.set()
await upload_done.wait()
remote_agent.async_upload_backup.side_effect = upload_with_progress
# Subscribe directly to collect all events
events: list[Any] = []
manager.async_subscribe_events(events.append)
ws_client = await hass_ws_client(hass)
with patch("pathlib.Path.open", mock_open(read_data=b"test")):
await ws_client.send_json_auto_id(
{"type": "backup/generate", "agent_ids": agent_ids}
)
result = await ws_client.receive_json()
assert result["success"] is True
# Wait for upload to reach the sync point (progress reported, upload paused)
await progress_done.wait()
# At this point the debouncer's cooldown timer is pending.
# The first event (100 bytes) fired immediately, 500 and 1000 are buffered.
remote_events = [
e
for e in events
if isinstance(e, UploadBackupEvent) and e.agent_id == "test.remote"
]
assert len(remote_events) == 1
assert remote_events[0].uploaded_bytes == 100
# Advance time past the cooldown to trigger the debouncer timer.
# This fires the coalesced event: 500 was replaced by 1000.
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2))
remote_events = [
e
for e in events
if isinstance(e, UploadBackupEvent) and e.agent_id == "test.remote"
]
assert len(remote_events) == 2
assert remote_events[0].uploaded_bytes == 100
assert remote_events[1].uploaded_bytes == 1000
# Let the upload finish
upload_done.set()
# Fire pending timers so the backup task can complete
async_fire_time_changed(
hass, dt_util.utcnow() + timedelta(seconds=10), fire_all=True
)
await hass.async_block_till_done()
# Check the final 100% progress event is sent, that is sent for every agent
remote_events = [
e
for e in events
if isinstance(e, UploadBackupEvent) and e.agent_id == "test.remote"
]
assert len(remote_events) == 3
assert remote_events[2].uploaded_bytes == remote_events[2].total_bytes

View File

@@ -0,0 +1,101 @@
# serializer version: 1
# name: test_setup[binary_sensor.mock_reeflex_light-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.mock_reeflex_light',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Light',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.LIGHT: 'light'>,
'original_icon': None,
'original_name': 'Light',
'platform': 'eheimdigital',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'is_lighting',
'unique_id': '00:00:00:00:00:05_is_lighting',
'unit_of_measurement': None,
})
# ---
# name: test_setup[binary_sensor.mock_reeflex_light-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'light',
'friendly_name': 'Mock reeflex Light',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.mock_reeflex_light',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_setup[binary_sensor.mock_reeflex_uvc_lamp_connected-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.mock_reeflex_uvc_lamp_connected',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'UVC lamp connected',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.CONNECTIVITY: 'connectivity'>,
'original_icon': None,
'original_name': 'UVC lamp connected',
'platform': 'eheimdigital',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'is_uvc_connected',
'unique_id': '00:00:00:00:00:05_is_uvc_connected',
'unit_of_measurement': None,
})
# ---
# name: test_setup[binary_sensor.mock_reeflex_uvc_lamp_connected-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'connectivity',
'friendly_name': 'Mock reeflex UVC lamp connected',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.mock_reeflex_uvc_lamp_connected',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---

View File

@@ -0,0 +1,109 @@
"""Tests for the binary sensor module."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from .conftest import init_integration
from tests.common import MockConfigEntry, get_sensor_display_state, snapshot_platform
async def test_setup(
hass: HomeAssistant,
eheimdigital_hub_mock: MagicMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test binary sensor platform setup."""
mock_config_entry.add_to_hass(hass)
with (
patch(
"homeassistant.components.eheimdigital.PLATFORMS", [Platform.BINARY_SENSOR]
),
patch(
"homeassistant.components.eheimdigital.coordinator.asyncio.Event",
new=AsyncMock,
),
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
for device in eheimdigital_hub_mock.return_value.devices:
await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"](
device, eheimdigital_hub_mock.return_value.devices[device].device_type
)
await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]()
await hass.async_block_till_done()
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.parametrize(
("device_name", "entity_list"),
[
(
"reeflex_mock",
[
(
"binary_sensor.mock_reeflex_light",
"reeflex_data",
"isLighting",
True,
"on",
),
(
"binary_sensor.mock_reeflex_light",
"reeflex_data",
"isLighting",
False,
"off",
),
(
"binary_sensor.mock_reeflex_uvc_lamp_connected",
"reeflex_data",
"isUVCConnected",
True,
"on",
),
(
"binary_sensor.mock_reeflex_uvc_lamp_connected",
"reeflex_data",
"isUVCConnected",
False,
"off",
),
],
),
],
)
async def test_state_update(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
eheimdigital_hub_mock: MagicMock,
mock_config_entry: MockConfigEntry,
device_name: str,
entity_list: list[tuple[str, str, str, bool | int, str]],
request: pytest.FixtureRequest,
) -> None:
"""Test the binary sensor state update."""
device: MagicMock = request.getfixturevalue(device_name)
await init_integration(hass, mock_config_entry)
await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"](
device.mac_address, device.device_type
)
await hass.async_block_till_done()
for item in entity_list:
getattr(device, item[1])[item[2]] = item[3]
await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]()
assert get_sensor_display_state(hass, entity_registry, item[0]) == str(item[4])

View File

@@ -8,7 +8,7 @@ from freezegun.api import FrozenDateTimeFactory
from gardena_bluetooth.client import Client
from gardena_bluetooth.const import DeviceInformation
from gardena_bluetooth.exceptions import CharacteristicNotFound
from gardena_bluetooth.parse import Characteristic
from gardena_bluetooth.parse import Characteristic, Service
import pytest
from homeassistant.components.gardena_bluetooth.const import DOMAIN
@@ -83,7 +83,7 @@ def mock_client(
) -> Generator[Mock]:
"""Auto mock bluetooth."""
client = Mock(spec_set=Client)
client_class = Mock()
SENTINEL = object()
@@ -106,19 +106,32 @@ def mock_client(
return default
return val
def _all_char():
def _all_char_uuid():
return set(mock_read_char_raw.keys())
def _all_char():
product_type = client_class.call_args.args[1]
services = Service.services_for_product_type(product_type)
return {
char.unique_id: char
for service in services
for char in service.characteristics.values()
if char.uuid in mock_read_char_raw
}
client = Mock(spec_set=Client)
client.read_char.side_effect = _read_char
client.read_char_raw.side_effect = _read_char_raw
client.get_all_characteristics_uuid.side_effect = _all_char
client.get_all_characteristics_uuid.side_effect = _all_char_uuid
client.get_all_characteristics.side_effect = _all_char
client_class.return_value = client
with (
patch(
"homeassistant.components.gardena_bluetooth.config_flow.Client",
return_value=client,
new=client_class,
),
patch("homeassistant.components.gardena_bluetooth.Client", return_value=client),
patch("homeassistant.components.gardena_bluetooth.Client", new=client_class),
):
yield client

View File

@@ -1,21 +1,26 @@
"""Test the Gardena Bluetooth setup."""
import asyncio
from datetime import timedelta
from unittest.mock import Mock
from unittest.mock import Mock, patch
from gardena_bluetooth.const import Battery
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.gardena_bluetooth import DeviceUnavailable
from homeassistant.components.gardena_bluetooth.const import DOMAIN
from homeassistant.components.gardena_bluetooth.util import (
async_get_product_type as original_get_product_type,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.util import utcnow
from . import WATER_TIMER_SERVICE_INFO
from . import MISSING_MANUFACTURER_DATA_SERVICE_INFO, WATER_TIMER_SERVICE_INFO
from tests.common import MockConfigEntry, async_fire_time_changed
from tests.components.bluetooth import inject_bluetooth_service_info
async def test_setup(
@@ -28,12 +33,10 @@ async def test_setup(
"""Test setup creates expected devices."""
mock_read_char_raw[Battery.battery_level.uuid] = Battery.battery_level.encode(100)
inject_bluetooth_service_info(hass, WATER_TIMER_SERVICE_INFO)
mock_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
assert mock_entry.state is ConfigEntryState.LOADED
assert await hass.config_entries.async_setup(mock_entry.entry_id) is True
device = device_registry.async_get_device(
identifiers={(DOMAIN, WATER_TIMER_SERVICE_INFO.address)}
@@ -41,11 +44,49 @@ async def test_setup(
assert device == snapshot
async def test_setup_delayed_product(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_entry: MockConfigEntry,
mock_read_char_raw: dict[str, bytes],
snapshot: SnapshotAssertion,
) -> None:
"""Test setup creates expected devices."""
mock_read_char_raw[Battery.battery_level.uuid] = Battery.battery_level.encode(100)
mock_entry.add_to_hass(hass)
event = asyncio.Event()
async def _get_product_type(*args, **kwargs):
event.set()
return await original_get_product_type(*args, **kwargs)
with patch(
"homeassistant.components.gardena_bluetooth.async_get_product_type",
wraps=_get_product_type,
):
async with asyncio.TaskGroup() as tg:
setup_task = tg.create_task(
hass.config_entries.async_setup(mock_entry.entry_id)
)
await event.wait()
assert mock_entry.state is ConfigEntryState.SETUP_IN_PROGRESS
inject_bluetooth_service_info(hass, MISSING_MANUFACTURER_DATA_SERVICE_INFO)
inject_bluetooth_service_info(hass, WATER_TIMER_SERVICE_INFO)
assert await setup_task is True
async def test_setup_retry(
hass: HomeAssistant, mock_entry: MockConfigEntry, mock_client: Mock
) -> None:
"""Test setup creates expected devices."""
inject_bluetooth_service_info(hass, WATER_TIMER_SERVICE_INFO)
original_read_char = mock_client.read_char.side_effect
mock_client.read_char.side_effect = DeviceUnavailable
mock_entry.add_to_hass(hass)

View File

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

View File

@@ -0,0 +1,170 @@
# serializer version: 1
# name: test_reauth_password_error_then_recovery[None-login_return_value0]
FlowResultSnapshot({
'description_placeholders': dict({
'name': 'Mock Title',
}),
'errors': dict({
'base': 'invalid_auth',
}),
'flow_id': <ANY>,
'handler': 'growatt_server',
'last_step': None,
'preview': None,
'step_id': 'reauth_confirm',
'type': <FlowResultType.FORM: 'form'>,
})
# ---
# name: test_reauth_password_error_then_recovery[login_side_effect1-None]
FlowResultSnapshot({
'description_placeholders': dict({
'name': 'Mock Title',
}),
'errors': dict({
'base': 'cannot_connect',
}),
'flow_id': <ANY>,
'handler': 'growatt_server',
'last_step': None,
'preview': None,
'step_id': 'reauth_confirm',
'type': <FlowResultType.FORM: 'form'>,
})
# ---
# name: test_reauth_password_exception
dict({
'auth_type': 'password',
'name': 'Test Plant',
'password': 'password',
'plant_id': '123456',
'url': 'https://openapi.growatt.com/',
'username': 'username',
})
# ---
# name: test_reauth_password_non_auth_login_failure
dict({
'auth_type': 'password',
'name': 'Test Plant',
'password': 'password',
'plant_id': '123456',
'url': 'https://openapi.growatt.com/',
'username': 'username',
})
# ---
# name: test_reauth_password_success[https://openapi-us.growatt.com/-user_input1-north_america]
FlowResultSnapshot({
'description_placeholders': dict({
'name': 'Mock Title',
}),
'errors': dict({
}),
'flow_id': <ANY>,
'handler': 'growatt_server',
'last_step': None,
'preview': None,
'step_id': 'reauth_confirm',
'type': <FlowResultType.FORM: 'form'>,
})
# ---
# name: test_reauth_password_success[https://openapi-us.growatt.com/-user_input1-north_america].1
dict({
'auth_type': 'password',
'name': 'Test Plant',
'password': 'password',
'plant_id': '123456',
'url': 'https://openapi-us.growatt.com/',
'username': 'username',
})
# ---
# name: test_reauth_password_success[https://openapi.growatt.com/-user_input0-other_regions]
FlowResultSnapshot({
'description_placeholders': dict({
'name': 'Mock Title',
}),
'errors': dict({
}),
'flow_id': <ANY>,
'handler': 'growatt_server',
'last_step': None,
'preview': None,
'step_id': 'reauth_confirm',
'type': <FlowResultType.FORM: 'form'>,
})
# ---
# name: test_reauth_password_success[https://openapi.growatt.com/-user_input0-other_regions].1
dict({
'auth_type': 'password',
'name': 'Test Plant',
'password': 'password',
'plant_id': '123456',
'url': 'https://openapi.growatt.com/',
'username': 'username',
})
# ---
# name: test_reauth_token_error_then_recovery[plant_list_side_effect0]
FlowResultSnapshot({
'description_placeholders': dict({
'name': 'Mock Title',
}),
'errors': dict({
'base': 'invalid_auth',
}),
'flow_id': <ANY>,
'handler': 'growatt_server',
'last_step': None,
'preview': None,
'step_id': 'reauth_confirm',
'type': <FlowResultType.FORM: 'form'>,
})
# ---
# name: test_reauth_token_error_then_recovery[plant_list_side_effect1]
FlowResultSnapshot({
'description_placeholders': dict({
'name': 'Mock Title',
}),
'errors': dict({
'base': 'cannot_connect',
}),
'flow_id': <ANY>,
'handler': 'growatt_server',
'last_step': None,
'preview': None,
'step_id': 'reauth_confirm',
'type': <FlowResultType.FORM: 'form'>,
})
# ---
# name: test_reauth_token_exception
dict({
'auth_type': 'api_token',
'name': 'Test Plant',
'plant_id': '123456',
'token': 'test_api_token_12345',
'url': 'https://openapi.growatt.com/',
'user_id': '12345',
})
# ---
# name: test_reauth_token_success
FlowResultSnapshot({
'description_placeholders': dict({
'name': 'Mock Title',
}),
'errors': dict({
}),
'flow_id': <ANY>,
'handler': 'growatt_server',
'last_step': None,
'preview': None,
'step_id': 'reauth_confirm',
'type': <FlowResultType.FORM: 'form'>,
})
# ---
# name: test_reauth_token_success.1
dict({
'auth_type': 'api_token',
'name': 'Test Plant',
'plant_id': '123456',
'token': 'test_api_token_12345',
'url': 'https://openapi.growatt.com/',
'user_id': '12345',
})
# ---

View File

@@ -5,6 +5,9 @@ from copy import deepcopy
import growattServer
import pytest
import requests
from syrupy.assertion import SnapshotAssertion
from syrupy.filters import props
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components.growatt_server.const import (
@@ -19,8 +22,17 @@ from homeassistant.components.growatt_server.const import (
ERROR_CANNOT_CONNECT,
ERROR_INVALID_AUTH,
LOGIN_INVALID_AUTH_CODE,
SERVER_URLS_NAMES,
V1_API_ERROR_NO_PRIVILEGE,
V1_API_ERROR_RATE_LIMITED,
)
from homeassistant.const import (
CONF_NAME,
CONF_PASSWORD,
CONF_TOKEN,
CONF_URL,
CONF_USERNAME,
)
from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
@@ -299,11 +311,21 @@ async def test_password_auth_multiple_plants(
# Token authentication tests
@pytest.mark.parametrize(
("error_code", "expected_error"),
[
(V1_API_ERROR_NO_PRIVILEGE, ERROR_INVALID_AUTH),
(V1_API_ERROR_RATE_LIMITED, ERROR_CANNOT_CONNECT),
],
)
async def test_token_auth_api_error(
hass: HomeAssistant, mock_growatt_v1_api, mock_setup_entry
hass: HomeAssistant,
mock_growatt_v1_api,
mock_setup_entry,
error_code: int,
expected_error: str,
) -> None:
"""Test token authentication with API error, then recovery."""
"""Test token authentication with V1 API error maps to correct error type."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
@@ -312,9 +334,8 @@ async def test_token_auth_api_error(
result["flow_id"], {"next_step_id": "token_auth"}
)
# Any GrowattV1ApiError during token verification should result in invalid_auth
error = growattServer.GrowattV1ApiError("API error")
error.error_code = 100
error.error_code = error_code
mock_growatt_v1_api.plant_list.side_effect = error
result = await hass.config_entries.flow.async_configure(
@@ -323,9 +344,9 @@ async def test_token_auth_api_error(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "token_auth"
assert result["errors"] == {"base": ERROR_INVALID_AUTH}
assert result["errors"] == {"base": expected_error}
# Test recovery - reset side_effect and set normal return value
# Test recovery
mock_growatt_v1_api.plant_list.side_effect = None
mock_growatt_v1_api.plant_list.return_value = GROWATT_V1_PLANT_LIST_RESPONSE
@@ -671,3 +692,362 @@ async def test_password_auth_plant_list_invalid_format(
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == ERROR_CANNOT_CONNECT
# Reauthentication flow tests
@pytest.mark.parametrize(
("stored_url", "user_input", "expected_region"),
[
(
SERVER_URLS_NAMES["other_regions"],
FIXTURE_USER_INPUT_PASSWORD,
"other_regions",
),
(
SERVER_URLS_NAMES["north_america"],
{
CONF_USERNAME: "username",
CONF_PASSWORD: "password",
CONF_REGION: "north_america",
},
"north_america",
),
],
)
async def test_reauth_password_success(
hass: HomeAssistant,
mock_growatt_classic_api,
snapshot: SnapshotAssertion,
stored_url: str,
user_input: dict,
expected_region: str,
) -> None:
"""Test successful reauthentication with password auth for default and non-default regions."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_AUTH_TYPE: AUTH_PASSWORD,
CONF_USERNAME: "test_user",
CONF_PASSWORD: "test_password",
CONF_URL: stored_url,
CONF_PLANT_ID: "123456",
"name": "Test Plant",
},
unique_id="123456",
)
entry.add_to_hass(hass)
result = await entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
assert result == snapshot(exclude=props("data_schema"))
region_key = next(
k
for k in result["data_schema"].schema
if isinstance(k, vol.Required) and k.schema == CONF_REGION
)
assert region_key.default() == expected_region
mock_growatt_classic_api.login.return_value = GROWATT_LOGIN_RESPONSE
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert entry.data == snapshot
@pytest.mark.parametrize(
("login_side_effect", "login_return_value"),
[
(
None,
{"msg": LOGIN_INVALID_AUTH_CODE, "success": False},
),
(
requests.exceptions.ConnectionError("Connection failed"),
None,
),
],
)
async def test_reauth_password_error_then_recovery(
hass: HomeAssistant,
mock_growatt_classic_api,
mock_config_entry_classic: MockConfigEntry,
snapshot: SnapshotAssertion,
login_side_effect: Exception | None,
login_return_value: dict | None,
) -> None:
"""Test password reauth shows error then allows recovery."""
mock_config_entry_classic.add_to_hass(hass)
result = await mock_config_entry_classic.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
mock_growatt_classic_api.login.side_effect = login_side_effect
if login_return_value is not None:
mock_growatt_classic_api.login.return_value = login_return_value
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
assert result == snapshot(exclude=props("data_schema"))
# Recover with correct credentials
mock_growatt_classic_api.login.side_effect = None
mock_growatt_classic_api.login.return_value = GROWATT_LOGIN_RESPONSE
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
async def test_reauth_token_success(
hass: HomeAssistant,
mock_growatt_v1_api,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Test successful reauthentication with token auth."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
assert result == snapshot(exclude=props("data_schema"))
mock_growatt_v1_api.plant_list.return_value = GROWATT_V1_PLANT_LIST_RESPONSE
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert mock_config_entry.data == snapshot
def _make_no_privilege_error() -> growattServer.GrowattV1ApiError:
error = growattServer.GrowattV1ApiError("No privilege access")
error.error_code = V1_API_ERROR_NO_PRIVILEGE
return error
@pytest.mark.parametrize(
"plant_list_side_effect",
[
_make_no_privilege_error(),
requests.exceptions.ConnectionError("Network error"),
],
)
async def test_reauth_token_error_then_recovery(
hass: HomeAssistant,
mock_growatt_v1_api,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
plant_list_side_effect: Exception,
) -> None:
"""Test token reauth shows error then allows recovery."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
mock_growatt_v1_api.plant_list.side_effect = plant_list_side_effect
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
assert result == snapshot(exclude=props("data_schema"))
# Recover with a valid token
mock_growatt_v1_api.plant_list.side_effect = None
mock_growatt_v1_api.plant_list.return_value = GROWATT_V1_PLANT_LIST_RESPONSE
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
async def test_reauth_token_non_auth_api_error(
hass: HomeAssistant,
mock_growatt_v1_api,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reauth token with non-auth V1 API error (e.g. rate limit) shows cannot_connect."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
error = growattServer.GrowattV1ApiError("Rate limit exceeded")
error.error_code = V1_API_ERROR_RATE_LIMITED
mock_growatt_v1_api.plant_list.side_effect = error
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
assert result["errors"] == {"base": ERROR_CANNOT_CONNECT}
async def test_reauth_password_invalid_response(
hass: HomeAssistant,
mock_growatt_classic_api,
mock_config_entry_classic: MockConfigEntry,
) -> None:
"""Test reauth password flow with non-dict login response, then recovery."""
mock_config_entry_classic.add_to_hass(hass)
result = await mock_config_entry_classic.start_reauth_flow(hass)
mock_growatt_classic_api.login.return_value = "not_a_dict"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
assert result["errors"] == {"base": ERROR_CANNOT_CONNECT}
# Recover with correct credentials
mock_growatt_classic_api.login.return_value = GROWATT_LOGIN_RESPONSE
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
async def test_reauth_password_non_auth_login_failure(
hass: HomeAssistant,
mock_growatt_classic_api,
mock_config_entry_classic: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Test reauth password flow when login fails with a non-auth error."""
mock_config_entry_classic.add_to_hass(hass)
result = await mock_config_entry_classic.start_reauth_flow(hass)
mock_growatt_classic_api.login.return_value = {
"success": False,
"msg": "server_maintenance",
}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
assert result["errors"] == {"base": ERROR_CANNOT_CONNECT}
# Recover with correct credentials
mock_growatt_classic_api.login.return_value = GROWATT_LOGIN_RESPONSE
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert mock_config_entry_classic.data == snapshot
async def test_reauth_password_exception(
hass: HomeAssistant,
mock_growatt_classic_api,
mock_config_entry_classic: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Test reauth password flow with unexpected exception from login, then recovery."""
mock_config_entry_classic.add_to_hass(hass)
result = await mock_config_entry_classic.start_reauth_flow(hass)
mock_growatt_classic_api.login.side_effect = ValueError("Unexpected error")
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
assert result["errors"] == {"base": ERROR_CANNOT_CONNECT}
# Recover with correct credentials
mock_growatt_classic_api.login.side_effect = None
mock_growatt_classic_api.login.return_value = GROWATT_LOGIN_RESPONSE
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_PASSWORD
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert mock_config_entry_classic.data == snapshot
async def test_reauth_token_exception(
hass: HomeAssistant,
mock_growatt_v1_api,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Test reauth token flow with unexpected exception from plant_list, then recovery."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reauth_flow(hass)
mock_growatt_v1_api.plant_list.side_effect = ValueError("Unexpected error")
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
assert result["errors"] == {"base": ERROR_CANNOT_CONNECT}
# Recover with a valid token
mock_growatt_v1_api.plant_list.side_effect = None
mock_growatt_v1_api.plant_list.return_value = GROWATT_V1_PLANT_LIST_RESPONSE
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert mock_config_entry.data == snapshot
async def test_reauth_unknown_auth_type(hass: HomeAssistant) -> None:
"""Test reauth aborts immediately when the config entry has an unknown auth type."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_AUTH_TYPE: "unknown_type",
"plant_id": "123456",
"name": "Test Plant",
},
unique_id="123456",
)
entry.add_to_hass(hass)
# The flow aborts immediately without showing a form
result = await entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == ERROR_CANNOT_CONNECT

View File

@@ -18,6 +18,9 @@ from homeassistant.components.growatt_server.const import (
CONF_PLANT_ID,
DEFAULT_PLANT_ID,
DOMAIN,
LOGIN_INVALID_AUTH_CODE,
V1_API_ERROR_NO_PRIVILEGE,
V1_API_ERROR_RATE_LIMITED,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import (
@@ -26,6 +29,7 @@ from homeassistant.const import (
CONF_TOKEN,
CONF_URL,
CONF_USERNAME,
STATE_UNAVAILABLE,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
@@ -110,6 +114,149 @@ async def test_coordinator_update_failed(
assert mock_config_entry.state is ConfigEntryState.LOADED
@pytest.mark.usefixtures("init_integration")
async def test_coordinator_update_json_error(
hass: HomeAssistant,
mock_growatt_v1_api,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test coordinator handles JSONDecodeError gracefully."""
assert mock_config_entry.state is ConfigEntryState.LOADED
mock_growatt_v1_api.min_detail.side_effect = json.decoder.JSONDecodeError(
"Invalid JSON", "", 0
)
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
assert mock_config_entry.state is ConfigEntryState.LOADED
assert (
hass.states.get("switch.min123456_charge_from_grid").state == STATE_UNAVAILABLE
)
@pytest.mark.usefixtures("init_integration")
async def test_coordinator_total_non_auth_api_error(
hass: HomeAssistant,
mock_growatt_v1_api,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test total coordinator handles non-auth V1 API errors as UpdateFailed."""
assert mock_config_entry.state is ConfigEntryState.LOADED
error = growattServer.GrowattV1ApiError("Rate limited")
error.error_code = V1_API_ERROR_RATE_LIMITED
mock_growatt_v1_api.plant_energy_overview.side_effect = error
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
# Should stay loaded (UpdateFailed is transient), no reauth flow started
assert mock_config_entry.state is ConfigEntryState.LOADED
flows = hass.config_entries.flow.async_progress()
assert not any(flow["context"]["source"] == "reauth" for flow in flows)
async def test_setup_auth_failed_on_permission_denied(
hass: HomeAssistant,
mock_growatt_v1_api,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that error 10011 (no privilege) from device_list triggers reauth during setup."""
error = growattServer.GrowattV1ApiError("Permission denied")
error.error_code = V1_API_ERROR_NO_PRIVILEGE
mock_growatt_v1_api.device_list.side_effect = error
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
# Verify a reauth flow was started
flows = hass.config_entries.flow.async_progress()
assert any(
flow["context"]["source"] == "reauth"
and flow["context"]["entry_id"] == mock_config_entry.entry_id
for flow in flows
)
async def test_coordinator_auth_failed_triggers_reauth(
hass: HomeAssistant,
mock_growatt_v1_api,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that error 10011 (no privilege) from coordinator update triggers reauth."""
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.LOADED
error = growattServer.GrowattV1ApiError("Permission denied")
error.error_code = V1_API_ERROR_NO_PRIVILEGE
mock_growatt_v1_api.min_detail.side_effect = error
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
# Verify a reauth flow was started
flows = hass.config_entries.flow.async_progress()
assert any(
flow["context"]["source"] == "reauth"
and flow["context"]["entry_id"] == mock_config_entry.entry_id
for flow in flows
)
assert (
hass.states.get("switch.min123456_charge_from_grid").state == STATE_UNAVAILABLE
)
async def test_classic_api_coordinator_auth_failed_triggers_reauth(
hass: HomeAssistant,
mock_growatt_classic_api,
mock_config_entry_classic: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that invalid classic API credentials during coordinator update trigger reauth."""
mock_growatt_classic_api.device_list.return_value = [
{"deviceSn": "TLX123456", "deviceType": "tlx"}
]
mock_growatt_classic_api.plant_info.return_value = {
"deviceList": [],
"totalEnergy": 1250.0,
"todayEnergy": 12.5,
"invTodayPpv": 2500,
"plantMoneyText": "123.45/USD",
}
mock_growatt_classic_api.tlx_detail.return_value = {
"data": {"deviceSn": "TLX123456"}
}
await setup_integration(hass, mock_config_entry_classic)
assert mock_config_entry_classic.state is ConfigEntryState.LOADED
# Credentials expire between updates
mock_growatt_classic_api.login.return_value = {
"success": False,
"msg": LOGIN_INVALID_AUTH_CODE,
}
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
flows = hass.config_entries.flow.async_progress()
assert any(
flow["context"]["source"] == "reauth"
and flow["context"]["entry_id"] == mock_config_entry_classic.entry_id
for flow in flows
)
assert hass.states.get("sensor.tlx123456_ac_frequency").state == STATE_UNAVAILABLE
async def test_classic_api_setup(
hass: HomeAssistant,
snapshot: SnapshotAssertion,

View File

@@ -980,7 +980,10 @@ async def test_reader_writer_create(
"state": "in_progress",
}
# Consume any upload progress events before the final state event
response = await client.receive_json()
while "uploaded_bytes" in response["event"]:
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,
@@ -1094,7 +1097,10 @@ async def test_reader_writer_create_addon_folder_error(
"state": "in_progress",
}
# Consume any upload progress events before the final state event
response = await client.receive_json()
while "uploaded_bytes" in response["event"]:
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,
@@ -1211,7 +1217,10 @@ async def test_reader_writer_create_report_progress(
"state": "in_progress",
}
# Consume any upload progress events before the final state event
response = await client.receive_json()
while "uploaded_bytes" in response["event"]:
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,
@@ -1273,7 +1282,10 @@ async def test_reader_writer_create_job_done(
"state": "in_progress",
}
# Consume any upload progress events before the final state event
response = await client.receive_json()
while "uploaded_bytes" in response["event"]:
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,
@@ -1536,7 +1548,10 @@ async def test_reader_writer_create_per_agent_encryption(
"state": "in_progress",
}
# Consume any upload progress events before the final state event
response = await client.receive_json()
while "uploaded_bytes" in response["event"]:
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,
@@ -1788,7 +1803,10 @@ async def test_reader_writer_create_download_remove_error(
"state": "in_progress",
}
# Consume any upload progress events before the final state event
response = await client.receive_json()
while "uploaded_bytes" in response["event"]:
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": "upload_failed",
@@ -1952,7 +1970,10 @@ async def test_reader_writer_create_remote_backup(
"state": "in_progress",
}
# Consume any upload progress events before the final state event
response = await client.receive_json()
while "uploaded_bytes" in response["event"]:
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,

View File

@@ -241,8 +241,6 @@ def _get_set_setting_side_effect(
def _get_set_program_options_side_effect(
event_queue: asyncio.Queue[list[EventMessage | Exception]],
):
"""Set programs side effect."""
async def set_program_options_side_effect(ha_id: str, *_, **kwargs) -> None:
await event_queue.put(
[
@@ -289,25 +287,9 @@ def _get_specific_appliance_side_effect(
pytest.fail(f"Mock didn't include appliance with id {ha_id}")
@pytest.fixture(name="client")
def mock_client(
appliances: list[HomeAppliance],
appliance: HomeAppliance | None,
request: pytest.FixtureRequest,
) -> MagicMock:
"""Fixture to mock Client from HomeConnect."""
mock = MagicMock(
autospec=HomeConnectClient,
)
event_queue: asyncio.Queue[list[EventMessage | Exception]] = asyncio.Queue()
async def add_events(events: list[EventMessage | Exception]) -> None:
await event_queue.put(events)
mock.add_events = add_events
def _get_set_program_option_side_effect(
event_queue: asyncio.Queue[list[EventMessage]],
):
async def set_program_option_side_effect(ha_id: str, *_, **kwargs) -> None:
event_key = EventKey(kwargs["option_key"])
await event_queue.put(
@@ -331,6 +313,28 @@ def mock_client(
]
)
return set_program_option_side_effect
@pytest.fixture(name="client")
def mock_client(
appliances: list[HomeAppliance],
appliance: HomeAppliance | None,
request: pytest.FixtureRequest,
) -> MagicMock:
"""Fixture to mock Client from HomeConnect."""
mock = MagicMock(
autospec=HomeConnectClient,
)
event_queue: asyncio.Queue[list[EventMessage | Exception]] = asyncio.Queue()
async def add_events(events: list[EventMessage | Exception]) -> None:
await event_queue.put(events)
mock.add_events = add_events
appliances = [appliance] if appliance else appliances
async def stream_all_events() -> AsyncGenerator[EventMessage]:
@@ -408,15 +412,9 @@ def mock_client(
),
)
mock.stop_program = AsyncMock()
mock.set_active_program_option = AsyncMock(
side_effect=_get_set_program_options_side_effect(event_queue),
)
mock.set_active_program_options = AsyncMock(
side_effect=_get_set_program_options_side_effect(event_queue),
)
mock.set_selected_program_option = AsyncMock(
side_effect=_get_set_program_options_side_effect(event_queue),
)
mock.set_selected_program_options = AsyncMock(
side_effect=_get_set_program_options_side_effect(event_queue),
)
@@ -437,10 +435,10 @@ def mock_client(
mock.get_active_program_options = AsyncMock(return_value=ArrayOfOptions([]))
mock.get_selected_program_options = AsyncMock(return_value=ArrayOfOptions([]))
mock.set_active_program_option = AsyncMock(
side_effect=set_program_option_side_effect
side_effect=_get_set_program_option_side_effect(event_queue)
)
mock.set_selected_program_option = AsyncMock(
side_effect=set_program_option_side_effect
side_effect=_get_set_program_option_side_effect(event_queue)
)
mock.side_effect = mock

View File

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

View File

@@ -66,7 +66,7 @@
'connected': True,
'e_number': 'HCS000000/07',
'ha_id': '8765432109876543210',
'name': 'AirConditioner',
'name': 'Air conditioner',
'programs': list([
'HeatingVentilationAirConditioning.AirConditioner.Program.ActiveClean',
'HeatingVentilationAirConditioning.AirConditioner.Program.Auto',

View File

@@ -0,0 +1,665 @@
"""Tests for home_connect fan entities."""
from collections.abc import Awaitable, Callable
from unittest.mock import AsyncMock, MagicMock
from aiohomeconnect.model import (
ArrayOfEvents,
Event,
EventKey,
EventMessage,
EventType,
HomeAppliance,
OptionKey,
ProgramDefinition,
ProgramKey,
)
from aiohomeconnect.model.error import (
ActiveProgramNotSetError,
HomeConnectApiError,
HomeConnectError,
SelectedProgramNotSetError,
)
from aiohomeconnect.model.program import (
ProgramDefinitionConstraints,
ProgramDefinitionOption,
)
import pytest
from homeassistant.components.fan import (
ATTR_PERCENTAGE,
ATTR_PRESET_MODE,
ATTR_PRESET_MODES,
DOMAIN as FAN_DOMAIN,
SERVICE_SET_PERCENTAGE,
SERVICE_SET_PRESET_MODE,
FanEntityFeature,
)
from homeassistant.components.home_connect.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_SUPPORTED_FEATURES,
STATE_UNAVAILABLE,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er
from tests.common import MockConfigEntry
@pytest.fixture
def platforms() -> list[str]:
"""Fixture to specify platforms to test."""
return [Platform.FAN]
@pytest.fixture(autouse=True)
def get_available_program_fixture(
client: MagicMock,
) -> None:
"""Mock get_available_program."""
client.get_available_program = AsyncMock(
return_value=ProgramDefinition(
ProgramKey.UNKNOWN,
options=[
ProgramDefinitionOption(
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
"Enumeration",
),
ProgramDefinitionOption(
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE,
"Enumeration",
),
],
)
)
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
async def test_paired_depaired_devices_flow(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
client: MagicMock,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
appliance: HomeAppliance,
) -> None:
"""Test that removed devices are correctly removed from and added to hass on API events."""
assert await integration_setup(client)
assert config_entry.state is ConfigEntryState.LOADED
device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)})
assert device
entity_entries = entity_registry.entities.get_entries_for_device_id(device.id)
assert entity_entries
await client.add_events(
[
EventMessage(
appliance.ha_id,
EventType.DEPAIRED,
data=ArrayOfEvents([]),
)
]
)
await hass.async_block_till_done()
device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)})
assert not device
for entity_entry in entity_entries:
assert not entity_registry.async_get(entity_entry.entity_id)
# Now that everything related to the device is removed, pair it again
await client.add_events(
[
EventMessage(
appliance.ha_id,
EventType.PAIRED,
data=ArrayOfEvents([]),
)
]
)
await hass.async_block_till_done()
assert device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)})
for entity_entry in entity_entries:
assert entity_registry.async_get(entity_entry.entity_id)
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
async def test_connected_devices(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
client: MagicMock,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
appliance: HomeAppliance,
) -> None:
"""Test that devices reconnect.
Specifically those devices whose settings, status, etc. could
not be obtained while disconnected and once connected, the entities are added.
"""
get_settings_original_mock = client.get_settings
get_all_programs_mock = client.get_all_programs
async def get_settings_side_effect(ha_id: str):
if ha_id == appliance.ha_id:
raise HomeConnectApiError(
"SDK.Error.HomeAppliance.Connection.Initialization.Failed"
)
return await get_settings_original_mock.side_effect(ha_id)
async def get_all_programs_side_effect(ha_id: str):
if ha_id == appliance.ha_id:
raise HomeConnectApiError(
"SDK.Error.HomeAppliance.Connection.Initialization.Failed"
)
return await get_all_programs_mock.side_effect(ha_id)
client.get_settings = AsyncMock(side_effect=get_settings_side_effect)
client.get_all_programs = AsyncMock(side_effect=get_all_programs_side_effect)
assert await integration_setup(client)
assert config_entry.state is ConfigEntryState.LOADED
client.get_settings = get_settings_original_mock
client.get_all_programs = get_all_programs_mock
device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)})
assert device
assert not entity_registry.async_get_entity_id(
Platform.FAN,
DOMAIN,
f"{appliance.ha_id}-air_conditioner",
)
await client.add_events(
[
EventMessage(
appliance.ha_id,
EventType.CONNECTED,
data=ArrayOfEvents([]),
)
]
)
await hass.async_block_till_done()
assert entity_registry.async_get_entity_id(
Platform.FAN,
DOMAIN,
f"{appliance.ha_id}-air_conditioner",
)
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
async def test_fan_entity_availability(
hass: HomeAssistant,
client: MagicMock,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
appliance: HomeAppliance,
) -> None:
"""Test if fan entities availability are based on the appliance connection state."""
entity_ids = [
"fan.air_conditioner",
]
assert await integration_setup(client)
assert config_entry.state is ConfigEntryState.LOADED
for entity_id in entity_ids:
state = hass.states.get(entity_id)
assert state
assert state.state != STATE_UNAVAILABLE
await client.add_events(
[
EventMessage(
appliance.ha_id,
EventType.DISCONNECTED,
ArrayOfEvents([]),
)
]
)
await hass.async_block_till_done()
for entity_id in entity_ids:
assert hass.states.is_state(entity_id, STATE_UNAVAILABLE)
await client.add_events(
[
EventMessage(
appliance.ha_id,
EventType.CONNECTED,
ArrayOfEvents([]),
)
]
)
await hass.async_block_till_done()
for entity_id in entity_ids:
state = hass.states.get(entity_id)
assert state
assert state.state != STATE_UNAVAILABLE
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
@pytest.mark.parametrize(
(
"set_active_program_options_side_effect",
"set_selected_program_options_side_effect",
"called_mock_method",
),
[
(
None,
SelectedProgramNotSetError("error.key"),
"set_active_program_option",
),
(
ActiveProgramNotSetError("error.key"),
None,
"set_selected_program_option",
),
],
)
async def test_speed_percentage_functionality(
hass: HomeAssistant,
client: MagicMock,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
appliance: HomeAppliance,
set_active_program_options_side_effect: ActiveProgramNotSetError | None,
set_selected_program_options_side_effect: SelectedProgramNotSetError | None,
called_mock_method: str,
) -> None:
"""Test speed percentage functionality."""
entity_id = "fan.air_conditioner"
option_key = OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE
if set_active_program_options_side_effect:
client.set_active_program_option.side_effect = (
set_active_program_options_side_effect
)
else:
assert set_selected_program_options_side_effect
client.set_selected_program_option.side_effect = (
set_selected_program_options_side_effect
)
called_mock: AsyncMock = getattr(client, called_mock_method)
assert await integration_setup(client)
assert config_entry.state is ConfigEntryState.LOADED
assert not hass.states.is_state(entity_id, "50")
await hass.services.async_call(
FAN_DOMAIN,
SERVICE_SET_PERCENTAGE,
{
ATTR_ENTITY_ID: entity_id,
ATTR_PERCENTAGE: 50,
},
blocking=True,
)
await hass.async_block_till_done()
called_mock.assert_called_once_with(
appliance.ha_id,
option_key=option_key,
value=50,
)
entity_state = hass.states.get(entity_id)
assert entity_state
assert entity_state.attributes[ATTR_PERCENTAGE] == 50
async def test_set_speed_raises_home_assistant_error_on_api_errors(
hass: HomeAssistant,
client: MagicMock,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
) -> None:
"""Test that setting a fan mode raises HomeAssistantError on API errors."""
assert await integration_setup(client)
assert config_entry.state is ConfigEntryState.LOADED
client.set_active_program_option.side_effect = HomeConnectError("Test error")
with pytest.raises(HomeAssistantError, match="Test error"):
await hass.services.async_call(
FAN_DOMAIN,
SERVICE_SET_PERCENTAGE,
{
ATTR_ENTITY_ID: "fan.air_conditioner",
ATTR_PERCENTAGE: 50,
},
blocking=True,
)
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
@pytest.mark.parametrize(
(
"set_active_program_options_side_effect",
"set_selected_program_options_side_effect",
"called_mock_method",
),
[
(
None,
SelectedProgramNotSetError("error.key"),
"set_active_program_option",
),
(
ActiveProgramNotSetError("error.key"),
None,
"set_selected_program_option",
),
],
)
@pytest.mark.parametrize(
("allowed_values", "expected_fan_modes"),
[
(
None,
["auto", "manual"],
),
(
[
"HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Automatic",
"HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Manual",
],
["auto", "manual"],
),
(
[
"HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Automatic",
],
["auto"],
),
(
[
"HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Manual",
"A.Non.Documented.Option",
],
["manual"],
),
],
)
async def test_preset_mode_functionality(
hass: HomeAssistant,
client: MagicMock,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
appliance: HomeAppliance,
allowed_values: list[str | None] | None,
expected_fan_modes: list[str],
set_active_program_options_side_effect: ActiveProgramNotSetError | None,
set_selected_program_options_side_effect: SelectedProgramNotSetError | None,
called_mock_method: str,
) -> None:
"""Test preset mode functionality."""
entity_id = "fan.air_conditioner"
option_key = (
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
)
if set_active_program_options_side_effect:
client.set_active_program_option.side_effect = (
set_active_program_options_side_effect
)
else:
assert set_selected_program_options_side_effect
client.set_selected_program_option.side_effect = (
set_selected_program_options_side_effect
)
called_mock: AsyncMock = getattr(client, called_mock_method)
client.get_available_program = AsyncMock(
return_value=ProgramDefinition(
ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO,
options=[
ProgramDefinitionOption(
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
"Enumeration",
constraints=ProgramDefinitionConstraints(
allowed_values=allowed_values
),
)
],
)
)
assert await integration_setup(client)
assert config_entry.state is ConfigEntryState.LOADED
entity_state = hass.states.get(entity_id)
assert entity_state
assert entity_state.attributes[ATTR_PRESET_MODES] == expected_fan_modes
assert entity_state.attributes[ATTR_PRESET_MODE] != expected_fan_modes[0]
await hass.services.async_call(
FAN_DOMAIN,
SERVICE_SET_PRESET_MODE,
{
ATTR_ENTITY_ID: entity_id,
ATTR_PRESET_MODE: expected_fan_modes[0],
},
blocking=True,
)
await hass.async_block_till_done()
called_mock.assert_called_once_with(
appliance.ha_id,
option_key=option_key,
value=allowed_values[0]
if allowed_values
else "HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Automatic",
)
entity_state = hass.states.get(entity_id)
assert entity_state
assert entity_state.attributes[ATTR_PRESET_MODE] == expected_fan_modes[0]
async def test_set_preset_mode_raises_home_assistant_error_on_api_errors(
hass: HomeAssistant,
client: MagicMock,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
) -> None:
"""Test that setting a fan mode raises HomeAssistantError on API errors."""
assert await integration_setup(client)
assert config_entry.state is ConfigEntryState.LOADED
client.set_active_program_option.side_effect = HomeConnectError("Test error")
with pytest.raises(HomeAssistantError, match="Test error"):
await hass.services.async_call(
FAN_DOMAIN,
SERVICE_SET_PRESET_MODE,
{
ATTR_ENTITY_ID: "fan.air_conditioner",
ATTR_PRESET_MODE: "auto",
},
blocking=True,
)
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
@pytest.mark.parametrize(
("option_key", "expected_fan_feature"),
[
(
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
FanEntityFeature.PRESET_MODE,
),
(
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE,
FanEntityFeature.SET_SPEED,
),
],
)
async def test_supported_features(
hass: HomeAssistant,
client: MagicMock,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
appliance: HomeAppliance,
option_key: OptionKey,
expected_fan_feature: FanEntityFeature,
) -> None:
"""Test that supported features are detected correctly."""
client.get_available_program = AsyncMock(
return_value=ProgramDefinition(
ProgramKey.UNKNOWN,
options=[
ProgramDefinitionOption(
option_key,
"Enumeration",
)
],
)
)
assert await integration_setup(client)
assert config_entry.state is ConfigEntryState.LOADED
entity_id = "fan.air_conditioner"
state = hass.states.get(entity_id)
assert state
assert state.attributes[ATTR_SUPPORTED_FEATURES] & expected_fan_feature
client.get_available_program = AsyncMock(
return_value=ProgramDefinition(
ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO,
options=[],
)
)
await client.add_events(
[
EventMessage(
appliance.ha_id,
EventType.NOTIFY,
data=ArrayOfEvents(
[
Event(
key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
raw_key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM.value,
timestamp=0,
level="",
handling="",
value=ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO,
)
]
),
)
]
)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state
assert not state.attributes[ATTR_SUPPORTED_FEATURES] & expected_fan_feature
client.get_available_program = AsyncMock(
return_value=ProgramDefinition(
ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO,
options=[
ProgramDefinitionOption(
option_key,
"Enumeration",
)
],
)
)
await client.add_events(
[
EventMessage(
appliance.ha_id,
EventType.NOTIFY,
data=ArrayOfEvents(
[
Event(
key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
raw_key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM.value,
timestamp=0,
level="",
handling="",
value=ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO.value,
)
]
),
)
]
)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state
assert state.attributes[ATTR_SUPPORTED_FEATURES] & expected_fan_feature
@pytest.mark.parametrize("appliance", ["AirConditioner"], indirect=True)
async def test_added_entity_automatically(
hass: HomeAssistant,
client: MagicMock,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
appliance: HomeAppliance,
) -> None:
"""Test that no fan entity is created if no fan options are available but when they are added later, the entity is created."""
entity_id = "fan.air_conditioner"
client.get_available_program = AsyncMock(
return_value=ProgramDefinition(
ProgramKey.UNKNOWN,
options=[
ProgramDefinitionOption(
OptionKey.LAUNDRY_CARE_WASHER_SPIN_SPEED,
"Enumeration",
)
],
)
)
assert await integration_setup(client)
assert config_entry.state is ConfigEntryState.LOADED
assert not hass.states.get(entity_id)
client.get_available_program = AsyncMock(
return_value=ProgramDefinition(
ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO,
options=[
ProgramDefinitionOption(
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
"Enumeration",
),
ProgramDefinitionOption(
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE,
"Enumeration",
),
],
)
)
await client.add_events(
[
EventMessage(
appliance.ha_id,
EventType.NOTIFY,
data=ArrayOfEvents(
[
Event(
key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
raw_key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM.value,
timestamp=0,
level="",
handling="",
value=ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO.value,
)
]
),
)
]
)
await hass.async_block_till_done()
assert hass.states.get(entity_id)

View File

@@ -42,7 +42,7 @@ def mock_lutron() -> Generator[MagicMock]:
# Mock a light
light = MagicMock()
light.name = "Test Light"
light.id = "light_id"
light.id = 1
light.uuid = "light_uuid"
light.legacy_uuid = "light_legacy_uuid"
light.is_dimmable = True
@@ -53,7 +53,7 @@ def mock_lutron() -> Generator[MagicMock]:
# Mock a switch
switch = MagicMock()
switch.name = "Test Switch"
switch.id = "switch_id"
switch.id = 2
switch.uuid = "switch_uuid"
switch.legacy_uuid = "switch_legacy_uuid"
switch.is_dimmable = False
@@ -64,7 +64,7 @@ def mock_lutron() -> Generator[MagicMock]:
# Mock a cover
cover = MagicMock()
cover.name = "Test Cover"
cover.id = "cover_id"
cover.id = 3
cover.uuid = "cover_uuid"
cover.legacy_uuid = "cover_legacy_uuid"
cover.type = "SYSTEM_SHADE"
@@ -74,6 +74,7 @@ def mock_lutron() -> Generator[MagicMock]:
# Mock a fan
fan = MagicMock()
fan.name = "Test Fan"
fan.id = 4
fan.uuid = "fan_uuid"
fan.legacy_uuid = "fan_legacy_uuid"
fan.type = "CEILING_FAN_TYPE"
@@ -108,7 +109,7 @@ def mock_lutron() -> Generator[MagicMock]:
# Mock an occupancy group
occ_group = MagicMock()
occ_group.name = "Test Occupancy"
occ_group.id = "occ_id"
occ_group.id = 5
occ_group.uuid = "occ_uuid"
occ_group.legacy_uuid = "occ_legacy_uuid"
occ_group.state = OccupancyGroup.State.VACANT

View File

@@ -40,7 +40,7 @@
'attributes': ReadOnlyDict({
'device_class': 'occupancy',
'friendly_name': 'Test Occupancy Occupancy',
'lutron_integration_id': 'occ_id',
'lutron_integration_id': 5,
}),
'context': <ANY>,
'entity_id': 'binary_sensor.test_occupancy_occupancy',

View File

@@ -41,7 +41,7 @@
'current_position': 0,
'friendly_name': 'Test Cover',
'is_closed': True,
'lutron_integration_id': 'cover_id',
'lutron_integration_id': 3,
'supported_features': <CoverEntityFeature: 7>,
}),
'context': <ANY>,

View File

@@ -45,7 +45,7 @@
'brightness': None,
'color_mode': None,
'friendly_name': 'Test Light',
'lutron_integration_id': 'light_id',
'lutron_integration_id': 1,
'supported_color_modes': list([
<ColorMode.BRIGHTNESS: 'brightness'>,
]),

View File

@@ -91,7 +91,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Test Switch',
'lutron_integration_id': 'switch_id',
'lutron_integration_id': 2,
}),
'context': <ANY>,
'entity_id': 'switch.test_switch',

View File

@@ -3,6 +3,7 @@
from unittest.mock import MagicMock, patch
from pylutron import OccupancyGroup
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import STATE_OFF, STATE_ON, Platform
@@ -12,6 +13,13 @@ from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
@pytest.fixture(autouse=True)
def setup_platforms():
"""Patch PLATFORMS for all tests in this file."""
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.BINARY_SENSOR]):
yield
async def test_binary_sensor_setup(
hass: HomeAssistant,
mock_lutron: MagicMock,
@@ -25,9 +33,8 @@ async def test_binary_sensor_setup(
occ_group = mock_lutron.areas[0].occupancy_group
occ_group.state = OccupancyGroup.State.VACANT
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.BINARY_SENSOR]):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)

View File

@@ -2,6 +2,7 @@
from unittest.mock import MagicMock, patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.cover import DOMAIN as COVER_DOMAIN
@@ -20,6 +21,13 @@ from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
@pytest.fixture(autouse=True)
def setup_platforms():
"""Patch PLATFORMS for all tests in this file."""
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.COVER]):
yield
async def test_cover_setup(
hass: HomeAssistant,
mock_lutron: MagicMock,
@@ -34,9 +42,8 @@ async def test_cover_setup(
cover.level = 0
cover.last_level.return_value = 0
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.COVER]):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)

View File

@@ -3,6 +3,7 @@
from unittest.mock import MagicMock, patch
from pylutron import Button
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import Platform
@@ -12,6 +13,13 @@ from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, async_capture_events, snapshot_platform
@pytest.fixture(autouse=True)
def setup_platforms():
"""Patch PLATFORMS for all tests in this file."""
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.EVENT]):
yield
async def test_event_setup(
hass: HomeAssistant,
mock_lutron: MagicMock,
@@ -22,9 +30,8 @@ async def test_event_setup(
"""Test event setup."""
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.EVENT]):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)

View File

@@ -2,6 +2,7 @@
from unittest.mock import MagicMock, patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.fan import (
@@ -18,6 +19,13 @@ from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
@pytest.fixture(autouse=True)
def setup_platforms():
"""Patch PLATFORMS for all tests in this file."""
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.FAN]):
yield
async def test_fan_setup(
hass: HomeAssistant,
mock_lutron: MagicMock,
@@ -32,9 +40,8 @@ async def test_fan_setup(
fan.level = 0
fan.last_level.return_value = 0
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.FAN]):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)

View File

@@ -25,6 +25,13 @@ from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
@pytest.fixture(autouse=True)
def setup_platforms():
"""Patch PLATFORMS for all tests in this file."""
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.LIGHT]):
yield
async def test_light_setup(
hass: HomeAssistant,
mock_lutron: MagicMock,
@@ -39,9 +46,8 @@ async def test_light_setup(
light.level = 0
light.last_level.return_value = 0
with patch("homeassistant.components.lutron.PLATFORMS", [Platform.LIGHT]):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)

View File

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

View File

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

View File

@@ -2,7 +2,6 @@
# name: test_diagnostics
dict({
'cap_available': True,
'ct_connected': True,
'device_info': dict({
'model': 'Home Pro',
'name': 'Ohme Home Pro',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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