Compare commits

..

1 Commits

Author SHA1 Message Date
Erik
30cced7472 Fix numerical entity trigger schema 2026-03-12 21:10:06 +01:00
141 changed files with 517 additions and 6889 deletions

4
CODEOWNERS generated
View File

@@ -1184,8 +1184,6 @@ build.json @home-assistant/supervisor
/tests/components/nzbget/ @chriscla
/homeassistant/components/obihai/ @dshokouhi @ejpenney
/tests/components/obihai/ @dshokouhi @ejpenney
/homeassistant/components/occupancy/ @home-assistant/core
/tests/components/occupancy/ @home-assistant/core
/homeassistant/components/octoprint/ @rfleming71
/tests/components/octoprint/ @rfleming71
/homeassistant/components/ohmconnect/ @robbiet480
@@ -1907,8 +1905,6 @@ build.json @home-assistant/supervisor
/tests/components/wiffi/ @mampfes
/homeassistant/components/wilight/ @leofig-rj
/tests/components/wilight/ @leofig-rj
/homeassistant/components/window/ @home-assistant/core
/tests/components/window/ @home-assistant/core
/homeassistant/components/wirelesstag/ @sergeymaysak
/homeassistant/components/withings/ @joostlek
/tests/components/withings/ @joostlek

View File

@@ -245,8 +245,6 @@ DEFAULT_INTEGRATIONS = {
"garage_door",
"gate",
"humidity",
"occupancy",
"window",
}
DEFAULT_INTEGRATIONS_RECOVERY_MODE = {
# These integrations are set up if recovery mode is activated.

View File

@@ -101,10 +101,7 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
assert method is not None
await method(self.device, state)
self.coordinator.data[self.device.serial_number].sensors[
self.entity_description.key
].value = state
self.async_write_ha_state()
await self.coordinator.async_request_refresh()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""

View File

@@ -11,11 +11,8 @@ 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__)
@@ -53,23 +50,6 @@ 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,10 +21,11 @@ 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 EVENT_TURN_ON
from .const import DOMAIN, EVENT_TURN_ON
from .coordinator import ArcamFmjConfigEntry, ArcamFmjCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -96,7 +97,14 @@ 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 = coordinator.device_info
self._attr_device_info = DeviceInfo(
identifiers={
(DOMAIN, uuid),
},
manufacturer="Arcam",
model="Arcam FMJ AVR",
name=device_name,
)
@property
def state(self) -> MediaPlayerState:

View File

@@ -152,7 +152,6 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"light",
"lock",
"media_player",
"occupancy",
"person",
"remote",
"scene",
@@ -162,7 +161,6 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"text",
"update",
"vacuum",
"window",
}

View File

@@ -32,7 +32,6 @@ 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
@@ -79,8 +78,6 @@ from .util import (
validate_password_stream,
)
UPLOAD_PROGRESS_DEBOUNCE_SECONDS = 1
@dataclass(frozen=True, kw_only=True, slots=True)
class NewBackup:
@@ -144,7 +141,6 @@ class CreateBackupStage(StrEnum):
ADDONS = "addons"
AWAIT_ADDON_RESTARTS = "await_addon_restarts"
DOCKER_CONFIG = "docker_config"
CLEANING_UP = "cleaning_up"
FINISHING_FILE = "finishing_file"
FOLDERS = "folders"
HOME_ASSISTANT = "home_assistant"
@@ -594,49 +590,23 @@ class BackupManager:
)
agent = self.backup_agents[agent_id]
latest_uploaded_bytes = 0
@callback
def _emit_upload_progress() -> None:
"""Emit the latest upload progress event."""
def on_upload_progress(*, bytes_uploaded: int, **kwargs: Any) -> None:
"""Handle upload progress."""
self.async_on_backup_event(
UploadBackupEvent(
manager_state=self.state,
agent_id=agent_id,
uploaded_bytes=latest_uploaded_bytes,
uploaded_bytes=bytes_uploaded,
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()
@@ -1291,13 +1261,6 @@ class BackupManager:
)
# delete old backups more numerous than copies
# try this regardless of agent errors above
self.async_on_backup_event(
CreateBackupEvent(
reason=None,
stage=CreateBackupStage.CLEANING_UP,
state=CreateBackupState.IN_PROGRESS,
)
)
await delete_backups_exceeding_configured_count(self)
finally:

View File

@@ -13,6 +13,6 @@
"integration_type": "system",
"iot_class": "cloud_push",
"loggers": ["acme", "hass_nabucasa", "snitun"],
"requirements": ["hass-nabucasa==2.0.0", "openai==2.21.0"],
"requirements": ["hass-nabucasa==1.15.0", "openai==2.21.0"],
"single_config_entry": true
}

View File

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

View File

@@ -1,101 +0,0 @@
"""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,19 +1,5 @@
{
"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,17 +33,6 @@
}
},
"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, "s2-pro"),
default=options.get(CONF_BACKEND, "s1"),
): SelectSelector(
SelectSelectorConfig(
options=[

View File

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

View File

@@ -179,9 +179,7 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
return PRESET_HOLIDAY
if self.data.summer_active:
return PRESET_SUMMER
if self.data.target_temperature == ON_API_TEMPERATURE or getattr(
self.data, "boost_active", False
):
if self.data.target_temperature == ON_API_TEMPERATURE:
return PRESET_BOOST
if self.data.target_temperature == self.data.comfort_temperature:
return PRESET_COMFORT

View File

@@ -2,18 +2,12 @@
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 (
CharacteristicNoAccess,
CharacteristicNotFound,
CommunicationFailure,
)
from gardena_bluetooth.parse import CharacteristicTime
from gardena_bluetooth.exceptions import CommunicationFailure
from homeassistant.components import bluetooth
from homeassistant.const import CONF_ADDRESS, Platform
@@ -29,7 +23,6 @@ from .coordinator import (
GardenaBluetoothConfigEntry,
GardenaBluetoothCoordinator,
)
from .util import async_get_product_type
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
@@ -58,41 +51,22 @@ 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 = entry.title
name = await client.read_char(DeviceConfiguration.custom_device_name, name)
await _update_timestamp(client, DeviceConfiguration.unix_timestamp)
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())
except (TimeoutError, CommunicationFailure, DeviceUnavailable) as exception:
await client.disconnect()
raise ConfigEntryNotReady(
@@ -109,7 +83,7 @@ async def async_setup_entry(
)
coordinator = GardenaBluetoothCoordinator(
hass, entry, LOGGER, client, set(chars.keys()), device, address
hass, entry, LOGGER, client, uuids, device, address
)
entry.runtime_data = coordinator

View File

@@ -34,14 +34,14 @@ class GardenaBluetoothBinarySensorEntityDescription(BinarySensorEntityDescriptio
DESCRIPTIONS = (
GardenaBluetoothBinarySensorEntityDescription(
key=Valve.connected_state.unique_id,
key=Valve.connected_state.uuid,
translation_key="valve_connected_state",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
char=Valve.connected_state,
),
GardenaBluetoothBinarySensorEntityDescription(
key=Sensor.connected_state.unique_id,
key=Sensor.connected_state.uuid,
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.char.unique_id in coordinator.characteristics
if description.key in coordinator.characteristics
]
async_add_entities(entities)

View File

@@ -30,7 +30,7 @@ class GardenaBluetoothButtonEntityDescription(ButtonEntityDescription):
DESCRIPTIONS = (
GardenaBluetoothButtonEntityDescription(
key=Reset.factory_reset.unique_id,
key=Reset.factory_reset.uuid,
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.char.unique_id in coordinator.characteristics
if description.key 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==2.1.0"]
"requirements": ["gardena-bluetooth==1.6.0"]
}

View File

@@ -46,7 +46,7 @@ class GardenaBluetoothNumberEntityDescription(NumberEntityDescription):
DESCRIPTIONS = (
GardenaBluetoothNumberEntityDescription(
key=Valve.manual_watering_time.unique_id,
key=Valve.manual_watering_time.uuid,
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.unique_id,
key=Valve.remaining_open_time.uuid,
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.unique_id,
key=DeviceConfiguration.rain_pause.uuid,
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.unique_id,
key=DeviceConfiguration.seasonal_adjust.uuid,
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.unique_id,
key=Sensor.threshold.uuid,
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.char.unique_id in coordinator.characteristics
if description.key in coordinator.characteristics
]
if Valve.remaining_open_time.unique_id in coordinator.characteristics:
if Valve.remaining_open_time.uuid 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.unique_id,
key=Valve.activation_reason.uuid,
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.unique_id,
key=Battery.battery_level.uuid,
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.unique_id,
key=Sensor.battery_level.uuid,
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.unique_id,
key=Sensor.value.uuid,
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.unique_id,
key=Sensor.type.uuid,
translation_key="sensor_type",
entity_category=EntityCategory.DIAGNOSTIC,
char=Sensor.type,
connected_state=Sensor.connected_state,
),
GardenaBluetoothSensorEntityDescription(
key=Sensor.measurement_timestamp.unique_id,
key=Sensor.measurement_timestamp.uuid,
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.char.unique_id in coordinator.characteristics
if description.key in coordinator.characteristics
]
if Valve.remaining_open_time.unique_id in coordinator.characteristics:
if Valve.remaining_open_time.uuid 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.unique_id,
Valve.manual_watering_time.unique_id,
Valve.remaining_open_time.unique_id,
Valve.state.uuid,
Valve.manual_watering_time.uuid,
Valve.remaining_open_time.uuid,
}
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.unique_id}"
self._attr_unique_id = f"{coordinator.address}-{Valve.state.uuid}"
self._attr_translation_key = "state"
self._attr_is_on = None
self._attr_entity_registry_enabled_default = False

View File

@@ -1,51 +0,0 @@
"""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.unique_id,
Valve.manual_watering_time.unique_id,
Valve.remaining_open_time.unique_id,
Valve.state.uuid,
Valve.manual_watering_time.uuid,
Valve.remaining_open_time.uuid,
}
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.unique_id}"
self._attr_unique_id = f"{coordinator.address}-{Valve.state.uuid}"
def _handle_coordinator_update(self) -> None:
self._attr_is_closed = not self.coordinator.get_cached(Valve.state)

View File

@@ -7,12 +7,7 @@ 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,
OAuth2TokenRequestError,
OAuth2TokenRequestReauthError,
)
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.config_entry_oauth2_flow import (
OAuth2Session,
@@ -44,11 +39,11 @@ async def async_setup_entry(
session = OAuth2Session(hass, entry, implementation)
try:
await session.async_ensure_token_valid()
except OAuth2TokenRequestReauthError as err:
raise ConfigEntryAuthFailed(
"OAuth session is not valid, reauth required"
) from err
except OAuth2TokenRequestError as err:
except aiohttp.ClientResponseError as err:
if 400 <= err.status < 500:
raise ConfigEntryAuthFailed(
"OAuth session is not valid, reauth required"
) from err
raise ConfigEntryNotReady from err
except aiohttp.ClientError as err:
raise ConfigEntryNotReady from err

View File

@@ -1,28 +1,4 @@
"""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)
"""
"""The Growatt server PV inverter sensor integration."""
from collections.abc import Mapping
from json import JSONDecodeError
@@ -49,7 +25,6 @@ from .const import (
DOMAIN,
LOGIN_INVALID_AUTH_CODE,
PLATFORMS,
V1_API_ERROR_NO_PRIVILEGE,
)
from .coordinator import GrowattConfigEntry, GrowattCoordinator
from .models import GrowattRuntimeData
@@ -252,12 +227,8 @@ 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.error_msg or str(e)} (Code: {e.error_code})"
f"API error during device list: {e} (Code: {getattr(e, 'error_code', None)}, Message: {getattr(e, 'error_msg', None)})"
) from e
devices = devices_dict.get("devices", [])
# Only MIN device (type = 7) support implemented in current V1 API
@@ -301,7 +272,6 @@ 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,6 +1,5 @@
"""Config flow for growatt server integration."""
from collections.abc import Mapping
import logging
from typing import Any
@@ -32,11 +31,8 @@ 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__)
@@ -64,137 +60,6 @@ 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:
@@ -264,11 +129,9 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.error(
"Growatt V1 API error: %s (Code: %s)",
e.error_msg or str(e),
e.error_code,
getattr(e, "error_code", None),
)
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})
return self._async_show_token_form({"base": ERROR_INVALID_AUTH})
except (ValueError, KeyError, TypeError, AttributeError) as ex:
_LOGGER.error(
"Invalid response format during Growatt V1 API plant list: %s", ex

View File

@@ -40,17 +40,8 @@ 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,11 +13,7 @@ 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 (
ConfigEntryAuthFailed,
HomeAssistantError,
ServiceValidationError,
)
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
@@ -27,8 +23,6 @@ from .const import (
BATT_MODE_LOAD_FIRST,
DEFAULT_URL,
DOMAIN,
LOGIN_INVALID_AUTH_CODE,
V1_API_ERROR_NO_PRIVILEGE,
)
from .models import GrowattRuntimeData
@@ -69,7 +63,6 @@ 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]
@@ -95,14 +88,7 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# login only required for classic API
if self.api_version == "classic":
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}")
self.api.login(self.username, self.password)
if self.device_type == "total":
if self.api_version == "v1":
@@ -114,16 +100,7 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# todayEnergy -> today_energy
# totalEnergy -> total_energy
# invTodayPpv -> current_power
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 = self.api.plant_energy_overview(self.plant_id)
total_info["todayEnergy"] = total_info["today_energy"]
total_info["totalEnergy"] = total_info["total_energy"]
total_info["invTodayPpv"] = total_info["current_power"]
@@ -145,10 +122,6 @@ 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: done
reauthentication-flow: todo
test-coverage: done
# Gold

View File

@@ -3,8 +3,7 @@
"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",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
"no_plants": "No plants have been found on this account"
},
"error": {
"cannot_connect": "Cannot connect to Growatt servers. Please check your internet connection and try again.",
@@ -14,7 +13,7 @@
"password_auth": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"region": "Server region",
"url": "Server region",
"username": "[%key:common::config_flow::data::username%]"
},
"title": "Enter your Growatt login credentials"
@@ -25,29 +24,19 @@
},
"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": {
"region": "[%key:component::growatt_server::config::step::password_auth::data::region%]",
"token": "API token"
"token": "API Token",
"url": "Server region"
},
"description": "Token authentication is only supported for MIN/TLX devices. For other device types, please use username/password authentication.",
"title": "Enter your API token"
},
"user": {
"description": "Note: Token authentication is currently only supported for MIN/TLX devices. For other device types, please use username/password authentication.",
"description": "Note: API Token authentication is currently only supported for MIN/TLX devices. For other device types, please use Username & Password authentication.",
"menu_options": {
"password_auth": "Username/password",
"token_auth": "API token (MIN/TLX only)"
"password_auth": "Username & Password",
"token_auth": "API Token (MIN/TLX only)"
},
"title": "Choose authentication method"
}

View File

@@ -38,7 +38,6 @@ 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
from .entity import HomeConnectEntity, HomeConnectOptionEntity
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[HomeConnectEntity],
list[HomeConnectOptionEntity],
],
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[HomeConnectEntity],
list[HomeConnectOptionEntity],
]
| 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[HomeConnectEntity],
list[HomeConnectOptionEntity],
]
| None = None,
) -> None:

View File

@@ -1,235 +0,0 @@
"""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[HomeConnectEntity]:
) -> list[HomeConnectOptionEntity]:
"""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[HomeConnectEntity]:
) -> list[HomeConnectOptionEntity]:
"""Get a list of entities."""
return [
HomeConnectSelectOptionEntity(appliance_coordinator, desc)

View File

@@ -119,18 +119,6 @@
"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[HomeConnectEntity]:
) -> list[HomeConnectOptionEntity]:
"""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 relative humidity changes.",
"description": "Triggers when the humidity changes.",
"fields": {
"above": {
"description": "Only trigger when relative humidity is above this value.",
"description": "Only trigger when humidity is above this value.",
"name": "Above"
},
"below": {
"description": "Only trigger when relative humidity is below this value.",
"description": "Only trigger when humidity is below this value.",
"name": "Below"
}
},
"name": "Relative humidity changed"
"name": "Humidity changed"
},
"crossed_threshold": {
"description": "Triggers when the relative humidity crosses a threshold.",
"description": "Triggers when the humidity crosses a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::humidity::common::trigger_behavior_description%]",
@@ -62,7 +62,7 @@
"name": "Upper limit"
}
},
"name": "Relative humidity crossed threshold"
"name": "Humidity crossed threshold"
}
}
}

View File

@@ -19,7 +19,6 @@
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, ProductType.UNKNOWN):
if product_type not in (ProductType.MOWER, None):
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==2.1.0"]
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==1.6.0"]
}

View File

@@ -12,5 +12,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["intellifire4py"],
"requirements": ["intellifire4py==4.4.0"]
"requirements": ["intellifire4py==4.3.1"]
}

View File

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

View File

@@ -7,7 +7,7 @@ import csv
import dataclasses
import logging
import os
from typing import TYPE_CHECKING, Any, Self, cast, final, override
from typing import TYPE_CHECKING, Any, Self, cast, final
from propcache.api import cached_property
import voluptuous as vol
@@ -272,18 +272,6 @@ 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
@@ -318,171 +306,7 @@ def filter_turn_on_params(light: LightEntity, params: dict[str, Any]) -> dict[st
return params
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:
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: C901
"""Expose light control via state machine and services."""
component = hass.data[DATA_COMPONENT] = EntityComponent[LightEntity](
_LOGGER, DOMAIN, hass, SCAN_INTERVAL
@@ -506,15 +330,177 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
base["params"] = data
return base
async def async_handle_light_on_service(
async def async_handle_light_on_service( # noqa: C901
light: LightEntity, call: ServiceCall
) -> None:
"""Handle turning a light on.
If brightness is set to 0, this service will turn the light off.
"""
params = process_turn_on_params(hass, light, call.data["params"])
params: dict[str, Any] = dict(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:
@@ -524,7 +510,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
light: LightEntity, call: ServiceCall
) -> None:
"""Handle turning off a light."""
params = process_turn_off_params(hass, light, call.data["params"])
params = dict(call.data["params"])
if ATTR_TRANSITION not in params:
profiles.apply_default(light.entity_id, True, params)
await light.async_turn_off(**filter_turn_off_params(light, params))
@@ -532,7 +521,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
light: LightEntity, call: ServiceCall
) -> None:
"""Handle toggling a light."""
await light.async_toggle(**call.data["params"])
if light.is_on:
await async_handle_light_off_service(light, call)
else:
await async_handle_light_on_service(light, call)
# Listen for light on and light off service calls.
@@ -1054,15 +1046,3 @@ 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

@@ -72,7 +72,6 @@ ABBREVIATIONS = {
"fan_mode_stat_t": "fan_mode_state_topic",
"frc_upd": "force_update",
"g_tpl": "green_template",
"grp": "group",
"hs_cmd_t": "hs_command_topic",
"hs_cmd_tpl": "hs_command_template",
"hs_stat_t": "hs_state_topic",

View File

@@ -10,7 +10,6 @@ from homeassistant.helpers import config_validation as cv
from .const import (
CONF_COMMAND_TOPIC,
CONF_ENCODING,
CONF_GROUP,
CONF_QOS,
CONF_RETAIN,
CONF_STATE_TOPIC,
@@ -24,7 +23,6 @@ from .util import valid_publish_topic, valid_qos_schema, valid_subscribe_topic
SCHEMA_BASE = {
vol.Optional(CONF_QOS, default=DEFAULT_QOS): valid_qos_schema,
vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string,
vol.Optional(CONF_GROUP): vol.All(cv.ensure_list, [cv.string]),
}
MQTT_BASE_SCHEMA = vol.Schema(SCHEMA_BASE)

View File

@@ -109,7 +109,6 @@ CONF_FLASH_TIME_SHORT = "flash_time_short"
CONF_GET_POSITION_TEMPLATE = "position_template"
CONF_GET_POSITION_TOPIC = "position_topic"
CONF_GREEN_TEMPLATE = "green_template"
CONF_GROUP = "group"
CONF_HS_COMMAND_TEMPLATE = "hs_command_template"
CONF_HS_COMMAND_TOPIC = "hs_command_topic"
CONF_HS_STATE_TOPIC = "hs_state_topic"

View File

@@ -48,7 +48,6 @@ from homeassistant.helpers.event import (
async_track_device_registry_updated_event,
async_track_entity_registry_updated_event,
)
from homeassistant.helpers.group import IntegrationSpecificGroup
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
from homeassistant.helpers.typing import (
@@ -79,7 +78,6 @@ from .const import (
CONF_ENABLED_BY_DEFAULT,
CONF_ENCODING,
CONF_ENTITY_PICTURE,
CONF_GROUP,
CONF_HW_VERSION,
CONF_IDENTIFIERS,
CONF_JSON_ATTRS_TEMPLATE,
@@ -135,7 +133,6 @@ MQTT_ATTRIBUTES_BLOCKED = {
"device_class",
"device_info",
"entity_category",
"entity_id",
"entity_picture",
"entity_registry_enabled_default",
"extra_state_attributes",
@@ -463,7 +460,7 @@ def async_setup_entity_entry_helper(
class MqttAttributesMixin(Entity):
"""Mixin used for platforms that support JSON attributes and group entities."""
"""Mixin used for platforms that support JSON attributes."""
_attributes_extra_blocked: frozenset[str] = frozenset()
_attr_tpl: Callable[[ReceivePayloadType], ReceivePayloadType] | None = None
@@ -471,13 +468,10 @@ class MqttAttributesMixin(Entity):
[MessageCallbackType, set[str] | None, ReceiveMessage], None
]
_process_update_extra_state_attributes: Callable[[dict[str, Any]], None]
group: IntegrationSpecificGroup | None
def __init__(self, config: ConfigType) -> None:
"""Initialize the JSON attributes and handle group entities."""
"""Initialize the JSON attributes mixin."""
self._attributes_sub_state: dict[str, EntitySubscription] = {}
if CONF_GROUP in config:
self.group = IntegrationSpecificGroup(self, config[CONF_GROUP])
self._attributes_config = config
async def async_added_to_hass(self) -> None:
@@ -488,16 +482,6 @@ class MqttAttributesMixin(Entity):
def attributes_prepare_discovery_update(self, config: DiscoveryInfoType) -> None:
"""Handle updated discovery message."""
if CONF_GROUP in config:
if self.group is not None:
self.group.member_unique_ids = config[CONF_GROUP]
else:
_LOGGER.info(
"Group member update received for entity %s, "
"but this entity was not initialized with the `group` option. "
"Reload the MQTT integration or restart Home Assistant to activate"
)
self._attributes_config = config
self._attributes_prepare_subscribe_topics()
@@ -559,7 +543,7 @@ class MqttAttributesMixin(Entity):
_LOGGER.warning("Erroneous JSON: %s", payload)
else:
if isinstance(json_dict, dict):
filtered_dict: dict[str, Any] = {
filtered_dict = {
k: v
for k, v in json_dict.items()
if k not in MQTT_ATTRIBUTES_BLOCKED

View File

@@ -1,17 +0,0 @@
"""Integration for occupancy triggers."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
DOMAIN = "occupancy"
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
__all__ = []
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
return True

View File

@@ -1,10 +0,0 @@
{
"triggers": {
"cleared": {
"trigger": "mdi:home-outline"
},
"detected": {
"trigger": "mdi:home-account"
}
}
}

View File

@@ -1,8 +0,0 @@
{
"domain": "occupancy",
"name": "Occupancy",
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/occupancy",
"integration_type": "system",
"quality_scale": "internal"
}

View File

@@ -1,38 +0,0 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted occupancy sensors to trigger on.",
"trigger_behavior_name": "Behavior"
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"title": "Occupancy",
"triggers": {
"cleared": {
"description": "Triggers after one or more occupancy sensors stop detecting occupancy.",
"fields": {
"behavior": {
"description": "[%key:component::occupancy::common::trigger_behavior_description%]",
"name": "[%key:component::occupancy::common::trigger_behavior_name%]"
}
},
"name": "Occupancy cleared"
},
"detected": {
"description": "Triggers after one or more occupancy sensors start detecting occupancy.",
"fields": {
"behavior": {
"description": "[%key:component::occupancy::common::trigger_behavior_description%]",
"name": "[%key:component::occupancy::common::trigger_behavior_name%]"
}
},
"name": "Occupancy detected"
}
}
}

View File

@@ -1,57 +0,0 @@
"""Provides triggers for occupancy."""
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
)
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import (
EntityTargetStateTriggerBase,
EntityTriggerBase,
Trigger,
get_device_class_or_undefined,
)
class _OccupancyBinaryTriggerBase(EntityTriggerBase):
"""Base trigger for occupancy binary sensor state changes."""
_domains = {BINARY_SENSOR_DOMAIN}
def entity_filter(self, entities: set[str]) -> set[str]:
"""Filter entities by occupancy device class."""
entities = super().entity_filter(entities)
return {
entity_id
for entity_id in entities
if get_device_class_or_undefined(self._hass, entity_id)
== BinarySensorDeviceClass.OCCUPANCY
}
class OccupancyDetectedTrigger(
_OccupancyBinaryTriggerBase, EntityTargetStateTriggerBase
):
"""Trigger for occupancy detected (binary sensor ON)."""
_to_states = {STATE_ON}
class OccupancyClearedTrigger(
_OccupancyBinaryTriggerBase, EntityTargetStateTriggerBase
):
"""Trigger for occupancy cleared (binary sensor OFF)."""
_to_states = {STATE_OFF}
TRIGGERS: dict[str, type[Trigger]] = {
"detected": OccupancyDetectedTrigger,
"cleared": OccupancyClearedTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for occupancy."""
return TRIGGERS

View File

@@ -1,25 +0,0 @@
.trigger_common_fields: &trigger_common_fields
behavior:
required: true
default: any
selector:
select:
translation_key: trigger_behavior
options:
- first
- last
- any
detected:
fields: *trigger_common_fields
target:
entity:
- domain: binary_sensor
device_class: occupancy
cleared:
fields: *trigger_common_fields
target:
entity:
- domain: binary_sensor
device_class: occupancy

View File

@@ -19,5 +19,6 @@ 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.7.0"]
"requirements": ["ohme==1.6.0"]
}

View File

@@ -431,7 +431,6 @@ 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": "silver",
"quality_scale": "bronze",
"requirements": ["opower==0.17.0"]
}

View File

@@ -39,7 +39,7 @@ rules:
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage: done
test-coverage: todo
# 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.9.0"]
"requirements": ["python-otbr-api==2.8.0"]
}

View File

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

View File

@@ -217,6 +217,9 @@
"energy_left": {
"default": "mdi:battery"
},
"energy_remaining": {
"default": "mdi:battery-medium"
},
"generator_power": {
"default": "mdi:generator-stationary"
},

View File

@@ -299,6 +299,14 @@ BATTERY_DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=2,
),
TessieSensorEntityDescription(
key="energy_remaining",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY_STORAGE,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=1,
),
TessieSensorEntityDescription(
key="lifetime_energy_used",
state_class=SensorStateClass.TOTAL_INCREASING,

View File

@@ -458,6 +458,9 @@
"energy_left": {
"name": "Energy left"
},
"energy_remaining": {
"name": "Energy remaining"
},
"generator_energy_exported": {
"name": "Generator exported"
},

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.9.0", "pyroute2==0.7.5"],
"requirements": ["python-otbr-api==2.8.0", "pyroute2==0.7.5"],
"single_config_entry": true,
"zeroconf": ["_meshcop._udp.local."]
}

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["waterfurnace"],
"quality_scale": "legacy",
"requirements": ["waterfurnace==1.6.2"]
"requirements": ["waterfurnace==1.5.1"]
}

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aiowebdav2"],
"quality_scale": "bronze",
"requirements": ["aiowebdav2==0.6.2"]
"requirements": ["aiowebdav2==0.6.1"]
}

View File

@@ -1,17 +0,0 @@
"""Integration for window triggers."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
DOMAIN = "window"
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
__all__ = []
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
return True

View File

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

View File

@@ -1,8 +0,0 @@
{
"domain": "window",
"name": "Window",
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/window",
"integration_type": "system",
"quality_scale": "internal"
}

View File

@@ -1,38 +0,0 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted windows to trigger on.",
"trigger_behavior_name": "Behavior"
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"title": "Window",
"triggers": {
"closed": {
"description": "Triggers after one or more windows close.",
"fields": {
"behavior": {
"description": "[%key:component::window::common::trigger_behavior_description%]",
"name": "[%key:component::window::common::trigger_behavior_name%]"
}
},
"name": "Window closed"
},
"opened": {
"description": "Triggers after one or more windows open.",
"fields": {
"behavior": {
"description": "[%key:component::window::common::trigger_behavior_description%]",
"name": "[%key:component::window::common::trigger_behavior_name%]"
}
},
"name": "Window opened"
}
}
}

View File

@@ -1,36 +0,0 @@
"""Provides triggers for windows."""
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
)
from homeassistant.components.cover import (
DOMAIN as COVER_DOMAIN,
CoverDeviceClass,
make_cover_closed_trigger,
make_cover_opened_trigger,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger
DEVICE_CLASSES_WINDOW: dict[str, str] = {
BINARY_SENSOR_DOMAIN: BinarySensorDeviceClass.WINDOW,
COVER_DOMAIN: CoverDeviceClass.WINDOW,
}
TRIGGERS: dict[str, type[Trigger]] = {
"opened": make_cover_opened_trigger(
device_classes=DEVICE_CLASSES_WINDOW,
domains={BINARY_SENSOR_DOMAIN, COVER_DOMAIN},
),
"closed": make_cover_closed_trigger(
device_classes=DEVICE_CLASSES_WINDOW,
domains={BINARY_SENSOR_DOMAIN, COVER_DOMAIN},
),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for windows."""
return TRIGGERS

View File

@@ -1,29 +0,0 @@
.trigger_common_fields: &trigger_common_fields
behavior:
required: true
default: any
selector:
select:
translation_key: trigger_behavior
options:
- first
- last
- any
closed:
fields: *trigger_common_fields
target:
entity:
- domain: binary_sensor
device_class: window
- domain: cover
device_class: window
opened:
fields: *trigger_common_fields
target:
entity:
- domain: binary_sensor
device_class: window
- domain: cover
device_class: window

View File

@@ -2,16 +2,13 @@
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,
@@ -32,10 +29,6 @@ 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,
@@ -66,42 +59,6 @@ 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):
@@ -125,14 +82,6 @@ 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
#
@@ -178,7 +127,6 @@ class OpeningStateZWaveJSEntityDescription(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, ...] = (
@@ -254,6 +202,26 @@ 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,
@@ -396,10 +364,6 @@ 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
@@ -442,13 +406,6 @@ 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":
@@ -585,51 +542,6 @@ 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."""
@@ -674,392 +586,7 @@ 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,7 +207,3 @@ 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,10 +16,6 @@ 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
@@ -57,8 +53,6 @@ from .const import (
DOMAIN,
LIB_LOGGER,
LOGGER,
NOTIFICATION_ACCESS_CONTROL_PROPERTY,
OPENING_STATE_PROPERTY_KEY,
)
from .models import ZwaveJSConfigEntry
@@ -132,37 +126,6 @@ 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,22 +859,13 @@ class ZWaveListSensor(ZwaveSensor):
)
# Entity class attributes
# Notification sensors use the notification event label as the name
# (property_key_name/metadata.label, falling back to property_name)
# Notification sensors have the following name mapping (variables are property
# keys, name is property)
# https://github.com/zwave-js/node-zwave-js/blob/master/packages/config/config/notifications.json
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],
)
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 self.info.primary_value.metadata.states:
self._attr_device_class = SensorDeviceClass.ENUM
self._attr_options = list(info.primary_value.metadata.states.values())

View File

@@ -782,8 +782,6 @@ async def entity_service_call(
all_referenced,
)
entity_candidates = [e for e in entity_candidates if e.available]
if not target_all_entities:
assert referenced is not None
# Only report on explicit referenced entities
@@ -794,6 +792,9 @@ async def entity_service_call(
entities: list[Entity] = []
for entity in entity_candidates:
if not entity.available:
continue
# Skip entities that don't have the required device class.
if (
entity_device_classes is not None

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): vol.All(
vol.Required(CONF_OPTIONS, default={}): 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==2.0.0
hass-nabucasa==1.15.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==2.0.0",
"hass-nabucasa==1.15.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==2.0.0
hass-nabucasa==1.15.0
hassil==3.5.0
home-assistant-bluetooth==1.13.1
home-assistant-intents==2026.3.3

16
requirements_all.txt generated
View File

@@ -443,7 +443,7 @@ aiowaqi==3.1.0
aiowatttime==0.1.1
# homeassistant.components.webdav
aiowebdav2==0.6.2
aiowebdav2==0.6.1
# homeassistant.components.webostv
aiowebostv==0.7.5
@@ -1026,7 +1026,7 @@ gTTS==2.5.3
# homeassistant.components.gardena_bluetooth
# homeassistant.components.husqvarna_automower_ble
gardena-bluetooth==2.1.0
gardena-bluetooth==1.6.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==2.0.0
hass-nabucasa==1.15.0
# homeassistant.components.splunk
hass-splunk==0.1.4
@@ -1322,7 +1322,7 @@ inkbird-ble==1.1.1
insteon-frontend-home-assistant==0.6.1
# homeassistant.components.intellifire
intellifire4py==4.4.0
intellifire4py==4.3.1
# homeassistant.components.iometer
iometer==0.4.0
@@ -1663,7 +1663,7 @@ odp-amsterdam==6.1.2
oemthermostat==1.1.1
# homeassistant.components.ohme
ohme==1.7.0
ohme==1.6.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.3
pyjvcprojector==2.0.2
# 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.9.0
python-otbr-api==2.8.0
# homeassistant.components.overseerr
python-overseerr==0.9.0
@@ -3247,7 +3247,7 @@ wallbox==0.9.0
watchdog==6.0.0
# homeassistant.components.waterfurnace
waterfurnace==1.6.2
waterfurnace==1.5.1
# homeassistant.components.watergate
watergate-local-api==2025.1.0

View File

@@ -428,7 +428,7 @@ aiowaqi==3.1.0
aiowatttime==0.1.1
# homeassistant.components.webdav
aiowebdav2==0.6.2
aiowebdav2==0.6.1
# homeassistant.components.webostv
aiowebostv==0.7.5
@@ -905,7 +905,7 @@ gTTS==2.5.3
# homeassistant.components.gardena_bluetooth
# homeassistant.components.husqvarna_automower_ble
gardena-bluetooth==2.1.0
gardena-bluetooth==1.6.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==2.0.0
hass-nabucasa==1.15.0
# homeassistant.components.splunk
hass-splunk==0.1.4
@@ -1171,7 +1171,7 @@ inkbird-ble==1.1.1
insteon-frontend-home-assistant==0.6.1
# homeassistant.components.intellifire
intellifire4py==4.4.0
intellifire4py==4.3.1
# homeassistant.components.iometer
iometer==0.4.0
@@ -1449,7 +1449,7 @@ objgraph==3.5.0
odp-amsterdam==6.1.2
# homeassistant.components.ohme
ohme==1.7.0
ohme==1.6.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.3
pyjvcprojector==2.0.2
# 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.9.0
python-otbr-api==2.8.0
# homeassistant.components.overseerr
python-overseerr==0.9.0
@@ -2735,7 +2735,7 @@ wallbox==0.9.0
watchdog==6.0.0
# homeassistant.components.waterfurnace
waterfurnace==1.6.2
waterfurnace==1.5.1
# homeassistant.components.watergate
watergate-local-api==2025.1.0

View File

@@ -103,7 +103,6 @@ NO_IOT_CLASS = [
"lovelace",
"media_source",
"my",
"occupancy",
"onboarding",
"panel_custom",
"plant",
@@ -124,7 +123,6 @@ NO_IOT_CLASS = [
"web_rtc",
"webhook",
"websocket_api",
"window",
"zone",
]

View File

@@ -2138,7 +2138,6 @@ NO_QUALITY_SCALE = [
"lovelace",
"media_source",
"my",
"occupancy",
"onboarding",
"panel_custom",
"proxy",
@@ -2158,7 +2157,6 @@ NO_QUALITY_SCALE = [
"web_rtc",
"webhook",
"websocket_api",
"window",
"zone",
]

View File

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

View File

@@ -5,7 +5,6 @@ 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
@@ -48,14 +47,12 @@ 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,
@@ -68,7 +65,6 @@ from .common import (
setup_backup_platform,
)
from tests.common import async_fire_time_changed
from tests.typing import ClientSessionGenerator, WebSocketGenerator
_EXPECTED_FILES = [
@@ -600,17 +596,6 @@ 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,
"stage": CreateBackupStage.CLEANING_UP,
"state": CreateBackupState.IN_PROGRESS,
}
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
@@ -858,17 +843,6 @@ 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": None,
"stage": CreateBackupStage.CLEANING_UP,
"state": CreateBackupState.IN_PROGRESS,
}
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
@@ -1427,10 +1401,7 @@ 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",
@@ -1623,10 +1594,7 @@ 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",
@@ -2741,10 +2709,7 @@ 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,
@@ -3561,17 +3526,6 @@ 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,
"stage": CreateBackupStage.CLEANING_UP,
"state": CreateBackupState.IN_PROGRESS,
}
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
@@ -3807,117 +3761,25 @@ async def test_upload_progress_event(
result = await ws_client.receive_json()
assert result["event"]["stage"] == CreateBackupStage.UPLOAD_TO_AGENTS
# Collect all upload progress events until the finishing backup stage event
progress_events = []
# Upload progress events for the remote agent
result = await ws_client.receive_json()
while "uploaded_bytes" in result["event"]:
progress_events.append(result["event"])
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
"agent_id": "test.remote",
"uploaded_bytes": 500,
"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"]
assert result["event"]["stage"] == CreateBackupStage.CLEANING_UP
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
"agent_id": "test.remote",
"uploaded_bytes": 1000,
"total_bytes": ANY,
}
result = await ws_client.receive_json()
assert result["event"]["state"] == CreateBackupState.COMPLETED
result = await ws_client.receive_json()
assert result["event"] == {"manager_state": BackupManagerState.IDLE}
async def test_upload_progress_debounced(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
generate_backup_id: MagicMock,
) -> None:
"""Test that rapid upload progress events are debounced.
Verify that when the on_progress callback is called multiple times during
the debounce cooldown period, only the latest event is fired.
"""
agent_ids = ["test.remote"]
mock_agents = await setup_backup_integration(hass, remote_agents=["test.remote"])
manager = hass.data[DATA_MANAGER]
remote_agent = mock_agents["test.remote"]
progress_done = asyncio.Event()
upload_done = asyncio.Event()
async def upload_with_progress(**kwargs: Any) -> None:
"""Upload and report progress."""
on_progress = kwargs["on_progress"]
# First call fires immediately
on_progress(bytes_uploaded=100)
# These two are buffered during cooldown; 1000 should replace 500
on_progress(bytes_uploaded=500)
on_progress(bytes_uploaded=1000)
progress_done.set()
await upload_done.wait()
remote_agent.async_upload_backup.side_effect = upload_with_progress
# Subscribe directly to collect all events
events: list[Any] = []
manager.async_subscribe_events(events.append)
ws_client = await hass_ws_client(hass)
with patch("pathlib.Path.open", mock_open(read_data=b"test")):
await ws_client.send_json_auto_id(
{"type": "backup/generate", "agent_ids": agent_ids}
)
result = await ws_client.receive_json()
assert result["success"] is True
# Wait for upload to reach the sync point (progress reported, upload paused)
await progress_done.wait()
# At this point the debouncer's cooldown timer is pending.
# The first event (100 bytes) fired immediately, 500 and 1000 are buffered.
remote_events = [
e
for e in events
if isinstance(e, UploadBackupEvent) and e.agent_id == "test.remote"
]
assert len(remote_events) == 1
assert remote_events[0].uploaded_bytes == 100
# Advance time past the cooldown to trigger the debouncer timer.
# This fires the coalesced event: 500 was replaced by 1000.
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2))
remote_events = [
e
for e in events
if isinstance(e, UploadBackupEvent) and e.agent_id == "test.remote"
]
assert len(remote_events) == 2
assert remote_events[0].uploaded_bytes == 100
assert remote_events[1].uploaded_bytes == 1000
# Let the upload finish
upload_done.set()
# Fire pending timers so the backup task can complete
async_fire_time_changed(
hass, dt_util.utcnow() + timedelta(seconds=10), fire_all=True
)
await hass.async_block_till_done()
# Check the final 100% progress event is sent, that is sent for every agent
remote_events = [
e
for e in events
if isinstance(e, UploadBackupEvent) and e.agent_id == "test.remote"
]
assert len(remote_events) == 3
assert remote_events[2].uploaded_bytes == remote_events[2].total_bytes

View File

@@ -1,101 +0,0 @@
# 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

@@ -1,109 +0,0 @@
"""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

@@ -114,7 +114,6 @@ class FritzDeviceClimateMock(FritzEntityBaseMock):
has_thermostat = True
has_blind = False
holiday_active = False
boost_active = False
lock = "fake_locked"
present = True
summer_active = False

View File

@@ -442,7 +442,7 @@ async def test_preset_mode_update(hass: HomeAssistant, fritz: Mock) -> None:
assert state
assert state.attributes[ATTR_PRESET_MODE] == PRESET_ECO
# test boost preset by special temp
# test boost preset
device.target_temperature = 127 # special temp from the api
next_update = dt_util.utcnow() + timedelta(seconds=200)
async_fire_time_changed(hass, next_update)
@@ -453,18 +453,6 @@ async def test_preset_mode_update(hass: HomeAssistant, fritz: Mock) -> None:
assert state
assert state.attributes[ATTR_PRESET_MODE] == PRESET_BOOST
# test boost preset by boost_active
device.target_temperature = 21
device.boost_active = True
next_update = dt_util.utcnow() + timedelta(seconds=200)
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done(wait_background_tasks=True)
state = hass.states.get(ENTITY_ID)
assert fritz().update_devices.call_count == 5
assert state
assert state.attributes[ATTR_PRESET_MODE] == PRESET_BOOST
async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None:
"""Test adding new discovered devices during runtime."""

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, Service
from gardena_bluetooth.parse import Characteristic
import pytest
from homeassistant.components.gardena_bluetooth.const import DOMAIN
@@ -83,7 +83,7 @@ def mock_client(
) -> Generator[Mock]:
"""Auto mock bluetooth."""
client_class = Mock()
client = Mock(spec_set=Client)
SENTINEL = object()
@@ -106,32 +106,19 @@ def mock_client(
return default
return val
def _all_char_uuid():
def _all_char():
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_uuid
client.get_all_characteristics.side_effect = _all_char
client_class.return_value = client
client.get_all_characteristics_uuid.side_effect = _all_char
with (
patch(
"homeassistant.components.gardena_bluetooth.config_flow.Client",
new=client_class,
return_value=client,
),
patch("homeassistant.components.gardena_bluetooth.Client", new=client_class),
patch("homeassistant.components.gardena_bluetooth.Client", return_value=client),
):
yield client

View File

@@ -1,26 +1,21 @@
"""Test the Gardena Bluetooth setup."""
import asyncio
from datetime import timedelta
from unittest.mock import Mock, patch
from unittest.mock import Mock
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 MISSING_MANUFACTURER_DATA_SERVICE_INFO, WATER_TIMER_SERVICE_INFO
from . import 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(
@@ -33,10 +28,12 @@ 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)
assert await hass.config_entries.async_setup(mock_entry.entry_id) is True
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
assert mock_entry.state is ConfigEntryState.LOADED
device = device_registry.async_get_device(
identifiers={(DOMAIN, WATER_TIMER_SERVICE_INFO.address)}
@@ -44,49 +41,11 @@ 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 Mock, patch
from unittest.mock import patch
from freezegun import freeze_time
from gspread.exceptions import APIError
@@ -29,12 +29,7 @@ from homeassistant.components.google_sheets.services import (
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import (
HomeAssistantError,
OAuth2TokenRequestReauthError,
OAuth2TokenRequestTransientError,
ServiceValidationError,
)
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
@@ -204,64 +199,6 @@ 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

@@ -1,170 +0,0 @@
# 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,9 +5,6 @@ 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 (
@@ -22,17 +19,8 @@ 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
@@ -311,21 +299,11 @@ 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,
error_code: int,
expected_error: str,
hass: HomeAssistant, mock_growatt_v1_api, mock_setup_entry
) -> None:
"""Test token authentication with V1 API error maps to correct error type."""
"""Test token authentication with API error, then recovery."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
@@ -334,8 +312,9 @@ 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 = error_code
error.error_code = 100
mock_growatt_v1_api.plant_list.side_effect = error
result = await hass.config_entries.flow.async_configure(
@@ -344,9 +323,9 @@ async def test_token_auth_api_error(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "token_auth"
assert result["errors"] == {"base": expected_error}
assert result["errors"] == {"base": ERROR_INVALID_AUTH}
# Test recovery
# Test recovery - reset side_effect and set normal return value
mock_growatt_v1_api.plant_list.side_effect = None
mock_growatt_v1_api.plant_list.return_value = GROWATT_V1_PLANT_LIST_RESPONSE
@@ -692,362 +671,3 @@ 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

Some files were not shown because too many files have changed in this diff Show More