mirror of
https://github.com/home-assistant/core.git
synced 2026-05-26 10:45:11 +02:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 78db1e3407 | |||
| 2368a3614d | |||
| 5053392cf2 | |||
| 6ec11460ed | |||
| 975e30c048 | |||
| 7655cb0fc6 | |||
| 7566839e9d | |||
| 7db5e82f58 | |||
| 7e67c53417 | |||
| 89fb856302 | |||
| a2fbd2b1ea | |||
| 231ed34133 | |||
| 6cff433b2e | |||
| eca83fb7b1 | |||
| 2c5adaec5c | |||
| 5d75f1c33b | |||
| d628d2314e | |||
| a9547ec349 | |||
| 2ec637df84 | |||
| 4f50ee5675 | |||
| 0faf96b983 | |||
| c3dacbc601 | |||
| 2659484000 | |||
| 6830ca75f5 | |||
| 38b4184dc3 | |||
| cfde7975d8 | |||
| d7ab696a4c | |||
| 7f7dad7f71 | |||
| 0ed21dbed7 | |||
| d2b37ee28b | |||
| b82c95e77f | |||
| baa61982a1 | |||
| 8ff6de788d | |||
| 640f82642a | |||
| 64ed269f9c | |||
| 2b58ef96eb | |||
| 74ca79ac28 | |||
| afb27bc165 | |||
| 0cbf27f44f | |||
| a5ceafa544 | |||
| cd4d669231 | |||
| cc411d06b5 | |||
| 1329f12d37 | |||
| 3899f5347b | |||
| cf02cfaa7c | |||
| e77c16ea1b | |||
| f1e2f94ee0 | |||
| 3516883b0a | |||
| c8b70b1a38 | |||
| 946625e281 | |||
| f4b7840d5c | |||
| 060f447e4a | |||
| d5bae0a2cf | |||
| f9bef804b1 |
@@ -193,7 +193,11 @@ async def async_setup_entry(
|
||||
Aranet4BluetoothSensorEntity, async_add_entities
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(entry.runtime_data.async_register_processor(processor))
|
||||
entry.async_on_unload(
|
||||
entry.runtime_data.async_register_processor(
|
||||
processor, AranetSensorEntityDescription
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class Aranet4BluetoothSensorEntity(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from collections.abc import Mapping
|
||||
from ipaddress import ip_address
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import voluptuous as vol
|
||||
@@ -49,6 +49,9 @@ from .const import (
|
||||
from .errors import AuthenticationRequired, CannotConnect
|
||||
from .hub import AxisHub, get_axis_api
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import axis
|
||||
|
||||
AXIS_OUI = {"00:40:8c", "ac:cc:8e", "b8:a4:4f", "e8:27:25"}
|
||||
DEFAULT_PORT = 443
|
||||
DEFAULT_PROTOCOL = "https"
|
||||
@@ -93,7 +96,8 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
errors["base"] = "cannot_connect"
|
||||
|
||||
else:
|
||||
serial = api.vapix.serial_number
|
||||
if (serial := self._get_serial_number(api)) is None:
|
||||
return self.async_abort(reason="no_serial_number")
|
||||
config = {
|
||||
CONF_PROTOCOL: user_input[CONF_PROTOCOL],
|
||||
CONF_HOST: user_input[CONF_HOST],
|
||||
@@ -258,6 +262,19 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
return await self.async_step_user()
|
||||
|
||||
@staticmethod
|
||||
def _get_serial_number(api: axis.AxisDevice) -> str | None:
|
||||
"""Retrieve the device serial number from the Axis API.
|
||||
|
||||
Tries basic_device_info first, then property_handler. Returns None if not found.
|
||||
"""
|
||||
vapix = api.vapix
|
||||
if vapix.basic_device_info.initialized:
|
||||
return vapix.basic_device_info["0"].serial_number
|
||||
if vapix.params.property_handler.initialized:
|
||||
return vapix.params.property_handler["0"].system_serial_number
|
||||
return None
|
||||
|
||||
|
||||
class AxisOptionsFlowHandler(OptionsFlow):
|
||||
"""Handle Axis device options."""
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"link_local_address": "Link local addresses are not supported",
|
||||
"no_serial_number": "Could not retrieve a serial number from the device. Please check device connectivity and try again.",
|
||||
"not_axis_device": "Discovered device not an Axis device",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
|
||||
@@ -124,7 +124,9 @@ async def async_setup_entry(
|
||||
BlueMaestroBluetoothSensorEntity, async_add_entities
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(coordinator.async_register_processor(processor))
|
||||
entry.async_on_unload(
|
||||
coordinator.async_register_processor(processor, SensorEntityDescription)
|
||||
)
|
||||
|
||||
|
||||
class BlueMaestroBluetoothSensorEntity(
|
||||
|
||||
@@ -22,6 +22,7 @@ from homeassistant.config_entries import (
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.const import CONF_SOURCE
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.schema_config_entry_flow import (
|
||||
SchemaCommonFlowHandler,
|
||||
@@ -40,7 +41,6 @@ from .const import (
|
||||
CONF_DETAILS,
|
||||
CONF_MODE,
|
||||
CONF_PASSIVE,
|
||||
CONF_SOURCE,
|
||||
CONF_SOURCE_CONFIG_ENTRY_ID,
|
||||
CONF_SOURCE_DEVICE_ID,
|
||||
CONF_SOURCE_DOMAIN,
|
||||
|
||||
@@ -22,9 +22,6 @@ CONF_PASSIVE = "passive"
|
||||
|
||||
DEFAULT_MODE = BluetoothScanningMode.AUTO.value
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_SOURCE: Final = "source"
|
||||
CONF_SOURCE_DOMAIN: Final = "source_domain"
|
||||
CONF_SOURCE_MODEL: Final = "source_model"
|
||||
CONF_SOURCE_CONFIG_ENTRY_ID: Final = "source_config_entry_id"
|
||||
|
||||
@@ -21,7 +21,11 @@ from habluetooth import (
|
||||
)
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, EVENT_LOGGING_CHANGED
|
||||
from homeassistant.const import (
|
||||
CONF_SOURCE,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
EVENT_LOGGING_CHANGED,
|
||||
)
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
Event,
|
||||
@@ -33,7 +37,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.util.package import is_docker_env
|
||||
|
||||
from .const import (
|
||||
CONF_SOURCE,
|
||||
CONF_SOURCE_CONFIG_ENTRY_ID,
|
||||
CONF_SOURCE_DEVICE_ID,
|
||||
CONF_SOURCE_DOMAIN,
|
||||
|
||||
@@ -17,10 +17,10 @@
|
||||
"requirements": [
|
||||
"bleak==3.0.2",
|
||||
"bleak-retry-connector==4.6.1",
|
||||
"bluetooth-adapters==2.2.0",
|
||||
"bluetooth-adapters==2.3.0",
|
||||
"bluetooth-auto-recovery==1.6.4",
|
||||
"bluetooth-data-tools==1.29.18",
|
||||
"dbus-fast==5.0.9",
|
||||
"habluetooth==6.7.3"
|
||||
"dbus-fast==5.0.14",
|
||||
"habluetooth==6.7.4"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -60,7 +60,9 @@ class CheckConfigView(HomeAssistantView):
|
||||
vol.Optional("location_name"): str,
|
||||
vol.Optional("longitude"): cv.longitude,
|
||||
vol.Optional("radius"): cv.positive_int,
|
||||
vol.Optional("time_zone"): cv.time_zone,
|
||||
# Validated by async_set_time_zone in the executor to avoid
|
||||
# blocking I/O loading zoneinfo data on the event loop.
|
||||
vol.Optional("time_zone"): str,
|
||||
vol.Optional("update_units"): bool,
|
||||
vol.Optional("unit_system"): unit_system.validate_unit_system,
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
],
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"aiodhcpwatcher==1.2.6",
|
||||
"aiodiscover==3.2.3",
|
||||
"aiodhcpwatcher==1.2.7",
|
||||
"aiodiscover==3.2.4",
|
||||
"cached-ipaddress==1.1.1"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.helpers.selector import SerialPortSelector
|
||||
|
||||
from .const import CONF_SERIAL_PORT, DEFAULT_TITLE, DOMAIN
|
||||
|
||||
DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_SERIAL_PORT): str,
|
||||
vol.Required(CONF_SERIAL_PORT): SerialPortSelector(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"codeowners": [],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/edl21",
|
||||
"integration_type": "hub",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["sml"],
|
||||
"requirements": ["pysml==0.1.5"]
|
||||
"requirements": ["pysml==0.1.7"]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Support for EDL21 Smart Meters."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from sml import SmlGetListResponse
|
||||
@@ -29,7 +28,6 @@ from homeassistant.helpers.dispatcher import (
|
||||
async_dispatcher_send,
|
||||
)
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from .const import (
|
||||
CONF_SERIAL_PORT,
|
||||
@@ -39,8 +37,6 @@ from .const import (
|
||||
SIGNAL_EDL21_TELEGRAM,
|
||||
)
|
||||
|
||||
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
|
||||
|
||||
# OBIS format: A-B:C.D.E*F
|
||||
SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
|
||||
# A=1: Electricity
|
||||
@@ -391,8 +387,6 @@ class EDL21Entity(SensorEntity):
|
||||
self._electricity_id = electricity_id
|
||||
self._obis = obis
|
||||
self._telegram = telegram
|
||||
self._min_time = MIN_TIME_BETWEEN_UPDATES
|
||||
self._last_update = utcnow()
|
||||
self._async_remove_dispatcher = None
|
||||
self.entity_description = entity_description
|
||||
self._attr_unique_id = f"{electricity_id}_{obis}"
|
||||
@@ -414,12 +408,7 @@ class EDL21Entity(SensorEntity):
|
||||
if self._telegram == telegram:
|
||||
return
|
||||
|
||||
now = utcnow()
|
||||
if now - self._last_update < self._min_time:
|
||||
return
|
||||
|
||||
self._telegram = telegram
|
||||
self._last_update = now
|
||||
self.async_write_ha_state()
|
||||
|
||||
self._async_remove_dispatcher = async_dispatcher_connect(
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"serial_port": "[%key:common::config_flow::data::usb_path%]"
|
||||
"serial_port": "[%key:common::config_flow::data::port%]"
|
||||
},
|
||||
"data_description": {
|
||||
"serial_port": "Serial port path to connect to"
|
||||
},
|
||||
"title": "Add your EDL21 smart meter"
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ DEFAULT_BLUETOOTH_SCANNING_MODE = BluetoothScanningMode.AUTO.value
|
||||
|
||||
DEFAULT_PORT: Final = 6053
|
||||
|
||||
STABLE_BLE_VERSION_STR = "2025.11.0"
|
||||
STABLE_BLE_VERSION_STR = "2026.5.1"
|
||||
STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR)
|
||||
PROJECT_URLS = {
|
||||
"esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/",
|
||||
|
||||
@@ -53,7 +53,7 @@ def async_static_info_updated(
|
||||
platform: entity_platform.EntityPlatform,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
info_type: type[_InfoT],
|
||||
entity_type: type[_EntityT],
|
||||
entity_type: Callable[[RuntimeEntryData, EntityInfo, type[_StateT]], _EntityT],
|
||||
state_type: type[_StateT],
|
||||
infos: list[EntityInfo],
|
||||
) -> None:
|
||||
@@ -188,7 +188,7 @@ async def platform_async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
*,
|
||||
info_type: type[_InfoT],
|
||||
entity_type: type[_EntityT],
|
||||
entity_type: Callable[[RuntimeEntryData, EntityInfo, type[_StateT]], _EntityT],
|
||||
state_type: type[_StateT],
|
||||
info_filter: Callable[[_InfoT], bool] | None = None,
|
||||
) -> None:
|
||||
@@ -196,6 +196,11 @@ async def platform_async_setup_entry(
|
||||
|
||||
This method is in charge of receiving, distributing and storing
|
||||
info and state updates.
|
||||
|
||||
`entity_type` is any callable that builds an entity from
|
||||
`(entry_data, info, state_type)`. A regular entity class satisfies this,
|
||||
and platforms with multiple entity classes can pass a factory function
|
||||
that picks the class per static info.
|
||||
"""
|
||||
entry_data = entry.runtime_data
|
||||
entry_data.info[info_type] = {}
|
||||
|
||||
@@ -1,28 +1,34 @@
|
||||
"""Infrared platform for ESPHome."""
|
||||
|
||||
from functools import partial
|
||||
import functools
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aioesphomeapi import EntityState, InfraredCapability, InfraredInfo
|
||||
from aioesphomeapi import EntityInfo, EntityState, InfraredCapability, InfraredInfo
|
||||
from aioesphomeapi.client import InfraredRFReceiveEventModel
|
||||
|
||||
from homeassistant.components.infrared import InfraredCommand, InfraredEmitterEntity
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.components.infrared import (
|
||||
InfraredCommand,
|
||||
InfraredEmitterEntity,
|
||||
InfraredReceivedSignal,
|
||||
InfraredReceiverEntity,
|
||||
)
|
||||
from homeassistant.core import CALLBACK_TYPE, callback
|
||||
|
||||
from .entity import (
|
||||
EsphomeEntity,
|
||||
convert_api_error_ha_error,
|
||||
platform_async_setup_entry,
|
||||
)
|
||||
from .entry_data import RuntimeEntryData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
class EsphomeInfraredEntity(
|
||||
EsphomeEntity[InfraredInfo, EntityState], InfraredEmitterEntity
|
||||
):
|
||||
"""ESPHome infrared emitter entity using native API."""
|
||||
class _EsphomeInfraredEntity(EsphomeEntity[InfraredInfo, EntityState]):
|
||||
"""Common base for ESPHome infrared entities."""
|
||||
|
||||
@callback
|
||||
def _on_device_update(self) -> None:
|
||||
@@ -32,6 +38,10 @@ class EsphomeInfraredEntity(
|
||||
# Infrared entities should go available as soon as the device comes online
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class EsphomeInfraredEmitterEntity(_EsphomeInfraredEntity, InfraredEmitterEntity):
|
||||
"""ESPHome infrared emitter entity using native API."""
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_send_command(self, command: InfraredCommand) -> None:
|
||||
"""Send an IR command."""
|
||||
@@ -46,10 +56,77 @@ class EsphomeInfraredEntity(
|
||||
)
|
||||
|
||||
|
||||
async_setup_entry = partial(
|
||||
class EsphomeInfraredReceiverEntity(_EsphomeInfraredEntity, InfraredReceiverEntity):
|
||||
"""ESPHome infrared receiver entity using native API."""
|
||||
|
||||
_unsub_receive: CALLBACK_TYPE | None = None
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks including IR receive subscription."""
|
||||
await super().async_added_to_hass()
|
||||
self._async_subscribe_receive()
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Unsubscribe from the device on entity removal."""
|
||||
await super().async_will_remove_from_hass()
|
||||
if self._unsub_receive is not None:
|
||||
self._unsub_receive()
|
||||
self._unsub_receive = None
|
||||
|
||||
@callback
|
||||
def _async_subscribe_receive(self) -> None:
|
||||
"""Subscribe to IR receive events if the device is connected."""
|
||||
# Subscribing requires an active API connection; defer to
|
||||
# _on_device_update when the device is not (yet) available.
|
||||
if self._unsub_receive is not None or not self._entry_data.available:
|
||||
return
|
||||
self._unsub_receive = self._client.subscribe_infrared_rf_receive(
|
||||
self._on_infrared_rf_receive
|
||||
)
|
||||
|
||||
@callback
|
||||
def _on_device_update(self) -> None:
|
||||
"""Call when device updates or entry data changes."""
|
||||
super()._on_device_update()
|
||||
if self._entry_data.available:
|
||||
self._async_subscribe_receive()
|
||||
elif self._unsub_receive is not None:
|
||||
self._unsub_receive = None
|
||||
|
||||
@callback
|
||||
def _on_infrared_rf_receive(self, event: InfraredRFReceiveEventModel) -> None:
|
||||
"""Handle a received IR signal from the device."""
|
||||
if (
|
||||
event.key != self._static_info.key
|
||||
or event.device_id != self._static_info.device_id
|
||||
):
|
||||
return
|
||||
self._handle_received_signal(InfraredReceivedSignal(timings=event.timings))
|
||||
|
||||
|
||||
def _make_infrared_entity(
|
||||
entry_data: RuntimeEntryData,
|
||||
info: EntityInfo,
|
||||
state_type: type[EntityState],
|
||||
) -> _EsphomeInfraredEntity:
|
||||
"""Build the right infrared entity based on the InfraredInfo capabilities."""
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(info, InfraredInfo)
|
||||
cls = (
|
||||
EsphomeInfraredReceiverEntity
|
||||
if info.capabilities & InfraredCapability.RECEIVER
|
||||
else EsphomeInfraredEmitterEntity
|
||||
)
|
||||
return cls(entry_data, info, state_type)
|
||||
|
||||
|
||||
async_setup_entry = functools.partial(
|
||||
platform_async_setup_entry,
|
||||
info_type=InfraredInfo,
|
||||
entity_type=EsphomeInfraredEntity,
|
||||
entity_type=_make_infrared_entity,
|
||||
state_type=EntityState,
|
||||
info_filter=lambda info: bool(info.capabilities & InfraredCapability.TRANSMITTER),
|
||||
info_filter=lambda info: bool(
|
||||
info.capabilities
|
||||
& (InfraredCapability.TRANSMITTER | InfraredCapability.RECEIVER)
|
||||
),
|
||||
)
|
||||
|
||||
@@ -4,24 +4,14 @@ import logging
|
||||
|
||||
from bleak.backends.device import BLEDevice
|
||||
from gardena_bluetooth.client import CachedConnection, Client
|
||||
from gardena_bluetooth.const import AquaContour, DeviceConfiguration, DeviceInformation
|
||||
from gardena_bluetooth.exceptions import (
|
||||
CharacteristicNoAccess,
|
||||
CharacteristicNotFound,
|
||||
CommunicationFailure,
|
||||
)
|
||||
from gardena_bluetooth.parse import CharacteristicTime, ProductType
|
||||
from gardena_bluetooth.const import ProductType
|
||||
from gardena_bluetooth.scan import async_get_manufacturer_data
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.const import CONF_ADDRESS, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import (
|
||||
DeviceUnavailable,
|
||||
GardenaBluetoothConfigEntry,
|
||||
@@ -39,7 +29,6 @@ PLATFORMS: list[Platform] = [
|
||||
Platform.VALVE,
|
||||
]
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
TIMEOUT = 20.0
|
||||
DISCONNECT_DELAY = 5
|
||||
|
||||
|
||||
@@ -57,15 +46,6 @@ 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:
|
||||
@@ -73,49 +53,30 @@ async def async_setup_entry(
|
||||
|
||||
address = entry.data[CONF_ADDRESS]
|
||||
|
||||
mfg_data = await async_get_manufacturer_data({address})
|
||||
try:
|
||||
mfg_data = await async_get_manufacturer_data({address})
|
||||
except TimeoutError as exc:
|
||||
raise ConfigEntryNotReady("Unable to find product type") from exc
|
||||
|
||||
product_type = mfg_data[address].product_type
|
||||
if product_type is ProductType.UNKNOWN:
|
||||
raise ConfigEntryNotReady("Unable to find product type")
|
||||
|
||||
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)
|
||||
name = await client.read_char(AquaContour.custom_device_name, name)
|
||||
|
||||
await _update_timestamp(client, DeviceConfiguration.unix_timestamp)
|
||||
await _update_timestamp(client, AquaContour.unix_timestamp)
|
||||
|
||||
except (TimeoutError, CommunicationFailure, DeviceUnavailable) as exception:
|
||||
await client.disconnect()
|
||||
raise ConfigEntryNotReady(
|
||||
f"Unable to connect to device {address} due to {exception}"
|
||||
) from exception
|
||||
|
||||
device = DeviceInfo(
|
||||
identifiers={(DOMAIN, address)},
|
||||
connections={(dr.CONNECTION_BLUETOOTH, address)},
|
||||
name=name,
|
||||
sw_version=sw_version,
|
||||
manufacturer=manufacturer,
|
||||
model=model,
|
||||
)
|
||||
|
||||
coordinator = GardenaBluetoothCoordinator(
|
||||
hass, entry, LOGGER, client, set(chars.keys()), device, address
|
||||
hass,
|
||||
entry,
|
||||
LOGGER,
|
||||
client,
|
||||
address,
|
||||
)
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
await coordinator.async_refresh()
|
||||
|
||||
await coordinator.async_request_refresh()
|
||||
return True
|
||||
|
||||
|
||||
@@ -123,7 +84,4 @@ async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: GardenaBluetoothConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
await entry.runtime_data.async_shutdown()
|
||||
|
||||
return unload_ok
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -4,17 +4,28 @@ from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from gardena_bluetooth.client import Client
|
||||
from gardena_bluetooth.const import AquaContour, DeviceConfiguration, DeviceInformation
|
||||
from gardena_bluetooth.exceptions import (
|
||||
CharacteristicNoAccess,
|
||||
CharacteristicNotFound,
|
||||
CommunicationFailure,
|
||||
GardenaBluetoothException,
|
||||
)
|
||||
from gardena_bluetooth.parse import Characteristic, CharacteristicType
|
||||
from gardena_bluetooth.parse import (
|
||||
Characteristic,
|
||||
CharacteristicTime,
|
||||
CharacteristicType,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=60)
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
@@ -37,8 +48,6 @@ class GardenaBluetoothCoordinator(DataUpdateCoordinator[dict[str, bytes]]):
|
||||
config_entry: GardenaBluetoothConfigEntry,
|
||||
logger: logging.Logger,
|
||||
client: Client,
|
||||
characteristics: set[str],
|
||||
device_info: DeviceInfo,
|
||||
address: str,
|
||||
) -> None:
|
||||
"""Initialize global data updater."""
|
||||
@@ -52,14 +61,63 @@ class GardenaBluetoothCoordinator(DataUpdateCoordinator[dict[str, bytes]]):
|
||||
self.address = address
|
||||
self.data = {}
|
||||
self.client = client
|
||||
self.characteristics = characteristics
|
||||
self.device_info = device_info
|
||||
self.characteristics: set[str] = set()
|
||||
self.device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, address)},
|
||||
connections={(dr.CONNECTION_BLUETOOTH, address)},
|
||||
name=config_entry.title,
|
||||
)
|
||||
|
||||
async def async_shutdown(self) -> None:
|
||||
"""Shutdown coordinator and any connection."""
|
||||
await super().async_shutdown()
|
||||
await self.client.disconnect()
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Set up the coordinator and read initial device metadata."""
|
||||
try:
|
||||
chars = await self.client.get_all_characteristics()
|
||||
|
||||
sw_version = await self.client.read_char(
|
||||
DeviceInformation.firmware_version, None
|
||||
)
|
||||
manufacturer = await self.client.read_char(
|
||||
DeviceInformation.manufacturer_name, None
|
||||
)
|
||||
model = await self.client.read_char(DeviceInformation.model_number, None)
|
||||
|
||||
name = self.config_entry.title
|
||||
name = await self.client.read_char(
|
||||
DeviceConfiguration.custom_device_name, name
|
||||
)
|
||||
name = await self.client.read_char(AquaContour.custom_device_name, name)
|
||||
|
||||
await self._update_timestamp(DeviceConfiguration.unix_timestamp)
|
||||
await self._update_timestamp(AquaContour.unix_timestamp)
|
||||
|
||||
self.characteristics = set(chars.keys())
|
||||
self.device_info = DeviceInfo(
|
||||
{
|
||||
**self.device_info,
|
||||
"name": name,
|
||||
"sw_version": sw_version,
|
||||
"manufacturer": manufacturer,
|
||||
"model": model,
|
||||
}
|
||||
)
|
||||
except (TimeoutError, CommunicationFailure, DeviceUnavailable) as exception:
|
||||
raise UpdateFailed(
|
||||
f"Unable to set up Gardena Bluetooth device due to {exception}"
|
||||
) from exception
|
||||
|
||||
async def _update_timestamp(self, char: CharacteristicTime) -> None:
|
||||
try:
|
||||
await self.client.update_timestamp(char, dt_util.now())
|
||||
except CharacteristicNotFound:
|
||||
pass
|
||||
except CharacteristicNoAccess:
|
||||
LOGGER.debug("No access to update internal time")
|
||||
|
||||
async def _async_update_data(self) -> dict[str, bytes]:
|
||||
"""Poll the device."""
|
||||
uuids: set[str] = {
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["googleapiclient"],
|
||||
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==13.2.4"]
|
||||
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==13.2.5"]
|
||||
}
|
||||
|
||||
@@ -249,7 +249,7 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if not errors and login is not None:
|
||||
await self.async_set_unique_id(str(login.id))
|
||||
self._abort_if_unique_id_mismatch()
|
||||
return self.async_update_reload_and_abort(
|
||||
return self.async_update_and_abort(
|
||||
reauth_entry,
|
||||
data_updates={CONF_API_KEY: login.apiToken},
|
||||
)
|
||||
@@ -261,7 +261,7 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
}
|
||||
)
|
||||
if not errors and user is not None:
|
||||
return self.async_update_reload_and_abort(
|
||||
return self.async_update_and_abort(
|
||||
reauth_entry, data_updates=user_input[SECTION_REAUTH_API_KEY]
|
||||
)
|
||||
else:
|
||||
@@ -309,7 +309,7 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
}
|
||||
)
|
||||
if not errors and user is not None:
|
||||
return self.async_update_reload_and_abort(
|
||||
return self.async_update_and_abort(
|
||||
reconf_entry,
|
||||
data_updates={
|
||||
CONF_API_KEY: user_input[CONF_API_KEY],
|
||||
|
||||
@@ -64,10 +64,6 @@ BINARY_SENSOR_DESCRIPTIONS: dict[str, BinarySensorEntityDescription] = {
|
||||
key="tamper_detection",
|
||||
device_class=BinarySensorDeviceClass.TAMPER,
|
||||
),
|
||||
"Shelter Alarm": BinarySensorEntityDescription(
|
||||
key="shelter_alarm",
|
||||
translation_key="shelter_alarm",
|
||||
),
|
||||
"Disk Full": BinarySensorEntityDescription(
|
||||
key="disk_full",
|
||||
translation_key="disk_full",
|
||||
|
||||
@@ -84,9 +84,6 @@
|
||||
"scene_change_detection": {
|
||||
"name": "Scene change detection"
|
||||
},
|
||||
"shelter_alarm": {
|
||||
"name": "Shelter alarm"
|
||||
},
|
||||
"unattended_baggage": {
|
||||
"name": "Unattended baggage"
|
||||
},
|
||||
|
||||
@@ -289,10 +289,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
"""Service handler for reloading core config."""
|
||||
try:
|
||||
conf = await conf_util.async_hass_config_yaml(hass)
|
||||
# pylint: disable-next=home-assistant-action-swallowed-exception
|
||||
except HomeAssistantError as err:
|
||||
_LOGGER.error(err)
|
||||
return
|
||||
except (HomeAssistantError, FileNotFoundError) as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="core_config_reload_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
# auth only processed during startup
|
||||
await core_config.async_process_ha_core_config(hass, conf.get(DOMAIN) or {})
|
||||
|
||||
@@ -183,10 +183,12 @@ async def async_setup_platform(
|
||||
"""Reload the scene config."""
|
||||
try:
|
||||
config = await conf_util.async_hass_config_yaml(hass)
|
||||
# pylint: disable-next=home-assistant-action-swallowed-exception
|
||||
except HomeAssistantError as err:
|
||||
_LOGGER.error(err)
|
||||
return
|
||||
except (HomeAssistantError, FileNotFoundError) as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="scene_config_reload_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
integration = await async_get_integration(hass, SCENE_DOMAIN)
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
"config_validator_unknown_err": {
|
||||
"message": "Unknown error calling {domain} config validator - {error}."
|
||||
},
|
||||
"core_config_reload_failed": {
|
||||
"message": "Failed to reload the Home Assistant Core configuration - {error}"
|
||||
},
|
||||
"max_length_exceeded": {
|
||||
"message": "Value {value} for property {property_name} has a maximum length of {max_length} characters."
|
||||
},
|
||||
@@ -48,6 +51,9 @@
|
||||
"platform_schema_validator_err": {
|
||||
"message": "Unknown error when validating config for {domain} from integration {p_name} - {error}."
|
||||
},
|
||||
"scene_config_reload_failed": {
|
||||
"message": "Failed to reload the Home Assistant scene platform configuration - {error}"
|
||||
},
|
||||
"service_config_entry_not_found": {
|
||||
"message": "Integration {domain} config entry with ID {entry_id} was not found."
|
||||
},
|
||||
|
||||
@@ -116,6 +116,7 @@ SENSOR_KEYS: Final[dict[int, list[str]]] = {
|
||||
IndevoltBattery.PACK_3_TEMPERATURE,
|
||||
IndevoltBattery.PACK_4_TEMPERATURE,
|
||||
IndevoltBattery.PACK_5_TEMPERATURE,
|
||||
IndevoltBattery.MAIN_MOS_TEMPERATURE,
|
||||
IndevoltBattery.PACK_1_MOS_TEMPERATURE,
|
||||
IndevoltBattery.PACK_2_MOS_TEMPERATURE,
|
||||
IndevoltBattery.PACK_3_MOS_TEMPERATURE,
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["indevolt-api==1.8.1"],
|
||||
"requirements": ["indevolt-api==1.8.2"],
|
||||
"zeroconf": [{ "name": "igen_fw*", "type": "_http._tcp.local." }]
|
||||
}
|
||||
|
||||
@@ -612,6 +612,16 @@ SENSORS: Final = (
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
# Battery Pack MOS Temperature
|
||||
IndevoltSensorEntityDescription(
|
||||
key=IndevoltBattery.MAIN_MOS_TEMPERATURE,
|
||||
generation=(2,),
|
||||
translation_key="main_mos_temperature",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
IndevoltSensorEntityDescription(
|
||||
key=IndevoltBattery.PACK_1_MOS_TEMPERATURE,
|
||||
generation=(2,),
|
||||
|
||||
@@ -295,6 +295,9 @@
|
||||
"main_current": {
|
||||
"name": "Main current"
|
||||
},
|
||||
"main_mos_temperature": {
|
||||
"name": "Main MOS temperature"
|
||||
},
|
||||
"main_serial_number": {
|
||||
"name": "Main serial number"
|
||||
},
|
||||
|
||||
@@ -27,6 +27,11 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
FALLBACK_POLL_INTERVAL = timedelta(seconds=180)
|
||||
|
||||
# IBS-TH2 broadcasts every ~20-30s and only carries sensor data in the scan
|
||||
# response, so the default 10s active window misses the device most cycles.
|
||||
# 25s covers one full broadcast interval with margin to absorb jitter.
|
||||
ACTIVE_SCAN_DURATION = 25.0
|
||||
|
||||
|
||||
class INKBIRDActiveBluetoothProcessorCoordinator(
|
||||
ActiveBluetoothProcessorCoordinator[SensorUpdate]
|
||||
@@ -57,6 +62,7 @@ class INKBIRDActiveBluetoothProcessorCoordinator(
|
||||
needs_poll_method=self._async_needs_poll,
|
||||
poll_method=self._async_poll_data,
|
||||
connectable=False, # Polling only happens if active scanning is disabled
|
||||
scan_duration=ACTIVE_SCAN_DURATION,
|
||||
)
|
||||
|
||||
async def async_init(self) -> None:
|
||||
|
||||
@@ -63,5 +63,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/inkbird",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["inkbird-ble==1.2.3"]
|
||||
"requirements": ["inkbird-ble==1.4.3"]
|
||||
}
|
||||
|
||||
@@ -117,7 +117,9 @@ async def async_setup_entry(
|
||||
INKBIRDBluetoothSensorEntity, async_add_entities
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(entry.runtime_data.async_register_processor(processor))
|
||||
entry.async_on_unload(
|
||||
entry.runtime_data.async_register_processor(processor, SensorEntityDescription)
|
||||
)
|
||||
|
||||
|
||||
class INKBIRDBluetoothSensorEntity(
|
||||
|
||||
@@ -116,7 +116,9 @@ async def async_setup_entry(
|
||||
KegtronBluetoothSensorEntity, async_add_entities
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(coordinator.async_register_processor(processor))
|
||||
entry.async_on_unload(
|
||||
coordinator.async_register_processor(processor, SensorEntityDescription)
|
||||
)
|
||||
|
||||
|
||||
class KegtronBluetoothSensorEntity(
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/local_calendar",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["ical"],
|
||||
"requirements": ["ical==13.2.4"]
|
||||
"requirements": ["ical==13.2.5"]
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/local_todo",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["ical==13.2.4"]
|
||||
"requirements": ["ical==13.2.5"]
|
||||
}
|
||||
|
||||
@@ -175,6 +175,8 @@ class ProgramPhaseWashingMachine(MieleEnum, missing_to_none=True):
|
||||
disinfecting = 285
|
||||
flex_load_active = 11047
|
||||
automatic_start = 11044
|
||||
paused = 11052
|
||||
cancelled = 11053
|
||||
|
||||
|
||||
class ProgramPhaseTumbleDryer(MieleEnum, missing_to_none=True):
|
||||
|
||||
@@ -984,6 +984,7 @@
|
||||
"blocked_brushes": "Brushes blocked",
|
||||
"blocked_drive_wheels": "Drive wheels blocked",
|
||||
"blocked_front_wheel": "Front wheel blocked",
|
||||
"cancelled": "Cancelled",
|
||||
"cleaning": "Cleaning",
|
||||
"comfort_cooling": "Comfort cooling",
|
||||
"cooling_down": "Cooling down",
|
||||
@@ -1026,6 +1027,7 @@
|
||||
"normal": "Normal",
|
||||
"normal_plus": "Normal plus",
|
||||
"not_running": "Not running",
|
||||
"paused": "Paused",
|
||||
"perfect_dry_active": "PerfectDry active",
|
||||
"pre_brewing": "Pre-brewing",
|
||||
"pre_dishwash": "Pre-cleaning",
|
||||
|
||||
@@ -111,7 +111,9 @@ async def async_setup_entry(
|
||||
MoatBluetoothSensorEntity, async_add_entities
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(coordinator.async_register_processor(processor))
|
||||
entry.async_on_unload(
|
||||
coordinator.async_register_processor(processor, SensorEntityDescription)
|
||||
)
|
||||
|
||||
|
||||
class MoatBluetoothSensorEntity(
|
||||
|
||||
@@ -10,10 +10,12 @@ import voluptuous as vol
|
||||
from homeassistant.components.device_tracker import (
|
||||
ATTR_BATTERY,
|
||||
ATTR_GPS,
|
||||
ATTR_IN_ZONES,
|
||||
ATTR_LOCATION_NAME,
|
||||
TrackerEntity,
|
||||
)
|
||||
from homeassistant.components.zone import (
|
||||
DOMAIN as ZONE_DOMAIN,
|
||||
ENTITY_ID_FORMAT as ZONE_ENTITY_ID_FORMAT,
|
||||
HOME_ZONE,
|
||||
)
|
||||
@@ -59,6 +61,7 @@ LOCATION_UPDATE_SCHEMA = vol.All(
|
||||
vol.Optional(ATTR_ALTITUDE): vol.Coerce(float),
|
||||
vol.Optional(ATTR_COURSE): cv.positive_int,
|
||||
vol.Optional(ATTR_VERTICAL_ACCURACY): cv.positive_int,
|
||||
vol.Optional(ATTR_IN_ZONES): cv.entities_domain(ZONE_DOMAIN),
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -126,6 +129,11 @@ class MobileAppEntity(TrackerEntity, RestoreEntity):
|
||||
|
||||
return attrs
|
||||
|
||||
@property
|
||||
def in_zones(self) -> list[str] | None:
|
||||
"""Return the zones the device is currently in."""
|
||||
return self._data.get(ATTR_IN_ZONES)
|
||||
|
||||
@property
|
||||
def location_accuracy(self) -> float:
|
||||
"""Return the gps accuracy of the device."""
|
||||
@@ -150,6 +158,11 @@ class MobileAppEntity(TrackerEntity, RestoreEntity):
|
||||
@property
|
||||
def location_name(self) -> str | None:
|
||||
"""Return a location name for the current location of the device."""
|
||||
if ATTR_IN_ZONES in self._data:
|
||||
# New app sends in_zones as well as location_name. Prioritize in_zones
|
||||
# and only use location_name for backwards compatibility with old
|
||||
# app versions.
|
||||
return None
|
||||
if location_name := self._data.get(ATTR_LOCATION_NAME):
|
||||
if location_name == HOME_ZONE:
|
||||
return STATE_HOME
|
||||
|
||||
@@ -123,7 +123,9 @@ async def async_setup_entry(
|
||||
MopekaBluetoothSensorEntity, async_add_entities
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(coordinator.async_register_processor(processor))
|
||||
entry.async_on_unload(
|
||||
coordinator.async_register_processor(processor, SensorEntityDescription)
|
||||
)
|
||||
|
||||
|
||||
class MopekaBluetoothSensorEntity(
|
||||
|
||||
@@ -303,7 +303,7 @@ class NtfyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"wrong_username": account.username,
|
||||
},
|
||||
)
|
||||
return self.async_update_reload_and_abort(
|
||||
return self.async_update_and_abort(
|
||||
entry,
|
||||
data_updates={CONF_TOKEN: token},
|
||||
)
|
||||
@@ -366,7 +366,7 @@ class NtfyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_update_reload_and_abort(
|
||||
return self.async_update_and_abort(
|
||||
entry,
|
||||
data_updates={CONF_TOKEN: token},
|
||||
)
|
||||
@@ -376,7 +376,7 @@ class NtfyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
CONF_USERNAME: account.username,
|
||||
}
|
||||
)
|
||||
return self.async_update_reload_and_abort(
|
||||
return self.async_update_and_abort(
|
||||
entry,
|
||||
data_updates={
|
||||
CONF_USERNAME: account.username,
|
||||
|
||||
@@ -39,7 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ONVIFConfigEntry) -> boo
|
||||
await async_populate_options(hass, entry)
|
||||
|
||||
device = ONVIFDevice(hass, entry)
|
||||
camera_address = f"{device.device.host}:{device.device.port}"
|
||||
camera_address = f"{device.host}:{device.port}"
|
||||
|
||||
async with AsyncExitStack() as stack:
|
||||
# Register cleanup callback for device
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["onvif", "wsdiscovery", "zeep"],
|
||||
"requirements": [
|
||||
"onvif-zeep-async==4.0.4",
|
||||
"onvif-zeep-async==4.1.0",
|
||||
"onvif_parsers==2.3.0",
|
||||
"WSDiscovery==2.1.2"
|
||||
]
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["icmplib"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["icmplib==3.0"]
|
||||
"requirements": ["icmplib==3.0.4"]
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ class PlaystationNetworkConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_update_reload_and_abort(
|
||||
return self.async_update_and_abort(
|
||||
entry,
|
||||
data_updates={CONF_NPSSO: npsso},
|
||||
)
|
||||
|
||||
@@ -27,6 +27,15 @@ ERROR_NO_SCHEDULE = "set_schedule_first"
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
def _check_for_schedule(active: bool, last_active: str | None) -> None:
|
||||
"""Raise a HAError when no thermostat schedule has been set."""
|
||||
if not active and last_active is None:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key=ERROR_NO_SCHEDULE,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlugwiseClimateExtraStoredData(ExtraStoredData):
|
||||
"""Object to hold extra stored data."""
|
||||
@@ -85,22 +94,6 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity):
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_translation_key = DOMAIN
|
||||
|
||||
_last_active_schedule: str | None = None
|
||||
_previous_action_mode: str | None = HVACAction.HEATING.value
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when entity about to be added."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
if extra_data := await self.async_get_last_extra_data():
|
||||
plugwise_extra_data = PlugwiseClimateExtraStoredData.from_dict(
|
||||
extra_data.as_dict()
|
||||
)
|
||||
self._last_active_schedule = plugwise_extra_data.last_active_schedule
|
||||
self._previous_action_mode = (
|
||||
plugwise_extra_data.previous_action_mode or HVACAction.HEATING.value
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: PlugwiseDataUpdateCoordinator,
|
||||
@@ -110,18 +103,18 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity):
|
||||
super().__init__(coordinator, device_id)
|
||||
self._attr_unique_id = f"{device_id}-climate"
|
||||
|
||||
gateway_id: str = coordinator.api.gateway_id
|
||||
self._api = coordinator.api
|
||||
gateway_id: str = self._api.gateway_id
|
||||
self._gateway_data = coordinator.data[gateway_id]
|
||||
self._last_active_schedule: str | None = None
|
||||
self._location = device_id
|
||||
if (location := self.device.get("location")) is not None:
|
||||
self._location = location
|
||||
self._previous_action_mode = HVACAction.HEATING.value
|
||||
|
||||
# Determine supported features
|
||||
self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
if (
|
||||
self.coordinator.api.cooling_present
|
||||
and coordinator.api.smile.name != "Adam"
|
||||
):
|
||||
if self._api.cooling_present and self._api.smile.name != "Adam":
|
||||
self._attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
|
||||
)
|
||||
@@ -140,10 +133,18 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity):
|
||||
self.device["thermostat"]["resolution"], 0.1
|
||||
)
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float:
|
||||
"""Return the current temperature."""
|
||||
return self.device["sensors"]["temperature"]
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when entity about to be added."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
if extra_data := await self.async_get_last_extra_data():
|
||||
plugwise_extra_data = PlugwiseClimateExtraStoredData.from_dict(
|
||||
extra_data.as_dict()
|
||||
)
|
||||
self._last_active_schedule = plugwise_extra_data.last_active_schedule
|
||||
self._previous_action_mode = (
|
||||
plugwise_extra_data.previous_action_mode or HVACAction.HEATING.value
|
||||
)
|
||||
|
||||
@property
|
||||
def extra_restore_state_data(self) -> PlugwiseClimateExtraStoredData:
|
||||
@@ -153,6 +154,11 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity):
|
||||
previous_action_mode=self._previous_action_mode,
|
||||
)
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float:
|
||||
"""Return the current temperature."""
|
||||
return self.device["sensors"]["temperature"]
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float:
|
||||
"""Return the temperature we try to reach.
|
||||
@@ -197,7 +203,7 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity):
|
||||
if self.device.get("available_schedules"):
|
||||
hvac_modes.append(HVACMode.AUTO)
|
||||
|
||||
if self.coordinator.api.cooling_present:
|
||||
if self._api.cooling_present:
|
||||
if "regulation_modes" in self._gateway_data:
|
||||
if "heating" in self._gateway_data["regulation_modes"]:
|
||||
hvac_modes.append(HVACMode.HEAT)
|
||||
@@ -247,79 +253,69 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity):
|
||||
if mode := kwargs.get(ATTR_HVAC_MODE):
|
||||
await self.async_set_hvac_mode(mode)
|
||||
|
||||
await self.coordinator.api.set_temperature(self._location, data)
|
||||
await self._api.set_temperature(self._location, data)
|
||||
|
||||
def _regulation_mode_for_hvac(self, hvac_mode: HVACMode) -> str | None:
|
||||
"""Return the API regulation value for a manual HVAC mode, or None."""
|
||||
def _regulation_mode_for_hvac(self, hvac_mode: HVACMode) -> str:
|
||||
"""Return the API regulation value for a manual HVAC mode, or None.
|
||||
|
||||
The function inputs are limited to the HVACModes HEAT and COOL.
|
||||
"""
|
||||
if hvac_mode == HVACMode.HEAT:
|
||||
return HVACAction.HEATING.value
|
||||
mode = HVACAction.HEATING.value
|
||||
if hvac_mode == HVACMode.COOL:
|
||||
return HVACAction.COOLING.value
|
||||
return None
|
||||
mode = HVACAction.COOLING.value
|
||||
return mode
|
||||
|
||||
@plugwise_command
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set the HVAC mode (off, heat, cool, heat_cool, or auto/schedule)."""
|
||||
# Early exit if no mode change
|
||||
if hvac_mode == self.hvac_mode:
|
||||
return
|
||||
|
||||
api = self.coordinator.api
|
||||
current_schedule = self.device.get("select_schedule")
|
||||
|
||||
# OFF: single API call
|
||||
# Adam only: set to HVACMode.OFF
|
||||
if hvac_mode == HVACMode.OFF:
|
||||
await api.set_regulation_mode(hvac_mode.value)
|
||||
await self._api.set_regulation_mode(hvac_mode.value)
|
||||
return
|
||||
|
||||
# Manual mode (heat/cool/heat_cool) without a schedule: set regulation only
|
||||
if (
|
||||
current_schedule is None
|
||||
and hvac_mode != HVACMode.AUTO
|
||||
and (
|
||||
regulation := self._regulation_mode_for_hvac(hvac_mode)
|
||||
or self._previous_action_mode
|
||||
)
|
||||
):
|
||||
await api.set_regulation_mode(regulation)
|
||||
return
|
||||
current_schedule = self.device.get("select_schedule")
|
||||
schedule_is_active = current_schedule not in (None, "off")
|
||||
desired_schedule = (
|
||||
current_schedule if schedule_is_active else self._last_active_schedule
|
||||
)
|
||||
# Adam only: transition from HVACMode.OFF
|
||||
if self.hvac_mode == HVACMode.OFF:
|
||||
if hvac_mode == HVACMode.AUTO:
|
||||
_check_for_schedule(schedule_is_active, self._last_active_schedule)
|
||||
await self._api.set_schedule_state(
|
||||
self._location, STATE_ON, desired_schedule
|
||||
)
|
||||
await self._api.set_regulation_mode(self._previous_action_mode)
|
||||
return
|
||||
|
||||
# Manual mode: ensure regulation and turn off schedule when needed
|
||||
if hvac_mode in (HVACMode.HEAT, HVACMode.COOL, HVACMode.HEAT_COOL):
|
||||
regulation = self._regulation_mode_for_hvac(hvac_mode) or (
|
||||
self._previous_action_mode
|
||||
if self.hvac_mode in (HVACMode.HEAT_COOL, HVACMode.OFF)
|
||||
else None
|
||||
)
|
||||
if regulation:
|
||||
await api.set_regulation_mode(regulation)
|
||||
|
||||
if (
|
||||
self.hvac_mode == HVACMode.OFF and current_schedule not in (None, "off")
|
||||
) or (self.hvac_mode == HVACMode.AUTO and current_schedule is not None):
|
||||
await api.set_schedule_state(
|
||||
# Transition to manual mode
|
||||
if schedule_is_active:
|
||||
await self._api.set_schedule_state(
|
||||
self._location, STATE_OFF, current_schedule
|
||||
)
|
||||
self._last_active_schedule = current_schedule
|
||||
regulation = self._regulation_mode_for_hvac(hvac_mode)
|
||||
await self._api.set_regulation_mode(regulation)
|
||||
return
|
||||
|
||||
# AUTO: restore schedule and regulation
|
||||
desired_schedule = current_schedule
|
||||
if desired_schedule and desired_schedule != "off":
|
||||
self._last_active_schedule = desired_schedule
|
||||
elif desired_schedule == "off":
|
||||
desired_schedule = self._last_active_schedule
|
||||
|
||||
if not desired_schedule:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key=ERROR_NO_SCHEDULE,
|
||||
# Common - transition from auto = schedule off
|
||||
if self.hvac_mode == HVACMode.AUTO:
|
||||
await self._api.set_schedule_state(
|
||||
self._location, STATE_OFF, current_schedule
|
||||
)
|
||||
self._last_active_schedule = current_schedule
|
||||
return
|
||||
|
||||
if self._previous_action_mode:
|
||||
if self.hvac_mode == HVACMode.OFF:
|
||||
await api.set_regulation_mode(self._previous_action_mode)
|
||||
await api.set_schedule_state(self._location, STATE_ON, desired_schedule)
|
||||
# Common - transition to auto = schedule on
|
||||
_check_for_schedule(schedule_is_active, self._last_active_schedule)
|
||||
await self._api.set_schedule_state(self._location, STATE_ON, desired_schedule)
|
||||
|
||||
@plugwise_command
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set the preset mode."""
|
||||
await self.coordinator.api.set_preset(self._location, preset_mode)
|
||||
await self._api.set_preset(self._location, preset_mode)
|
||||
|
||||
@@ -105,7 +105,9 @@ async def async_setup_entry(
|
||||
RAPTPillBluetoothSensorEntity, async_add_entities
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(coordinator.async_register_processor(processor))
|
||||
entry.async_on_unload(
|
||||
coordinator.async_register_processor(processor, SensorEntityDescription)
|
||||
)
|
||||
|
||||
|
||||
class RAPTPillBluetoothSensorEntity(
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["ical"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["ical==13.2.4"]
|
||||
"requirements": ["ical==13.2.5"]
|
||||
}
|
||||
|
||||
@@ -207,7 +207,9 @@ async def async_setup_entry(
|
||||
RuuvitagBluetoothSensorEntity, async_add_entities
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(coordinator.async_register_processor(processor))
|
||||
entry.async_on_unload(
|
||||
coordinator.async_register_processor(processor, SensorEntityDescription)
|
||||
)
|
||||
|
||||
|
||||
class RuuvitagBluetoothSensorEntity(
|
||||
|
||||
@@ -112,7 +112,9 @@ async def async_setup_entry(
|
||||
SensirionBluetoothSensorEntity, async_add_entities
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(coordinator.async_register_processor(processor))
|
||||
entry.async_on_unload(
|
||||
coordinator.async_register_processor(processor, SensorEntityDescription)
|
||||
)
|
||||
|
||||
|
||||
class SensirionBluetoothSensorEntity(
|
||||
|
||||
@@ -123,7 +123,9 @@ async def async_setup_entry(
|
||||
SensorProBluetoothSensorEntity, async_add_entities
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(coordinator.async_register_processor(processor))
|
||||
entry.async_on_unload(
|
||||
coordinator.async_register_processor(processor, SensorEntityDescription)
|
||||
)
|
||||
|
||||
|
||||
class SensorProBluetoothSensorEntity(
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"quality_scale": "bronze",
|
||||
"requirements": [
|
||||
"defusedxml==0.7.1",
|
||||
"soco==0.30.15",
|
||||
"soco==0.31.1",
|
||||
"sonos-websocket==0.1.3"
|
||||
],
|
||||
"ssdp": [
|
||||
|
||||
@@ -119,7 +119,9 @@ async def async_setup_entry(
|
||||
ThermoBeaconBluetoothSensorEntity, async_add_entities
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(coordinator.async_register_processor(processor))
|
||||
entry.async_on_unload(
|
||||
coordinator.async_register_processor(processor, SensorEntityDescription)
|
||||
)
|
||||
|
||||
|
||||
class ThermoBeaconBluetoothSensorEntity(
|
||||
|
||||
@@ -92,7 +92,9 @@ async def async_setup_entry(
|
||||
TiltBluetoothSensorEntity, async_add_entities
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(coordinator.async_register_processor(processor))
|
||||
entry.async_on_unload(
|
||||
coordinator.async_register_processor(processor, SensorEntityDescription)
|
||||
)
|
||||
|
||||
|
||||
class TiltBluetoothSensorEntity(
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiounifi"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aiounifi==90"]
|
||||
"requirements": ["aiounifi==91"]
|
||||
}
|
||||
|
||||
@@ -519,7 +519,11 @@ async def async_setup_entry(
|
||||
VictronBLESensorEntity, async_add_entities
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(coordinator.async_register_processor(processor))
|
||||
entry.async_on_unload(
|
||||
coordinator.async_register_processor(
|
||||
processor, VictronBLESensorEntityDescription
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class VictronBLESensorEntity(PassiveBluetoothProcessorEntity, SensorEntity):
|
||||
|
||||
@@ -83,9 +83,7 @@ class OAuth2FlowHandler(
|
||||
description_placeholders={"gamertag": me.people[0].gamertag}
|
||||
)
|
||||
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(), data=data
|
||||
)
|
||||
return self.async_update_and_abort(self._get_reauth_entry(), data=data)
|
||||
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import voluptuous as vol
|
||||
|
||||
from .core import HomeAssistant, callback
|
||||
from .exceptions import HomeAssistantError
|
||||
from .helpers.deprecation import deprecated_function
|
||||
from .helpers.frame import ReportBehavior, report_usage
|
||||
from .loader import async_suggest_report_issue
|
||||
from .util import uuid as uuid_util
|
||||
@@ -643,9 +644,17 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]):
|
||||
return self.context.get("source", None) # type: ignore[return-value]
|
||||
|
||||
@property
|
||||
@deprecated_function(
|
||||
"a user friendly way to present additional options in the UI, for example a section",
|
||||
breaks_in_ha_version="2027.6",
|
||||
)
|
||||
def show_advanced_options(self) -> bool:
|
||||
"""If we should show advanced options."""
|
||||
return self.context.get("show_advanced_options", False) # type: ignore[return-value]
|
||||
"""If we should show advanced options.
|
||||
|
||||
During the deprecation period return True to not break existing flows that use
|
||||
this property to determine whether to show additional options.
|
||||
"""
|
||||
return True
|
||||
|
||||
def add_suggested_values_to_schema(
|
||||
self, data_schema: vol.Schema, suggested_values: Mapping[str, Any] | None
|
||||
@@ -658,15 +667,6 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]):
|
||||
"""
|
||||
schema = {}
|
||||
for key, val in data_schema.schema.items():
|
||||
if isinstance(key, vol.Marker):
|
||||
# Exclude advanced field
|
||||
if (
|
||||
key.description
|
||||
and key.description.get("advanced")
|
||||
and not self.show_advanced_options
|
||||
):
|
||||
continue
|
||||
|
||||
# Process the section schema options
|
||||
if (
|
||||
suggested_values is not None
|
||||
|
||||
@@ -1662,7 +1662,7 @@
|
||||
},
|
||||
"edl21": {
|
||||
"name": "EDL21",
|
||||
"integration_type": "hub",
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_push"
|
||||
},
|
||||
|
||||
@@ -181,25 +181,6 @@ class SchemaCommonFlowHandler:
|
||||
"""Handle a form step."""
|
||||
form_step: SchemaFlowFormStep = cast(SchemaFlowFormStep, self._flow[step_id])
|
||||
|
||||
if (
|
||||
user_input is not None
|
||||
and (data_schema := await self._get_schema(form_step))
|
||||
and data_schema.schema
|
||||
and not self._handler.show_advanced_options
|
||||
):
|
||||
# Add advanced field default if not set
|
||||
for key in data_schema.schema:
|
||||
if isinstance(key, (vol.Optional, vol.Required)):
|
||||
if (
|
||||
key.description
|
||||
and key.description.get("advanced")
|
||||
and key.default is not vol.UNDEFINED
|
||||
and key not in self._options
|
||||
):
|
||||
user_input[str(key.schema)] = cast(
|
||||
Callable[[], Any], key.default
|
||||
)()
|
||||
|
||||
if user_input is not None and form_step.validate_user_input is not None:
|
||||
# Do extra validation of user input
|
||||
try:
|
||||
@@ -210,7 +191,7 @@ class SchemaCommonFlowHandler:
|
||||
if user_input is not None:
|
||||
# User input was validated successfully, update options
|
||||
self._update_and_remove_omitted_optional_keys(
|
||||
self._options, user_input, data_schema
|
||||
self._options, user_input, await self._get_schema(form_step)
|
||||
)
|
||||
|
||||
if user_input is not None or form_step.schema is None:
|
||||
@@ -230,12 +211,6 @@ class SchemaCommonFlowHandler:
|
||||
if (
|
||||
isinstance(key, vol.Optional)
|
||||
and key not in user_input
|
||||
and not (
|
||||
# don't remove advanced keys, if they are hidden
|
||||
key.description
|
||||
and key.description.get("advanced")
|
||||
and not self._handler.show_advanced_options
|
||||
)
|
||||
and not (
|
||||
# don't remove read_only keys
|
||||
isinstance(data_schema.schema[key], selector.Selector)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Automatically generated by gen_requirements_all.py, do not edit
|
||||
|
||||
aiodhcpwatcher==1.2.6
|
||||
aiodiscover==3.2.3
|
||||
aiodhcpwatcher==1.2.7
|
||||
aiodiscover==3.2.4
|
||||
aiodns==4.0.4
|
||||
aiogithubapi==26.0.0
|
||||
aiohttp-asyncmdnsresolver==0.1.1
|
||||
aiohttp-asyncmdnsresolver==0.2.0
|
||||
aiohttp-fast-zlib==0.3.0
|
||||
aiohttp==3.13.5
|
||||
aiohttp_cors==0.8.1
|
||||
@@ -22,7 +22,7 @@ awesomeversion==25.8.0
|
||||
bcrypt==5.0.0
|
||||
bleak-retry-connector==4.6.1
|
||||
bleak==3.0.2
|
||||
bluetooth-adapters==2.2.0
|
||||
bluetooth-adapters==2.3.0
|
||||
bluetooth-auto-recovery==1.6.4
|
||||
bluetooth-data-tools==1.29.18
|
||||
cached-ipaddress==1.1.1
|
||||
@@ -30,12 +30,12 @@ certifi>=2021.5.30
|
||||
ciso8601==2.3.3
|
||||
cronsim==2.7
|
||||
cryptography==48.0.0
|
||||
dbus-fast==5.0.9
|
||||
dbus-fast==5.0.14
|
||||
file-read-backwards==2.0.0
|
||||
fnv-hash-fast==2.0.3
|
||||
go2rtc-client==0.4.0
|
||||
ha-ffmpeg==3.2.2
|
||||
habluetooth==6.7.3
|
||||
habluetooth==6.7.4
|
||||
hass-nabucasa==2.2.0
|
||||
hassil==3.5.0
|
||||
home-assistant-bluetooth==2.0.0
|
||||
@@ -68,9 +68,9 @@ SQLAlchemy==2.0.49
|
||||
standard-aifc==3.13.0
|
||||
standard-telnetlib==3.13.0
|
||||
typing-extensions>=4.15.0,<5.0
|
||||
ulid-transform==2.2.1
|
||||
ulid-transform==2.2.9
|
||||
urllib3>=2.0
|
||||
uv==0.11.14
|
||||
uv==0.11.15
|
||||
voluptuous-openapi==0.3.0
|
||||
voluptuous-serialize==2.7.0
|
||||
voluptuous==0.15.2
|
||||
|
||||
@@ -99,6 +99,7 @@ Every check has a code following the
|
||||
| `W7407` | [`home-assistant-config-flow-polling-field`](#w7407-home-assistant-config-flow-polling-field) | Config flow should not include polling interval fields |
|
||||
| `W7408` | [`home-assistant-config-flow-name-field`](#w7408-home-assistant-config-flow-name-field) | Config flow should not include name fields |
|
||||
| `R7402` | [`home-assistant-unused-test-fixture-argument`](#r7402-home-assistant-unused-test-fixture-argument) | Unused test function argument should use `@pytest.mark.usefixtures` |
|
||||
| `W7422` | [`home-assistant-tests-direct-async-setup`](#w7422-home-assistant-tests-direct-async-setup) | Tests should not call an integration's `async_setup` directly |
|
||||
|
||||
|
||||
## `home_assistant_logger` checker
|
||||
@@ -340,3 +341,22 @@ only needed for its side effects.
|
||||
|
||||
This rule only applies to `test_*` functions, not to fixture functions.
|
||||
|
||||
|
||||
|
||||
## `home_assistant_tests_direct_async_setup` checker
|
||||
|
||||
Detects tests that call an integration's `async_setup` directly.
|
||||
|
||||
### `W7422`: `home-assistant-tests-direct-async-setup`
|
||||
|
||||
Tests should not invoke an integration's `async_setup` from
|
||||
`__init__.py` directly. Instead, tests should let Home Assistant drive
|
||||
the setup through the normal pipeline:
|
||||
|
||||
* For integrations with config entries, add a `MockConfigEntry` and
|
||||
call `await hass.config_entries.async_setup(entry.entry_id)`.
|
||||
* For integrations without config entries (system integrations), use
|
||||
`await async_setup_component(hass, DOMAIN, {...})` from
|
||||
`homeassistant.setup`.
|
||||
|
||||
See [epic #79](https://github.com/home-assistant/epics/issues/79).
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Checker for direct calls to ``async_setup`` from tests.
|
||||
|
||||
Tests should not invoke an integration's ``async_setup`` directly.
|
||||
Instead, tests should let Home Assistant perform the setup via the
|
||||
normal pipeline:
|
||||
|
||||
* For integrations with config entries, add a ``MockConfigEntry`` and
|
||||
call ``await hass.config_entries.async_setup(entry.entry_id)``.
|
||||
* For integrations without config entries (system integrations), use
|
||||
``await async_setup_component(hass, DOMAIN, {...})`` from
|
||||
``homeassistant.setup``.
|
||||
|
||||
This checker flags any ``await <something>.async_setup(...)`` or
|
||||
``await async_setup(...)`` call in a test module whose target resolves
|
||||
to a function defined in an integration's ``__init__`` module under
|
||||
``homeassistant.components.*``.
|
||||
"""
|
||||
|
||||
import astroid
|
||||
from astroid import nodes
|
||||
from pylint.checkers import BaseChecker
|
||||
from pylint.lint import PyLinter
|
||||
|
||||
from pylint_home_assistant.helpers.module_info import is_test_module, parse_module
|
||||
|
||||
|
||||
def _is_integration_async_setup(call: nodes.Call) -> bool:
|
||||
"""Return True if *call* targets an integration's ``async_setup``."""
|
||||
func = call.func
|
||||
if isinstance(func, nodes.Attribute):
|
||||
if func.attrname != "async_setup":
|
||||
return False
|
||||
elif isinstance(func, nodes.Name):
|
||||
if func.name != "async_setup":
|
||||
return False
|
||||
else:
|
||||
return False
|
||||
|
||||
try:
|
||||
inferred_values = list(func.infer())
|
||||
except astroid.InferenceError, astroid.AstroidError:
|
||||
return False
|
||||
|
||||
seen_qnames: set[str] = set()
|
||||
for inferred in inferred_values:
|
||||
if inferred is astroid.Uninferable:
|
||||
continue
|
||||
if not isinstance(inferred, (nodes.FunctionDef, nodes.AsyncFunctionDef)):
|
||||
continue
|
||||
qname = inferred.qname()
|
||||
if not qname or qname in seen_qnames:
|
||||
continue
|
||||
seen_qnames.add(qname)
|
||||
# qname is the function's fully-qualified name, e.g.
|
||||
# ``homeassistant.components.sun.async_setup``. Strip the function
|
||||
# name to get the module and parse it.
|
||||
module_qname = qname.rsplit(".", 1)[0]
|
||||
parsed = parse_module(module_qname)
|
||||
if parsed is None:
|
||||
continue
|
||||
# ``async_setup`` lives in the integration's ``__init__``.
|
||||
if parsed.module is None:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class DirectAsyncSetup(BaseChecker):
|
||||
"""Checker for direct calls to async_setup in tests."""
|
||||
|
||||
name = "home_assistant_tests_direct_async_setup"
|
||||
priority = -1
|
||||
msgs = {
|
||||
"W7422": (
|
||||
"Do not call `async_setup` directly from tests; set up a "
|
||||
"`MockConfigEntry` via `hass.config_entries.async_setup` or use "
|
||||
"`async_setup_component` instead",
|
||||
"home-assistant-tests-direct-async-setup",
|
||||
"Used when a test module calls an integration's `async_setup` "
|
||||
"directly. Tests should let Home Assistant drive the setup so "
|
||||
"the full setup pipeline is exercised.",
|
||||
),
|
||||
}
|
||||
options = ()
|
||||
|
||||
_in_test_module: bool = False
|
||||
|
||||
def visit_module(self, node: nodes.Module) -> None:
|
||||
"""Record whether the current module is a test module."""
|
||||
self._in_test_module = is_test_module(node.name)
|
||||
|
||||
def visit_call(self, node: nodes.Call) -> None:
|
||||
"""Flag direct calls to an integration's async_setup."""
|
||||
if not self._in_test_module:
|
||||
return
|
||||
if _is_integration_async_setup(node):
|
||||
self.add_message(
|
||||
"home-assistant-tests-direct-async-setup",
|
||||
node=node,
|
||||
)
|
||||
|
||||
|
||||
def register(linter: PyLinter) -> None:
|
||||
"""Register the checker."""
|
||||
linter.register_checker(DirectAsyncSetup(linter))
|
||||
+3
-3
@@ -31,7 +31,7 @@ dependencies = [
|
||||
"aiohttp==3.13.5",
|
||||
"aiohttp_cors==0.8.1",
|
||||
"aiohttp-fast-zlib==0.3.0",
|
||||
"aiohttp-asyncmdnsresolver==0.1.1",
|
||||
"aiohttp-asyncmdnsresolver==0.2.0",
|
||||
"aiozoneinfo==0.2.3",
|
||||
"annotatedyaml==1.0.2",
|
||||
"astral==2.2",
|
||||
@@ -72,9 +72,9 @@ dependencies = [
|
||||
"standard-aifc==3.13.0",
|
||||
"standard-telnetlib==3.13.0",
|
||||
"typing-extensions>=4.15.0,<5.0",
|
||||
"ulid-transform==2.2.1",
|
||||
"ulid-transform==2.2.9",
|
||||
"urllib3>=2.0",
|
||||
"uv==0.11.14",
|
||||
"uv==0.11.15",
|
||||
"voluptuous==0.15.2",
|
||||
"voluptuous-serialize==2.7.0",
|
||||
"voluptuous-openapi==0.3.0",
|
||||
|
||||
Generated
+3
-3
@@ -5,7 +5,7 @@
|
||||
# Home Assistant Core
|
||||
aiodns==4.0.4
|
||||
aiogithubapi==26.0.0
|
||||
aiohttp-asyncmdnsresolver==0.1.1
|
||||
aiohttp-asyncmdnsresolver==0.2.0
|
||||
aiohttp-fast-zlib==0.3.0
|
||||
aiohttp==3.13.5
|
||||
aiohttp_cors==0.8.1
|
||||
@@ -53,9 +53,9 @@ SQLAlchemy==2.0.49
|
||||
standard-aifc==3.13.0
|
||||
standard-telnetlib==3.13.0
|
||||
typing-extensions>=4.15.0,<5.0
|
||||
ulid-transform==2.2.1
|
||||
ulid-transform==2.2.9
|
||||
urllib3>=2.0
|
||||
uv==0.11.14
|
||||
uv==0.11.15
|
||||
voluptuous-openapi==0.3.0
|
||||
voluptuous-serialize==2.7.0
|
||||
voluptuous==0.15.2
|
||||
|
||||
Generated
+13
-13
@@ -233,10 +233,10 @@ aiocentriconnect==0.2.3
|
||||
aiocomelit==2.0.3
|
||||
|
||||
# homeassistant.components.dhcp
|
||||
aiodhcpwatcher==1.2.6
|
||||
aiodhcpwatcher==1.2.7
|
||||
|
||||
# homeassistant.components.dhcp
|
||||
aiodiscover==3.2.3
|
||||
aiodiscover==3.2.4
|
||||
|
||||
# homeassistant.components.dnsip
|
||||
aiodns==4.0.4
|
||||
@@ -438,7 +438,7 @@ aiotedee==0.3.0
|
||||
aiotractive==1.0.3
|
||||
|
||||
# homeassistant.components.unifi
|
||||
aiounifi==90
|
||||
aiounifi==91
|
||||
|
||||
# homeassistant.components.usb
|
||||
aiousbwatcher==1.1.2
|
||||
@@ -675,7 +675,7 @@ bluecurrent-api==1.3.2
|
||||
bluemaestro-ble==0.4.1
|
||||
|
||||
# homeassistant.components.bluetooth
|
||||
bluetooth-adapters==2.2.0
|
||||
bluetooth-adapters==2.3.0
|
||||
|
||||
# homeassistant.components.bluetooth
|
||||
bluetooth-auto-recovery==1.6.4
|
||||
@@ -797,7 +797,7 @@ datadog==0.52.0
|
||||
datapoint==0.12.1
|
||||
|
||||
# homeassistant.components.bluetooth
|
||||
dbus-fast==5.0.9
|
||||
dbus-fast==5.0.14
|
||||
|
||||
# homeassistant.components.debugpy
|
||||
debugpy==1.8.17
|
||||
@@ -1213,7 +1213,7 @@ ha-xthings-cloud==1.0.5
|
||||
habiticalib==0.4.7
|
||||
|
||||
# homeassistant.components.bluetooth
|
||||
habluetooth==6.7.3
|
||||
habluetooth==6.7.4
|
||||
|
||||
# homeassistant.components.hanna
|
||||
hanna-cloud==0.0.7
|
||||
@@ -1314,13 +1314,13 @@ ibeacon-ble==1.2.0
|
||||
# homeassistant.components.local_calendar
|
||||
# homeassistant.components.local_todo
|
||||
# homeassistant.components.remote_calendar
|
||||
ical==13.2.4
|
||||
ical==13.2.5
|
||||
|
||||
# homeassistant.components.caldav
|
||||
icalendar==6.3.1
|
||||
|
||||
# homeassistant.components.ping
|
||||
icmplib==3.0
|
||||
icmplib==3.0.4
|
||||
|
||||
# homeassistant.components.idasen_desk
|
||||
idasen-ha==2.6.5
|
||||
@@ -1350,7 +1350,7 @@ imgw_pib==2.2.0
|
||||
incomfort-client==0.7.0
|
||||
|
||||
# homeassistant.components.indevolt
|
||||
indevolt-api==1.8.1
|
||||
indevolt-api==1.8.2
|
||||
|
||||
# homeassistant.components.influxdb
|
||||
influxdb-client==1.50.0
|
||||
@@ -1362,7 +1362,7 @@ influxdb==5.3.1
|
||||
infrared-protocols==5.6.0
|
||||
|
||||
# homeassistant.components.inkbird
|
||||
inkbird-ble==1.2.3
|
||||
inkbird-ble==1.4.3
|
||||
|
||||
# homeassistant.components.insteon
|
||||
insteon-frontend-home-assistant==0.6.2
|
||||
@@ -1731,7 +1731,7 @@ ondilo==0.5.0
|
||||
onedrive-personal-sdk==0.1.7
|
||||
|
||||
# homeassistant.components.onvif
|
||||
onvif-zeep-async==4.0.4
|
||||
onvif-zeep-async==4.1.0
|
||||
|
||||
# homeassistant.components.onvif
|
||||
onvif_parsers==2.3.0
|
||||
@@ -2542,7 +2542,7 @@ pysmarty2==0.10.3
|
||||
pysmhi==2.0.0
|
||||
|
||||
# homeassistant.components.edl21
|
||||
pysml==0.1.5
|
||||
pysml==0.1.7
|
||||
|
||||
# homeassistant.components.smlight
|
||||
pysmlight==0.3.2
|
||||
@@ -3018,7 +3018,7 @@ smart-meter-texas==0.5.5
|
||||
snapcast==2.3.7
|
||||
|
||||
# homeassistant.components.sonos
|
||||
soco==0.30.15
|
||||
soco==0.31.1
|
||||
|
||||
# homeassistant.components.solaredge_local
|
||||
solaredge-local==0.2.3
|
||||
|
||||
@@ -140,6 +140,7 @@ def fixture_request(
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
port_management_payload: dict[str, Any],
|
||||
param_properties_payload: str,
|
||||
param_properties_status_code: int,
|
||||
param_ports_payload: str,
|
||||
mqtt_status_code: int,
|
||||
) -> Callable[[str], None]:
|
||||
@@ -149,12 +150,15 @@ def fixture_request(
|
||||
def _url_pattern(path: str) -> re.Pattern[str]:
|
||||
return re.compile(rf"^https?://{re.escape(host)}(?::\d+)?{path}$")
|
||||
|
||||
def _text_response(url: URL, text: str) -> AiohttpClientMockResponse:
|
||||
def _text_response(
|
||||
url: URL, text: str, status: int = 200
|
||||
) -> AiohttpClientMockResponse:
|
||||
return AiohttpClientMockResponse(
|
||||
"post",
|
||||
url,
|
||||
text=text,
|
||||
headers={"Content-Type": "text/plain"},
|
||||
status=status,
|
||||
)
|
||||
|
||||
async def _param_cgi_response(
|
||||
@@ -172,7 +176,9 @@ def fixture_request(
|
||||
if group == "root.Output":
|
||||
return _text_response(url, PORTS_RESPONSE)
|
||||
if group == "root.Properties":
|
||||
return _text_response(url, param_properties_payload)
|
||||
return _text_response(
|
||||
url, param_properties_payload, param_properties_status_code
|
||||
)
|
||||
if group == "root.PTZ":
|
||||
return _text_response(url, PTZ_RESPONSE)
|
||||
if group == "root.StreamProfile":
|
||||
@@ -276,9 +282,15 @@ def fixture_param_ports_data() -> str:
|
||||
return PORTS_RESPONSE
|
||||
|
||||
|
||||
@pytest.fixture(name="param_properties_status_code")
|
||||
def fixture_param_properties_status_code() -> int:
|
||||
"""Property parameter status code."""
|
||||
return 200
|
||||
|
||||
|
||||
@pytest.fixture(name="mqtt_status_code")
|
||||
def fixture_mqtt_status_code() -> int:
|
||||
"""Property parameter data."""
|
||||
"""MQTT status code."""
|
||||
return 200
|
||||
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from .const import DEFAULT_HOST, MAC, MODEL, NAME
|
||||
from .const import API_DISCOVERY_BASIC_DEVICE_INFO, DEFAULT_HOST, MAC, MODEL, NAME
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
@@ -146,6 +146,63 @@ async def test_flow_fails_on_api(
|
||||
assert result["errors"] == {"base": error}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_default_requests")
|
||||
@pytest.mark.parametrize("param_properties_status_code", [404])
|
||||
async def test_flow_aborts_if_no_serial_number(hass: HomeAssistant) -> None:
|
||||
"""Test that config flow aborts if property_handler is not initialized and no serial is found."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_PROTOCOL: "http",
|
||||
CONF_HOST: "1.2.3.4",
|
||||
CONF_USERNAME: "user",
|
||||
CONF_PASSWORD: "pass",
|
||||
CONF_PORT: 80,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "no_serial_number"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_default_requests")
|
||||
@pytest.mark.parametrize("api_discovery_items", [API_DISCOVERY_BASIC_DEVICE_INFO])
|
||||
async def test_flow_succeeds_with_basic_device_info(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test that config flow succeeds when basic device info is present (positive path)."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_PROTOCOL: "http",
|
||||
CONF_HOST: "1.2.3.4",
|
||||
CONF_USERNAME: "user",
|
||||
CONF_PASSWORD: "pass",
|
||||
CONF_PORT: 80,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == f"M1065-LW - {MAC}"
|
||||
assert result["data"][CONF_HOST] == "1.2.3.4"
|
||||
assert result["data"][CONF_MODEL] == "M1065-LW"
|
||||
assert result["data"][CONF_NAME] == f"M1065-LW - {MAC}"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_default_requests")
|
||||
async def test_flow_create_entry_multiple_existing_entries_of_same_model(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -13,13 +13,13 @@ from homeassistant.components.bluetooth.const import (
|
||||
CONF_DETAILS,
|
||||
CONF_MODE,
|
||||
CONF_PASSIVE,
|
||||
CONF_SOURCE,
|
||||
CONF_SOURCE_CONFIG_ENTRY_ID,
|
||||
CONF_SOURCE_DEVICE_ID,
|
||||
CONF_SOURCE_DOMAIN,
|
||||
CONF_SOURCE_MODEL,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.const import CONF_SOURCE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers import area_registry as ar, device_registry as dr
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Test bluetooth diagnostics."""
|
||||
|
||||
import sys
|
||||
from unittest.mock import ANY, MagicMock, patch
|
||||
|
||||
from bleak.backends.scanner import AdvertisementData, BLEDevice
|
||||
@@ -43,6 +44,7 @@ class FakeHaScanner(FakeScannerMixin, HaScanner):
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "linux", reason="Requires Linux Bluetooth stack")
|
||||
@patch("homeassistant.components.bluetooth.HaScanner", FakeHaScanner)
|
||||
@pytest.mark.usefixtures("enable_bluetooth", "two_adapters")
|
||||
async def test_diagnostics(
|
||||
@@ -255,7 +257,7 @@ async def test_diagnostics(
|
||||
"type": "FakeHaScanner",
|
||||
"current_mode": {
|
||||
"__type": "<enum 'BluetoothScanningMode'>",
|
||||
"repr": "<BluetoothScanningMode.AUTO: 'auto'>",
|
||||
"repr": "<BluetoothScanningMode.PASSIVE: 'passive'>",
|
||||
},
|
||||
"requested_mode": {
|
||||
"__type": "<enum 'BluetoothScanningMode'>",
|
||||
@@ -325,8 +327,10 @@ async def test_diagnostics_macos(
|
||||
"Core Bluetooth": {
|
||||
"adapter_type": None,
|
||||
"address": "00:00:00:00:00:00",
|
||||
"advertise": True,
|
||||
"manufacturer": "Apple",
|
||||
"passive_scan": False,
|
||||
"powered": True,
|
||||
"product": "Unknown MacOS Model",
|
||||
"product_id": "Unknown",
|
||||
"sw_version": ANY,
|
||||
@@ -346,8 +350,10 @@ async def test_diagnostics_macos(
|
||||
"Core Bluetooth": {
|
||||
"adapter_type": None,
|
||||
"address": "00:00:00:00:00:00",
|
||||
"advertise": True,
|
||||
"manufacturer": "Apple",
|
||||
"passive_scan": False,
|
||||
"powered": True,
|
||||
"product": "Unknown MacOS Model",
|
||||
"product_id": "Unknown",
|
||||
"sw_version": ANY,
|
||||
|
||||
@@ -28,7 +28,6 @@ from homeassistant.components.bluetooth.const import (
|
||||
BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS,
|
||||
CONF_MODE,
|
||||
CONF_PASSIVE,
|
||||
CONF_SOURCE,
|
||||
CONF_SOURCE_CONFIG_ENTRY_ID,
|
||||
CONF_SOURCE_DOMAIN,
|
||||
CONF_SOURCE_MODEL,
|
||||
@@ -47,7 +46,11 @@ from homeassistant.components.bluetooth.match import (
|
||||
SERVICE_UUID,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.const import (
|
||||
CONF_SOURCE,
|
||||
EVENT_HOMEASSISTANT_STARTED,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
@@ -220,6 +220,27 @@ async def test_websocket_bad_core_update(hass: HomeAssistant, client) -> None:
|
||||
assert msg["error"]["code"] == "invalid_format"
|
||||
|
||||
|
||||
async def test_websocket_core_update_invalid_time_zone(
|
||||
hass: HomeAssistant, client: MockHAClientWebSocket
|
||||
) -> None:
|
||||
"""Test core config update rejects an invalid time zone."""
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 8,
|
||||
"type": "config/core/update",
|
||||
"time_zone": "not/a/zone",
|
||||
}
|
||||
)
|
||||
|
||||
msg = await client.receive_json()
|
||||
|
||||
assert msg["id"] == 8
|
||||
assert msg["type"] == TYPE_RESULT
|
||||
assert not msg["success"]
|
||||
assert msg["error"]["code"] == "invalid_info"
|
||||
assert hass.config.time_zone != "not/a/zone"
|
||||
|
||||
|
||||
async def test_detect_config(hass: HomeAssistant, client) -> None:
|
||||
"""Test detect config."""
|
||||
with patch(
|
||||
|
||||
@@ -425,6 +425,7 @@ async def test_multiple_devices(hass: HomeAssistant) -> None:
|
||||
"sense_energy.SenseLink",
|
||||
return_value=Mock(start=AsyncMock(), close=AsyncMock()),
|
||||
):
|
||||
# pylint: disable-next=home-assistant-tests-direct-async-setup
|
||||
assert await emulated_kasa.async_setup(hass, CONFIG) is True
|
||||
await hass.async_block_till_done()
|
||||
await emulated_kasa.validate_configs(hass, config)
|
||||
|
||||
@@ -6,10 +6,17 @@ from aioesphomeapi import (
|
||||
InfraredCapability,
|
||||
InfraredInfo,
|
||||
)
|
||||
from aioesphomeapi.client import InfraredRFReceiveEventModel
|
||||
from infrared_protocols.commands.nec import NECCommand
|
||||
import pytest
|
||||
|
||||
from homeassistant.components import infrared
|
||||
from homeassistant.components.infrared import (
|
||||
DATA_COMPONENT,
|
||||
InfraredDeviceClass,
|
||||
InfraredReceivedSignal,
|
||||
InfraredReceiverEntity,
|
||||
)
|
||||
from homeassistant.const import STATE_UNAVAILABLE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -33,32 +40,47 @@ async def _mock_ir_device(
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("capabilities", "entity_created"),
|
||||
("capabilities", "expected_device_class", "emitter_count", "receiver_count"),
|
||||
[
|
||||
(InfraredCapability.TRANSMITTER, True),
|
||||
(InfraredCapability.RECEIVER, False),
|
||||
(InfraredCapability.TRANSMITTER | InfraredCapability.RECEIVER, True),
|
||||
(InfraredCapability(0), False),
|
||||
pytest.param(
|
||||
InfraredCapability.TRANSMITTER,
|
||||
InfraredDeviceClass.EMITTER,
|
||||
1,
|
||||
0,
|
||||
id="transmitter",
|
||||
),
|
||||
pytest.param(
|
||||
InfraredCapability.RECEIVER,
|
||||
InfraredDeviceClass.RECEIVER,
|
||||
0,
|
||||
1,
|
||||
id="receiver",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_infrared_entity_transmitter(
|
||||
async def test_infrared_entity_single_capability(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
capabilities: InfraredCapability,
|
||||
entity_created: bool,
|
||||
expected_device_class: InfraredDeviceClass,
|
||||
emitter_count: int,
|
||||
receiver_count: int,
|
||||
) -> None:
|
||||
"""Test infrared entity with transmitter capability is created."""
|
||||
"""Test infrared entity is created with the right device class per capability."""
|
||||
await _mock_ir_device(mock_esphome_device, mock_client, capabilities)
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert (state is not None) == entity_created
|
||||
assert (state is not None) == (expected_device_class is not None)
|
||||
assert state.attributes["device_class"] == expected_device_class
|
||||
|
||||
emitters = infrared.async_get_emitters(hass)
|
||||
assert (len(emitters) == 1) == entity_created
|
||||
assert len(emitters) == emitter_count
|
||||
receivers = infrared.async_get_receivers(hass)
|
||||
assert len(receivers) == receiver_count
|
||||
|
||||
|
||||
async def test_infrared_multiple_entities_mixed_capabilities(
|
||||
async def test_infrared_entity_dual_capability(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
@@ -77,12 +99,6 @@ async def test_infrared_multiple_entities_mixed_capabilities(
|
||||
name="IR Receiver",
|
||||
capabilities=InfraredCapability.RECEIVER,
|
||||
),
|
||||
InfraredInfo(
|
||||
object_id="ir_transceiver",
|
||||
key=3,
|
||||
name="IR Transceiver",
|
||||
capabilities=InfraredCapability.TRANSMITTER | InfraredCapability.RECEIVER,
|
||||
),
|
||||
]
|
||||
await mock_esphome_device(
|
||||
mock_client=mock_client,
|
||||
@@ -90,13 +106,18 @@ async def test_infrared_multiple_entities_mixed_capabilities(
|
||||
states=[],
|
||||
)
|
||||
|
||||
# Only transmitter and transceiver should be created
|
||||
assert hass.states.get("infrared.test_ir_transmitter") is not None
|
||||
assert hass.states.get("infrared.test_ir_receiver") is None
|
||||
assert hass.states.get("infrared.test_ir_transceiver") is not None
|
||||
transmitter_state = hass.states.get("infrared.test_ir_transmitter")
|
||||
assert transmitter_state is not None
|
||||
assert transmitter_state.attributes["device_class"] == InfraredDeviceClass.EMITTER
|
||||
|
||||
receiver_state = hass.states.get("infrared.test_ir_receiver")
|
||||
assert receiver_state is not None
|
||||
assert receiver_state.attributes["device_class"] == InfraredDeviceClass.RECEIVER
|
||||
|
||||
emitters = infrared.async_get_emitters(hass)
|
||||
assert len(emitters) == 2
|
||||
assert len(emitters) == 1
|
||||
receivers = infrared.async_get_receivers(hass)
|
||||
assert len(receivers) == 1
|
||||
|
||||
|
||||
async def test_infrared_send_command_success(
|
||||
@@ -146,6 +167,77 @@ async def test_infrared_send_command_failure(
|
||||
assert exc_info.value.translation_key == "error_communicating_with_device"
|
||||
|
||||
|
||||
async def test_infrared_receiver_signal_dispatched(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test receiver subscribes to events and dispatches received signals."""
|
||||
await _mock_ir_device(
|
||||
mock_esphome_device, mock_client, capabilities=InfraredCapability.RECEIVER
|
||||
)
|
||||
|
||||
mock_client.subscribe_infrared_rf_receive.assert_called_once()
|
||||
on_event = mock_client.subscribe_infrared_rf_receive.call_args[0][0]
|
||||
|
||||
receiver = hass.data[DATA_COMPONENT].get_entity(ENTITY_ID)
|
||||
assert isinstance(receiver, InfraredReceiverEntity)
|
||||
received_signals: list[InfraredReceivedSignal] = []
|
||||
receiver.async_subscribe_received_signal(received_signals.append)
|
||||
|
||||
timings = [100, -200, 300]
|
||||
on_event(InfraredRFReceiveEventModel(key=1, device_id=0, timings=timings))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert received_signals == [InfraredReceivedSignal(timings=timings)]
|
||||
assert hass.states.get(ENTITY_ID).state != STATE_UNAVAILABLE
|
||||
|
||||
# Test events with wrong key/device_id are ignored
|
||||
on_event(InfraredRFReceiveEventModel(key=99, device_id=0, timings=timings))
|
||||
on_event(InfraredRFReceiveEventModel(key=1, device_id=42, timings=timings))
|
||||
await hass.async_block_till_done()
|
||||
assert len(received_signals) == 1
|
||||
|
||||
|
||||
async def test_infrared_receiver_unsubscribes_on_unload(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test receiver unsubscribes from device events when its entry is unloaded."""
|
||||
mock_device = await _mock_ir_device(
|
||||
mock_esphome_device, mock_client, capabilities=InfraredCapability.RECEIVER
|
||||
)
|
||||
|
||||
unsub = mock_client.subscribe_infrared_rf_receive.return_value
|
||||
unsub.assert_not_called()
|
||||
|
||||
await hass.config_entries.async_unload(mock_device.entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
unsub.assert_called_once()
|
||||
|
||||
|
||||
async def test_infrared_receiver_resubscribes_on_reconnect(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test receiver re-subscribes to events after a reconnect."""
|
||||
mock_device = await _mock_ir_device(
|
||||
mock_esphome_device, mock_client, capabilities=InfraredCapability.RECEIVER
|
||||
)
|
||||
|
||||
assert mock_client.subscribe_infrared_rf_receive.call_count == 1
|
||||
|
||||
await mock_device.mock_disconnect(False)
|
||||
await hass.async_block_till_done()
|
||||
await mock_device.mock_connect()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_client.subscribe_infrared_rf_receive.call_count == 2
|
||||
|
||||
|
||||
async def test_infrared_entity_availability(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
|
||||
@@ -132,18 +132,40 @@ async def test_reload_core_conf(hass: HomeAssistant) -> None:
|
||||
@patch("homeassistant.config.os.path.isfile", Mock(return_value=True))
|
||||
@patch("homeassistant.components.homeassistant._LOGGER.error")
|
||||
@patch("homeassistant.core_config.async_process_ha_core_config")
|
||||
@pytest.mark.parametrize(
|
||||
("files_patch", "expected_error"),
|
||||
[
|
||||
(
|
||||
{config.YAML_CONFIG_FILE: yaml.dump(["invalid", "config"])},
|
||||
"YAML file .*configuration.yaml does not contain a dict",
|
||||
),
|
||||
({"not_existing": "blabla"}, "File not found: .*configuration.yaml"),
|
||||
],
|
||||
)
|
||||
async def test_reload_core_with_wrong_conf(
|
||||
mock_process, mock_error, hass: HomeAssistant
|
||||
mock_process,
|
||||
mock_error,
|
||||
hass: HomeAssistant,
|
||||
files_patch: dict[str, str],
|
||||
expected_error: str,
|
||||
) -> None:
|
||||
"""Test reload core conf service."""
|
||||
files = {config.YAML_CONFIG_FILE: yaml.dump(["invalid", "config"])}
|
||||
await async_setup_component(hass, ha.DOMAIN, {})
|
||||
with patch_yaml_files(files, True):
|
||||
with (
|
||||
patch_yaml_files(files_patch, True),
|
||||
pytest.raises(
|
||||
HomeAssistantError,
|
||||
match=(
|
||||
"Failed to reload the Home Assistant Core configuration - "
|
||||
f"{expected_error}"
|
||||
),
|
||||
),
|
||||
):
|
||||
await hass.services.async_call(
|
||||
ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG, blocking=True
|
||||
)
|
||||
|
||||
assert mock_error.called
|
||||
assert mock_error.called is False
|
||||
assert mock_process.called is False
|
||||
|
||||
|
||||
|
||||
@@ -4,15 +4,17 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import voluptuous as vol
|
||||
import yaml
|
||||
|
||||
from homeassistant import config
|
||||
from homeassistant.components.homeassistant import scene as ha_scene
|
||||
from homeassistant.components.homeassistant.scene import EVENT_SCENE_RELOADED
|
||||
from homeassistant.const import STATE_UNKNOWN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import async_capture_events, async_mock_service
|
||||
from tests.common import async_capture_events, async_mock_service, patch_yaml_files
|
||||
|
||||
|
||||
async def test_reload_config_service(hass: HomeAssistant) -> None:
|
||||
@@ -46,6 +48,36 @@ async def test_reload_config_service(hass: HomeAssistant) -> None:
|
||||
assert hass.states.get("scene.bye") is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("files_patch", "expected_error"),
|
||||
[
|
||||
(
|
||||
{config.YAML_CONFIG_FILE: yaml.dump(["invalid", "config"])},
|
||||
"YAML file .*configuration.yaml does not contain a dict",
|
||||
),
|
||||
({"not_existing": "blabla"}, "File not found: .*configuration.yaml"),
|
||||
],
|
||||
)
|
||||
async def test_reload_config_service_failed(
|
||||
hass: HomeAssistant, files_patch: dict[str, str], expected_error: str
|
||||
) -> None:
|
||||
"""Test error handling when the reload config service fails."""
|
||||
assert await async_setup_component(hass, "scene", {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
with (
|
||||
patch_yaml_files(files_patch, True),
|
||||
pytest.raises(
|
||||
HomeAssistantError,
|
||||
match=(
|
||||
"Failed to reload the Home Assistant scene platform configuration - "
|
||||
f"{expected_error}"
|
||||
),
|
||||
),
|
||||
):
|
||||
await hass.services.async_call("scene", "reload", blocking=True)
|
||||
|
||||
|
||||
async def test_apply_service(hass: HomeAssistant) -> None:
|
||||
"""Test the apply service."""
|
||||
assert await async_setup_component(hass, "scene", {})
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
"9117": 31.9,
|
||||
"9133": 33.0,
|
||||
"9270": 31.2,
|
||||
"9003": 150,
|
||||
"8646": 150,
|
||||
"11005": 35.5,
|
||||
"7171": 1,
|
||||
"680": 0
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
'11011': 85,
|
||||
'11016': 0,
|
||||
'11034': 100,
|
||||
'11042': 32.1,
|
||||
'142': 1.79,
|
||||
'1501': 0,
|
||||
'1502': 0,
|
||||
@@ -112,8 +113,8 @@
|
||||
'7101': 1,
|
||||
'7120': 1001,
|
||||
'7171': 1,
|
||||
'8646': 150,
|
||||
'9000': 92,
|
||||
'9003': 150,
|
||||
'9004': 51.2,
|
||||
'9008': '**REDACTED**',
|
||||
'9012': 25.5,
|
||||
|
||||
@@ -4511,7 +4511,7 @@
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'cycle_count',
|
||||
'unique_id': 'SolidFlex2000-87654321_9003',
|
||||
'unique_id': 'SolidFlex2000-87654321_8646',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
@@ -5641,6 +5641,64 @@
|
||||
'state': '15.2',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[2][sensor.cms_sf2000_main_mos_temperature-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.cms_sf2000_main_mos_temperature',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Main MOS temperature',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 1,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Main MOS temperature',
|
||||
'platform': 'indevolt',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'main_mos_temperature',
|
||||
'unique_id': 'SolidFlex2000-87654321_11042',
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[2][sensor.cms_sf2000_main_mos_temperature-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'temperature',
|
||||
'friendly_name': 'CMS-SF2000 Main MOS temperature',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.cms_sf2000_main_mos_temperature',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '32.1',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[2][sensor.cms_sf2000_main_serial_number-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
|
||||
@@ -7743,6 +7743,7 @@
|
||||
'options': list([
|
||||
'anti_crease',
|
||||
'automatic_start',
|
||||
'cancelled',
|
||||
'cleaning',
|
||||
'cooling_down',
|
||||
'disinfecting',
|
||||
@@ -7754,6 +7755,7 @@
|
||||
'hygiene',
|
||||
'main_wash',
|
||||
'not_running',
|
||||
'paused',
|
||||
'pre_wash',
|
||||
'rinse',
|
||||
'rinse_hold',
|
||||
@@ -7802,6 +7804,7 @@
|
||||
'options': list([
|
||||
'anti_crease',
|
||||
'automatic_start',
|
||||
'cancelled',
|
||||
'cleaning',
|
||||
'cooling_down',
|
||||
'disinfecting',
|
||||
@@ -7813,6 +7816,7 @@
|
||||
'hygiene',
|
||||
'main_wash',
|
||||
'not_running',
|
||||
'paused',
|
||||
'pre_wash',
|
||||
'rinse',
|
||||
'rinse_hold',
|
||||
@@ -11553,6 +11557,7 @@
|
||||
'options': list([
|
||||
'anti_crease',
|
||||
'automatic_start',
|
||||
'cancelled',
|
||||
'cleaning',
|
||||
'cooling_down',
|
||||
'disinfecting',
|
||||
@@ -11564,6 +11569,7 @@
|
||||
'hygiene',
|
||||
'main_wash',
|
||||
'not_running',
|
||||
'paused',
|
||||
'pre_wash',
|
||||
'rinse',
|
||||
'rinse_hold',
|
||||
@@ -11612,6 +11618,7 @@
|
||||
'options': list([
|
||||
'anti_crease',
|
||||
'automatic_start',
|
||||
'cancelled',
|
||||
'cleaning',
|
||||
'cooling_down',
|
||||
'disinfecting',
|
||||
@@ -11623,6 +11630,7 @@
|
||||
'hygiene',
|
||||
'main_wash',
|
||||
'not_running',
|
||||
'paused',
|
||||
'pre_wash',
|
||||
'rinse',
|
||||
'rinse_hold',
|
||||
|
||||
@@ -138,6 +138,51 @@ async def setup_zone(hass: HomeAssistant) -> None:
|
||||
},
|
||||
"School",
|
||||
),
|
||||
# Send in_zones only - first zone determines state
|
||||
(
|
||||
{"in_zones": ["zone.home"]},
|
||||
{"in_zones": ["zone.home"]},
|
||||
"home",
|
||||
),
|
||||
(
|
||||
{"in_zones": ["zone.office"]},
|
||||
{"in_zones": ["zone.office"]},
|
||||
"Office",
|
||||
),
|
||||
(
|
||||
{"in_zones": ["zone.home", "zone.office"]},
|
||||
{"in_zones": ["zone.home", "zone.office"]},
|
||||
"home",
|
||||
),
|
||||
# Empty in_zones list - not_home
|
||||
(
|
||||
{"in_zones": []},
|
||||
{"in_zones": []},
|
||||
"not_home",
|
||||
),
|
||||
# in_zones + location_name: in_zones wins, location_name ignored
|
||||
(
|
||||
{"in_zones": ["zone.office"], "location_name": "home"},
|
||||
{"in_zones": ["zone.office"]},
|
||||
"Office",
|
||||
),
|
||||
# in_zones with empty list still suppresses location_name
|
||||
(
|
||||
{"in_zones": [], "location_name": "home"},
|
||||
{"in_zones": []},
|
||||
"not_home",
|
||||
),
|
||||
# in_zones + gps: gps wins, in_zones recomputed from coordinates
|
||||
(
|
||||
{"gps": [10, 20], "in_zones": ["zone.school"]},
|
||||
{
|
||||
"latitude": 10,
|
||||
"longitude": 20,
|
||||
"gps_accuracy": 30,
|
||||
"in_zones": ["zone.home"],
|
||||
},
|
||||
"home",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_sending_location(
|
||||
@@ -341,6 +386,32 @@ async def test_restoring_location(
|
||||
}
|
||||
},
|
||||
),
|
||||
# in_zones only
|
||||
(
|
||||
{"in_zones": ["zone.office"]},
|
||||
"Office",
|
||||
{
|
||||
"friendly_name": "Test 1",
|
||||
"source_type": "gps",
|
||||
"battery_level": 40,
|
||||
"altitude": 50.0,
|
||||
"course": 60,
|
||||
"speed": 70,
|
||||
"vertical_accuracy": 80,
|
||||
"in_zones": ["zone.office"],
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"gps_accuracy": 30,
|
||||
"battery": 40,
|
||||
"altitude": 50.0,
|
||||
"course": 60,
|
||||
"speed": 70,
|
||||
"vertical_accuracy": 80,
|
||||
"in_zones": ["zone.office"],
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_saving_state(
|
||||
@@ -454,6 +525,42 @@ async def test_saving_state(
|
||||
"in_zones": [],
|
||||
},
|
||||
),
|
||||
# Last update was an in_zones list (no coords)
|
||||
(
|
||||
{
|
||||
"in_zones": ["zone.office"],
|
||||
"battery": 40,
|
||||
"altitude": 50.0,
|
||||
"course": 60,
|
||||
"speed": 70,
|
||||
"vertical_accuracy": 80,
|
||||
},
|
||||
"Office",
|
||||
{
|
||||
"friendly_name": "Test 1",
|
||||
"source_type": "gps",
|
||||
"battery_level": 40,
|
||||
"altitude": 50.0,
|
||||
"course": 60,
|
||||
"speed": 70,
|
||||
"vertical_accuracy": 80,
|
||||
"in_zones": ["zone.office"],
|
||||
},
|
||||
),
|
||||
# Empty in_zones list - not_home
|
||||
(
|
||||
{
|
||||
"in_zones": [],
|
||||
"battery": 40,
|
||||
},
|
||||
"not_home",
|
||||
{
|
||||
"friendly_name": "Test 1",
|
||||
"source_type": "gps",
|
||||
"battery_level": 40,
|
||||
"in_zones": [],
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_restoring_state(
|
||||
@@ -602,6 +709,8 @@ async def test_restoring_state_legacy_fallback(
|
||||
{"battery": -1},
|
||||
# gps_accuracy rejected by cv.positive_float
|
||||
{"gps_accuracy": "not-a-number"},
|
||||
# in_zones contains a non-zone entity_id
|
||||
{"in_zones": ["sensor.foo"]},
|
||||
],
|
||||
)
|
||||
async def test_restoring_state_invalid_extra_data(
|
||||
|
||||
@@ -833,6 +833,99 @@ async def test_webhook_update_location_with_location_name(
|
||||
assert state.state == STATE_NOT_HOME
|
||||
|
||||
|
||||
async def test_webhook_update_location_with_in_zones(
|
||||
hass: HomeAssistant,
|
||||
create_registrations: tuple[dict[str, Any], dict[str, Any]],
|
||||
webhook_client: TestClient,
|
||||
) -> None:
|
||||
"""Test that in_zones can be set via the update_location webhook."""
|
||||
with patch(
|
||||
"homeassistant.config.load_yaml_config_file",
|
||||
autospec=True,
|
||||
return_value={
|
||||
ZONE_DOMAIN: [
|
||||
{
|
||||
"name": "zone_name",
|
||||
"latitude": 1.23,
|
||||
"longitude": -4.56,
|
||||
"radius": 200,
|
||||
"icon": "mdi:test-tube",
|
||||
},
|
||||
]
|
||||
},
|
||||
):
|
||||
await hass.services.async_call(ZONE_DOMAIN, "reload", blocking=True)
|
||||
|
||||
resp = await webhook_client.post(
|
||||
f"/api/webhook/{create_registrations[1]['webhook_id']}",
|
||||
json={
|
||||
"type": "update_location",
|
||||
"data": {"in_zones": ["zone.zone_name"]},
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status == HTTPStatus.OK
|
||||
|
||||
state = hass.states.get("device_tracker.test_1_2")
|
||||
assert state is not None
|
||||
assert state.state == "zone_name"
|
||||
assert state.attributes["in_zones"] == ["zone.zone_name"]
|
||||
|
||||
# Empty list reports not_home
|
||||
resp = await webhook_client.post(
|
||||
f"/api/webhook/{create_registrations[1]['webhook_id']}",
|
||||
json={
|
||||
"type": "update_location",
|
||||
"data": {"in_zones": []},
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status == HTTPStatus.OK
|
||||
|
||||
state = hass.states.get("device_tracker.test_1_2")
|
||||
assert state is not None
|
||||
assert state.state == STATE_NOT_HOME
|
||||
assert state.attributes["in_zones"] == []
|
||||
|
||||
|
||||
async def test_webhook_update_location_in_zones_rejects_non_zone_entity(
|
||||
hass: HomeAssistant,
|
||||
create_registrations: tuple[dict[str, Any], dict[str, Any]],
|
||||
webhook_client: TestClient,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test that in_zones rejects entity_ids outside the zone domain."""
|
||||
# First, set a valid state so we can verify it isn't overwritten by the
|
||||
# rejected payload.
|
||||
resp = await webhook_client.post(
|
||||
f"/api/webhook/{create_registrations[1]['webhook_id']}",
|
||||
json={
|
||||
"type": "update_location",
|
||||
"data": {"location_name": STATE_HOME},
|
||||
},
|
||||
)
|
||||
assert resp.status == HTTPStatus.OK
|
||||
state = hass.states.get("device_tracker.test_1_2")
|
||||
assert state is not None
|
||||
assert state.state == STATE_HOME
|
||||
|
||||
# Send a payload with an invalid in_zones entry; the webhook responds OK
|
||||
# but the payload is dropped.
|
||||
resp = await webhook_client.post(
|
||||
f"/api/webhook/{create_registrations[1]['webhook_id']}",
|
||||
json={
|
||||
"type": "update_location",
|
||||
"data": {"in_zones": ["sensor.not_a_zone"]},
|
||||
},
|
||||
)
|
||||
assert resp.status == HTTPStatus.OK
|
||||
assert "Received invalid webhook payload" in caplog.text
|
||||
|
||||
state = hass.states.get("device_tracker.test_1_2")
|
||||
assert state is not None
|
||||
assert state.state == STATE_HOME
|
||||
|
||||
|
||||
async def test_webhook_enable_encryption(
|
||||
hass: HomeAssistant,
|
||||
create_registrations: tuple[dict[str, Any], dict[str, Any]],
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
{
|
||||
"056ee145a816487eaa69243c3280f8bf": {
|
||||
"available": true,
|
||||
"binary_sensors": {
|
||||
"dhw_state": false,
|
||||
"flame_state": false,
|
||||
"heating_state": false
|
||||
},
|
||||
"dev_class": "heater_central",
|
||||
"location": "bc93488efab249e5bc54fd7e175a6f91",
|
||||
"max_dhw_temperature": {
|
||||
"lower_bound": 40.0,
|
||||
"resolution": 0.01,
|
||||
"setpoint": 60.0,
|
||||
"upper_bound": 60.0
|
||||
},
|
||||
"maximum_boiler_temperature": {
|
||||
"lower_bound": 25.0,
|
||||
"resolution": 0.01,
|
||||
"setpoint": 50.0,
|
||||
"upper_bound": 95.0
|
||||
},
|
||||
"model": "Generic heater",
|
||||
"name": "OpenTherm",
|
||||
"sensors": {
|
||||
"intended_boiler_temperature": 0.0,
|
||||
"water_temperature": 37.0
|
||||
},
|
||||
"switches": {
|
||||
"dhw_cm_switch": false
|
||||
}
|
||||
},
|
||||
"14df5c4dc8cb4ba69f9d1ac0eaf7c5c6": {
|
||||
"available": true,
|
||||
"binary_sensors": {
|
||||
"low_battery": false
|
||||
},
|
||||
"dev_class": "zone_thermostat",
|
||||
"firmware": "2025-11-10T01:00:00+01:00",
|
||||
"hardware": "1",
|
||||
"location": "f2bf9048bef64cc5b6d5110154e33c81",
|
||||
"model": "Emma Pro",
|
||||
"model_id": "170-01",
|
||||
"name": "Emma",
|
||||
"sensors": {
|
||||
"battery": 100,
|
||||
"humidity": 65.0,
|
||||
"setpoint": 20.0,
|
||||
"temperature": 19.5
|
||||
},
|
||||
"temperature_offset": {
|
||||
"lower_bound": -2.0,
|
||||
"resolution": 0.1,
|
||||
"setpoint": 0.0,
|
||||
"upper_bound": 2.0
|
||||
},
|
||||
"vendor": "Plugwise",
|
||||
"zigbee_mac_address": "60EFABFFFE89CBA0"
|
||||
},
|
||||
"1772a4ea304041adb83f357b751341ff": {
|
||||
"available": true,
|
||||
"binary_sensors": {
|
||||
"low_battery": false
|
||||
},
|
||||
"dev_class": "thermostatic_radiator_valve",
|
||||
"firmware": "2020-11-04T01:00:00+01:00",
|
||||
"hardware": "1",
|
||||
"location": "f871b8c4d63549319221e294e4f88074",
|
||||
"model": "Tom",
|
||||
"model_id": "106-03",
|
||||
"name": "Tom Badkamer",
|
||||
"sensors": {
|
||||
"battery": 60,
|
||||
"setpoint": 25.0,
|
||||
"temperature": 18.6,
|
||||
"temperature_difference": -0.4,
|
||||
"valve_position": 100.0
|
||||
},
|
||||
"temperature_offset": {
|
||||
"lower_bound": -2.0,
|
||||
"resolution": 0.1,
|
||||
"setpoint": 0.1,
|
||||
"upper_bound": 2.0
|
||||
},
|
||||
"vendor": "Plugwise",
|
||||
"zigbee_mac_address": "000D6F000C8FCBA0"
|
||||
},
|
||||
"ad4838d7d35c4d6ea796ee12ae5aedf8": {
|
||||
"dev_class": "thermostat",
|
||||
"location": "f2bf9048bef64cc5b6d5110154e33c81",
|
||||
"model": "ThermoTouch",
|
||||
"model_id": "143.1",
|
||||
"name": "Anna",
|
||||
"sensors": {
|
||||
"setpoint": 20.0,
|
||||
"temperature": 19.1
|
||||
},
|
||||
"vendor": "Plugwise"
|
||||
},
|
||||
"c9293d1d68ee48fc8843c6f0dee2b6be": {
|
||||
"dev_class": "pumping",
|
||||
"members": [
|
||||
"854f8a9b0e7e425db97f1f110e1ce4b3",
|
||||
"ad4838d7d35c4d6ea796ee12ae5aedf8"
|
||||
],
|
||||
"model": "Group",
|
||||
"name": "Vloerverwarming",
|
||||
"sensors": {
|
||||
"electricity_consumed": 45.0,
|
||||
"electricity_produced": 0.0,
|
||||
"temperature": 20.1
|
||||
},
|
||||
"vendor": "Plugwise"
|
||||
},
|
||||
"da224107914542988a88561b4452b0f6": {
|
||||
"binary_sensors": {
|
||||
"plugwise_notification": false
|
||||
},
|
||||
"dev_class": "gateway",
|
||||
"firmware": "3.9.0",
|
||||
"gateway_modes": ["away", "full", "vacation"],
|
||||
"hardware": "AME Smile 2.0 board",
|
||||
"location": "bc93488efab249e5bc54fd7e175a6f91",
|
||||
"mac_address": "D40FB201CBA0",
|
||||
"model": "Gateway",
|
||||
"model_id": "smile_open_therm",
|
||||
"name": "Adam",
|
||||
"notifications": {},
|
||||
"regulation_modes": ["bleeding_cold", "heating", "off", "bleeding_hot"],
|
||||
"select_gateway_mode": "full",
|
||||
"select_regulation_mode": "off",
|
||||
"sensors": {
|
||||
"outdoor_temperature": -1.25
|
||||
},
|
||||
"vendor": "Plugwise",
|
||||
"zigbee_mac_address": "000D6F000D5ACBA0"
|
||||
},
|
||||
"da575e9e09b947e281fb6e3ebce3b174": {
|
||||
"available": true,
|
||||
"binary_sensors": {
|
||||
"low_battery": false
|
||||
},
|
||||
"dev_class": "zone_thermometer",
|
||||
"firmware": "2020-09-01T02:00:00+02:00",
|
||||
"hardware": "1",
|
||||
"location": "f2bf9048bef64cc5b6d5110154e33c81",
|
||||
"model": "Jip",
|
||||
"model_id": "168-01",
|
||||
"name": "Jip",
|
||||
"sensors": {
|
||||
"battery": 100,
|
||||
"humidity": 65.8,
|
||||
"setpoint": 20.0,
|
||||
"temperature": 19.3
|
||||
},
|
||||
"vendor": "Plugwise",
|
||||
"zigbee_mac_address": "70AC08FFFEE1CBA0"
|
||||
},
|
||||
"e2f4322d57924fa090fbbc48b3a140dc": {
|
||||
"available": true,
|
||||
"binary_sensors": {
|
||||
"low_battery": false
|
||||
},
|
||||
"dev_class": "zone_thermostat",
|
||||
"firmware": "2016-10-10T02:00:00+02:00",
|
||||
"hardware": "255",
|
||||
"location": "f871b8c4d63549319221e294e4f88074",
|
||||
"model": "Lisa",
|
||||
"model_id": "158-01",
|
||||
"name": "Lisa Badkamer",
|
||||
"sensors": {
|
||||
"battery": 71,
|
||||
"setpoint": 15.0,
|
||||
"temperature": 17.9
|
||||
},
|
||||
"temperature_offset": {
|
||||
"lower_bound": -2.0,
|
||||
"resolution": 0.1,
|
||||
"setpoint": 0.0,
|
||||
"upper_bound": 2.0
|
||||
},
|
||||
"vendor": "Plugwise",
|
||||
"zigbee_mac_address": "000D6F000C86CBA0"
|
||||
},
|
||||
"e8ef2a01ed3b4139a53bf749204fe6b4": {
|
||||
"dev_class": "switching",
|
||||
"members": [
|
||||
"2568cc4b9c1e401495d4741a5f89bee1",
|
||||
"29542b2b6a6a4169acecc15c72a599b8"
|
||||
],
|
||||
"model": "Group",
|
||||
"name": "Test",
|
||||
"sensors": {
|
||||
"electricity_consumed": 16.5,
|
||||
"electricity_produced": 0.0
|
||||
},
|
||||
"switches": {
|
||||
"relay": true
|
||||
},
|
||||
"vendor": "Plugwise"
|
||||
},
|
||||
"f2bf9048bef64cc5b6d5110154e33c81": {
|
||||
"active_preset": "home",
|
||||
"available_schedules": [
|
||||
"Badkamer",
|
||||
"Vakantie",
|
||||
"Weekschema",
|
||||
"Test",
|
||||
"off"
|
||||
],
|
||||
"climate_mode": "off",
|
||||
"control_state": "idle",
|
||||
"dev_class": "climate",
|
||||
"model": "ThermoZone",
|
||||
"name": "Living room",
|
||||
"preset_modes": ["vacation", "no_frost", "asleep", "home", "away"],
|
||||
"select_schedule": "off",
|
||||
"select_zone_profile": "active",
|
||||
"sensors": {
|
||||
"electricity_consumed": 60.8,
|
||||
"electricity_produced": 0.0,
|
||||
"temperature": 19.1
|
||||
},
|
||||
"thermostat": {
|
||||
"lower_bound": 1.0,
|
||||
"resolution": 0.01,
|
||||
"setpoint": 20.0,
|
||||
"upper_bound": 35.0
|
||||
},
|
||||
"thermostats": {
|
||||
"primary": [
|
||||
"ad4838d7d35c4d6ea796ee12ae5aedf8",
|
||||
"14df5c4dc8cb4ba69f9d1ac0eaf7c5c6",
|
||||
"da575e9e09b947e281fb6e3ebce3b174"
|
||||
],
|
||||
"secondary": []
|
||||
},
|
||||
"vendor": "Plugwise",
|
||||
"zone_profiles": ["active", "off", "passive"]
|
||||
},
|
||||
"f871b8c4d63549319221e294e4f88074": {
|
||||
"active_preset": "vacation",
|
||||
"available_schedules": [
|
||||
"Badkamer",
|
||||
"Vakantie",
|
||||
"Weekschema",
|
||||
"Test",
|
||||
"off"
|
||||
],
|
||||
"climate_mode": "off",
|
||||
"control_state": "idle",
|
||||
"dev_class": "climate",
|
||||
"model": "ThermoZone",
|
||||
"name": "Bathroom",
|
||||
"preset_modes": ["vacation", "no_frost", "asleep", "home", "away"],
|
||||
"select_schedule": "Badkamer",
|
||||
"select_zone_profile": "passive",
|
||||
"sensors": {
|
||||
"electricity_consumed": 0.0,
|
||||
"electricity_produced": 0.0,
|
||||
"temperature": 17.9
|
||||
},
|
||||
"thermostat": {
|
||||
"lower_bound": 0.0,
|
||||
"resolution": 0.01,
|
||||
"setpoint": 15.0,
|
||||
"upper_bound": 99.9
|
||||
},
|
||||
"thermostats": {
|
||||
"primary": ["e2f4322d57924fa090fbbc48b3a140dc"],
|
||||
"secondary": ["1772a4ea304041adb83f357b751341ff"]
|
||||
},
|
||||
"vendor": "Plugwise",
|
||||
"zone_profiles": ["active", "off", "passive"]
|
||||
}
|
||||
}
|
||||
@@ -275,6 +275,64 @@ async def test_adam_2_climate_snapshot(
|
||||
await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("chosen_env", ["m_adam_heating_off_schedule"], indirect=True)
|
||||
@pytest.mark.parametrize("cooling_present", [False], indirect=True)
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_adam_off_regulation_mode_change(
|
||||
hass: HomeAssistant,
|
||||
mock_smile_adam_heat_cool: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test changing from regulation off mode."""
|
||||
mock_restore_cache_with_extra_data(
|
||||
hass,
|
||||
[
|
||||
(
|
||||
State("climate.living_room", "heat"),
|
||||
PlugwiseClimateExtraStoredData(
|
||||
last_active_schedule=None,
|
||||
previous_action_mode="heating",
|
||||
).as_dict(),
|
||||
),
|
||||
(
|
||||
State("climate.bathroom", "heat"),
|
||||
PlugwiseClimateExtraStoredData(
|
||||
last_active_schedule="Badkamer",
|
||||
previous_action_mode="heating",
|
||||
).as_dict(),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert (state := hass.states.get("climate.living_room"))
|
||||
assert state.state == "off"
|
||||
|
||||
# Verify a HomeAssistantError is raised setting a schedule from regulation-off-mode with last_active_schedule = None
|
||||
with pytest.raises(HomeAssistantError):
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
{ATTR_ENTITY_ID: "climate.living_room", ATTR_HVAC_MODE: HVACMode.AUTO},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
# Verify that the active schedule is turned off when transitioning from regulation-off-mode to a manual mode
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
{ATTR_ENTITY_ID: "climate.bathroom", ATTR_HVAC_MODE: HVACMode.HEAT},
|
||||
blocking=True,
|
||||
)
|
||||
mock_smile_adam_heat_cool.set_schedule_state.assert_called_with(
|
||||
"f871b8c4d63549319221e294e4f88074", STATE_OFF, "Badkamer"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("chosen_env", ["m_adam_cooling"], indirect=True)
|
||||
@pytest.mark.parametrize("cooling_present", [True], indirect=True)
|
||||
async def test_adam_3_climate_entity_attributes(
|
||||
@@ -561,6 +619,26 @@ async def test_anna_climate_entity_climate_changes(
|
||||
"standaard",
|
||||
)
|
||||
|
||||
data = mock_smile_anna.async_update.return_value
|
||||
data["3cb70739631c4d17a86b8b12e8a5161b"]["climate_mode"] = "heat"
|
||||
with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data):
|
||||
freezer.tick(timedelta(minutes=1))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
{ATTR_ENTITY_ID: "climate.anna", ATTR_HVAC_MODE: HVACMode.AUTO},
|
||||
blocking=True,
|
||||
)
|
||||
assert mock_smile_anna.set_schedule_state.call_count == 2
|
||||
mock_smile_anna.set_schedule_state.assert_called_with(
|
||||
"c784ee9fdab44e1395b8dee7d7a497d5",
|
||||
STATE_ON,
|
||||
"standaard",
|
||||
)
|
||||
|
||||
# Mock user deleting last schedule from app or browser
|
||||
data = mock_smile_anna.async_update.return_value
|
||||
data["3cb70739631c4d17a86b8b12e8a5161b"]["available_schedules"] = []
|
||||
|
||||
@@ -114,6 +114,7 @@ MOCK_GAMES_LOCKED = {MOCK_ID: MOCK_GAMES_DATA_LOCKED}
|
||||
|
||||
async def test_ps4_integration_setup(hass: HomeAssistant) -> None:
|
||||
"""Test PS4 integration is setup."""
|
||||
# pylint: disable-next=home-assistant-tests-direct-async-setup
|
||||
await ps4.async_setup(hass, {})
|
||||
await hass.async_block_till_done()
|
||||
assert hass.data[PS4_DATA].protocol is not None
|
||||
|
||||
@@ -19,6 +19,7 @@ async def test_async_new_device_discovery_no_entry(
|
||||
"""Service should raise when no config entry exists."""
|
||||
|
||||
# Ensure the integration is set up so the service is registered
|
||||
# pylint: disable-next=home-assistant-tests-direct-async-setup
|
||||
assert await async_setup(hass, {})
|
||||
|
||||
# No entries for the domain, service should raise
|
||||
@@ -34,6 +35,7 @@ async def test_async_new_device_discovery_entry_not_loaded(
|
||||
# Add a config entry but do not set it up (state is not LOADED)
|
||||
assert config_entry.state is ConfigEntryState.NOT_LOADED
|
||||
# Ensure the integration is set up so the service is registered
|
||||
# pylint: disable-next=home-assistant-tests-direct-async-setup
|
||||
assert await async_setup(hass, {})
|
||||
|
||||
with pytest.raises(ServiceValidationError, match="Entry not loaded"):
|
||||
|
||||
@@ -790,14 +790,11 @@ async def test_flow_reauth(
|
||||
"user_id": "AAAAAAAAAAAAAAAAAAAAA",
|
||||
},
|
||||
)
|
||||
with patch(
|
||||
"homeassistant.components.xbox.async_setup_entry", return_value=True
|
||||
) as mock_setup:
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
||||
assert len(mock_setup.mock_calls) == 1
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
|
||||
@@ -104,213 +104,6 @@ async def test_name(hass: HomeAssistant, entity_registry: er.EntityRegistry) ->
|
||||
assert wrapped_entity_config_entry_title(hass, entry.id) == "Custom Name"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("marker", [vol.Required, vol.Optional])
|
||||
async def test_config_flow_advanced_option(
|
||||
hass: HomeAssistant, manager: data_entry_flow.FlowManager, marker
|
||||
) -> None:
|
||||
"""Test handling of advanced options in config flow."""
|
||||
manager.hass = hass
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
marker("option1"): str,
|
||||
marker("advanced_no_default", description={"advanced": True}): str,
|
||||
marker(
|
||||
"advanced_default",
|
||||
default="a very reasonable default",
|
||||
description={"advanced": True},
|
||||
): str,
|
||||
}
|
||||
)
|
||||
|
||||
CONFIG_FLOW: dict[str, SchemaFlowFormStep | SchemaFlowMenuStep] = {
|
||||
"init": SchemaFlowFormStep(CONFIG_SCHEMA)
|
||||
}
|
||||
|
||||
@manager.mock_reg_handler("test")
|
||||
class TestFlow(MockSchemaConfigFlowHandler):
|
||||
config_flow = CONFIG_FLOW
|
||||
|
||||
# Start flow in basic mode
|
||||
result = await manager.async_init("test")
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert list(result["data_schema"].schema.keys()) == ["option1"]
|
||||
|
||||
result = await manager.async_configure(result["flow_id"], {"option1": "blabla"})
|
||||
assert result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {}
|
||||
assert result["options"] == {
|
||||
"advanced_default": "a very reasonable default",
|
||||
"option1": "blabla",
|
||||
}
|
||||
for option in result["options"]:
|
||||
# Make sure we didn't get the Optional or Required instance as key
|
||||
assert isinstance(option, str)
|
||||
|
||||
# Start flow in advanced mode
|
||||
result = await manager.async_init("test", context={"show_advanced_options": True})
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert list(result["data_schema"].schema.keys()) == [
|
||||
"option1",
|
||||
"advanced_no_default",
|
||||
"advanced_default",
|
||||
]
|
||||
|
||||
result = await manager.async_configure(
|
||||
result["flow_id"], {"advanced_no_default": "abc123", "option1": "blabla"}
|
||||
)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {}
|
||||
assert result["options"] == {
|
||||
"advanced_default": "a very reasonable default",
|
||||
"advanced_no_default": "abc123",
|
||||
"option1": "blabla",
|
||||
}
|
||||
for option in result["options"]:
|
||||
# Make sure we didn't get the Optional or Required instance as key
|
||||
assert isinstance(option, str)
|
||||
|
||||
# Start flow in advanced mode
|
||||
result = await manager.async_init("test", context={"show_advanced_options": True})
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert list(result["data_schema"].schema.keys()) == [
|
||||
"option1",
|
||||
"advanced_no_default",
|
||||
"advanced_default",
|
||||
]
|
||||
|
||||
result = await manager.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
"advanced_default": "not default",
|
||||
"advanced_no_default": "abc123",
|
||||
"option1": "blabla",
|
||||
},
|
||||
)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {}
|
||||
assert result["options"] == {
|
||||
"advanced_default": "not default",
|
||||
"advanced_no_default": "abc123",
|
||||
"option1": "blabla",
|
||||
}
|
||||
for option in result["options"]:
|
||||
# Make sure we didn't get the Optional or Required instance as key
|
||||
assert isinstance(option, str)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("marker", [vol.Required, vol.Optional])
|
||||
async def test_options_flow_advanced_option(
|
||||
hass: HomeAssistant, manager: data_entry_flow.FlowManager, marker
|
||||
) -> None:
|
||||
"""Test handling of advanced options in options flow."""
|
||||
manager.hass = hass
|
||||
|
||||
OPTIONS_SCHEMA = vol.Schema(
|
||||
{
|
||||
marker("option1"): str,
|
||||
marker("advanced_no_default", description={"advanced": True}): str,
|
||||
marker(
|
||||
"advanced_default",
|
||||
default="a very reasonable default",
|
||||
description={"advanced": True},
|
||||
): str,
|
||||
}
|
||||
)
|
||||
|
||||
OPTIONS_FLOW: dict[str, SchemaFlowFormStep | SchemaFlowMenuStep] = {
|
||||
"init": SchemaFlowFormStep(OPTIONS_SCHEMA)
|
||||
}
|
||||
|
||||
class TestFlow(MockSchemaConfigFlowHandler, domain="test"):
|
||||
config_flow = {}
|
||||
options_flow = OPTIONS_FLOW
|
||||
|
||||
mock_integration(hass, MockModule("test"))
|
||||
mock_platform(hass, "test.config_flow", None)
|
||||
config_entry = MockConfigEntry(
|
||||
data={},
|
||||
domain="test",
|
||||
options={
|
||||
"option1": "blabla",
|
||||
"advanced_no_default": "abc123",
|
||||
"advanced_default": "not default",
|
||||
},
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
# Start flow in basic mode
|
||||
result = await hass.config_entries.options.async_init(config_entry.entry_id)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert list(result["data_schema"].schema.keys()) == ["option1"]
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"], {"option1": "blublu"}
|
||||
)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {
|
||||
"advanced_default": "not default",
|
||||
"advanced_no_default": "abc123",
|
||||
"option1": "blublu",
|
||||
}
|
||||
for option in result["data"]:
|
||||
# Make sure we didn't get the Optional or Required instance as key
|
||||
assert isinstance(option, str)
|
||||
|
||||
# Start flow in advanced mode
|
||||
result = await hass.config_entries.options.async_init(
|
||||
config_entry.entry_id, context={"show_advanced_options": True}
|
||||
)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert list(result["data_schema"].schema.keys()) == [
|
||||
"option1",
|
||||
"advanced_no_default",
|
||||
"advanced_default",
|
||||
]
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"], {"advanced_no_default": "def456", "option1": "blabla"}
|
||||
)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {
|
||||
"advanced_default": "a very reasonable default",
|
||||
"advanced_no_default": "def456",
|
||||
"option1": "blabla",
|
||||
}
|
||||
for option in result["data"]:
|
||||
# Make sure we didn't get the Optional or Required instance as key
|
||||
assert isinstance(option, str)
|
||||
|
||||
# Start flow in advanced mode
|
||||
result = await hass.config_entries.options.async_init(
|
||||
config_entry.entry_id, context={"show_advanced_options": True}
|
||||
)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert list(result["data_schema"].schema.keys()) == [
|
||||
"option1",
|
||||
"advanced_no_default",
|
||||
"advanced_default",
|
||||
]
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
"advanced_default": "also not default",
|
||||
"advanced_no_default": "abc123",
|
||||
"option1": "blabla",
|
||||
},
|
||||
)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {
|
||||
"advanced_default": "also not default",
|
||||
"advanced_no_default": "abc123",
|
||||
"option1": "blabla",
|
||||
}
|
||||
for option in result["data"]:
|
||||
# Make sure we didn't get the Optional or Required instance as key
|
||||
assert isinstance(option, str)
|
||||
|
||||
|
||||
async def test_menu_step(hass: HomeAssistant) -> None:
|
||||
"""Test menu step."""
|
||||
|
||||
@@ -526,7 +319,7 @@ async def test_suggested_values(
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
# Start flow in basic mode, suggested values should be the existing options
|
||||
# Start flow, suggested values should be the existing options
|
||||
result = await hass.config_entries.options.async_init(config_entry.entry_id)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result["step_id"] == "init"
|
||||
@@ -678,7 +471,7 @@ async def test_options_flow_state(hass: HomeAssistant) -> None:
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
# Start flow in basic mode, flow state is initialised with None value
|
||||
# Start flow, flow state is initialised with None value
|
||||
result = await hass.config_entries.options.async_init(config_entry.entry_id)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result["step_id"] == "step_1"
|
||||
@@ -715,19 +508,13 @@ async def test_options_flow_state(hass: HomeAssistant) -> None:
|
||||
async def test_options_flow_omit_optional_keys(
|
||||
hass: HomeAssistant, manager: data_entry_flow.FlowManager
|
||||
) -> None:
|
||||
"""Test handling of advanced options in options flow."""
|
||||
"""Test handling of optional keys in options flow."""
|
||||
manager.hass = hass
|
||||
|
||||
OPTIONS_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional("optional_no_default"): str,
|
||||
vol.Optional("optional_default", default="a very reasonable default"): str,
|
||||
vol.Optional("advanced_no_default", description={"advanced": True}): str,
|
||||
vol.Optional(
|
||||
"advanced_default",
|
||||
default="a very reasonable default",
|
||||
description={"advanced": True},
|
||||
): str,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -747,13 +534,11 @@ async def test_options_flow_omit_optional_keys(
|
||||
options={
|
||||
"optional_no_default": "abc123",
|
||||
"optional_default": "not default",
|
||||
"advanced_no_default": "abc123",
|
||||
"advanced_default": "not default",
|
||||
},
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
# Start flow in basic mode
|
||||
# Start flow
|
||||
result = await hass.config_entries.options.async_init(config_entry.entry_id)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert list(result["data_schema"].schema.keys()) == [
|
||||
@@ -764,27 +549,6 @@ async def test_options_flow_omit_optional_keys(
|
||||
result = await hass.config_entries.options.async_configure(result["flow_id"], {})
|
||||
assert result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {
|
||||
"advanced_default": "not default",
|
||||
"advanced_no_default": "abc123",
|
||||
"optional_default": "a very reasonable default",
|
||||
}
|
||||
|
||||
# Start flow in advanced mode
|
||||
result = await hass.config_entries.options.async_init(
|
||||
config_entry.entry_id, context={"show_advanced_options": True}
|
||||
)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert list(result["data_schema"].schema.keys()) == [
|
||||
"optional_no_default",
|
||||
"optional_default",
|
||||
"advanced_no_default",
|
||||
"advanced_default",
|
||||
]
|
||||
|
||||
result = await hass.config_entries.options.async_configure(result["flow_id"], {})
|
||||
assert result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {
|
||||
"advanced_default": "a very reasonable default",
|
||||
"optional_default": "a very reasonable default",
|
||||
}
|
||||
|
||||
@@ -839,15 +603,13 @@ async def test_options_flow_with_automatic_reload(
|
||||
options={
|
||||
"optional_no_default": "abc123",
|
||||
"optional_default": "not default",
|
||||
"advanced_no_default": "abc123",
|
||||
"advanced_default": "not default",
|
||||
},
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
assert len(load_entry_mock.mock_calls) == 1
|
||||
|
||||
# Start flow in basic mode
|
||||
# Start flow
|
||||
result = await hass.config_entries.options.async_init(config_entry.entry_id)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Tests for the direct async_setup checker."""
|
||||
|
||||
import astroid
|
||||
from pylint.testutils import UnittestLinter
|
||||
from pylint.utils.ast_walker import ASTWalker
|
||||
from pylint_home_assistant.checkers.tests.direct_async_setup import DirectAsyncSetup
|
||||
import pytest
|
||||
|
||||
from tests.pylint import assert_no_messages
|
||||
|
||||
# Pre-load so astroid can resolve ``async_setup`` in parsed snippets.
|
||||
astroid.MANAGER.ast_from_module_name("homeassistant.components.ps4")
|
||||
|
||||
|
||||
@pytest.fixture(name="checker")
|
||||
def checker_fixture(linter: UnittestLinter) -> DirectAsyncSetup:
|
||||
"""Fixture to provide a direct async_setup checker."""
|
||||
return DirectAsyncSetup(linter)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("code", "module_name"),
|
||||
[
|
||||
pytest.param(
|
||||
"""
|
||||
async def test_setup(hass):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
""",
|
||||
"tests.components.ps4.test_init",
|
||||
id="proper_config_entry_setup_call",
|
||||
),
|
||||
pytest.param(
|
||||
"""
|
||||
from homeassistant.components.ps4 import async_setup
|
||||
|
||||
async def test_setup(hass):
|
||||
await async_setup(hass, {})
|
||||
""",
|
||||
"homeassistant.components.ps4",
|
||||
id="not_a_test_module",
|
||||
),
|
||||
pytest.param(
|
||||
"""
|
||||
async def test_setup(hass):
|
||||
await some_local.async_setup(hass, {})
|
||||
""",
|
||||
"tests.components.ps4.test_init",
|
||||
id="unresolved_attribute_call",
|
||||
),
|
||||
pytest.param(
|
||||
"""
|
||||
async def async_setup(hass, config):
|
||||
return True
|
||||
|
||||
async def test_setup(hass):
|
||||
await async_setup(hass, {})
|
||||
""",
|
||||
"tests.components.ps4.test_init",
|
||||
id="local_async_setup_not_an_integration",
|
||||
),
|
||||
pytest.param(
|
||||
"""
|
||||
from homeassistant.components.ps4 import sensor
|
||||
|
||||
async def test_setup(hass):
|
||||
await sensor.async_setup(hass, {})
|
||||
""",
|
||||
"tests.components.ps4.test_sensor",
|
||||
id="platform_async_setup_not_flagged",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_no_warning(
|
||||
linter: UnittestLinter,
|
||||
checker: DirectAsyncSetup,
|
||||
code: str,
|
||||
module_name: str,
|
||||
) -> None:
|
||||
"""Test cases that should not trigger a warning."""
|
||||
root_node = astroid.parse(code, module_name)
|
||||
walker = ASTWalker(linter)
|
||||
walker.add_checker(checker)
|
||||
|
||||
with assert_no_messages(linter):
|
||||
walker.walk(root_node)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("code", "module_name"),
|
||||
[
|
||||
pytest.param(
|
||||
"""
|
||||
from homeassistant.components.ps4 import async_setup
|
||||
|
||||
async def test_setup(hass):
|
||||
await async_setup(hass, {})
|
||||
""",
|
||||
"tests.components.ps4.test_init",
|
||||
id="direct_name_call",
|
||||
),
|
||||
pytest.param(
|
||||
"""
|
||||
from homeassistant.components import ps4
|
||||
|
||||
async def test_setup(hass):
|
||||
await ps4.async_setup(hass, {})
|
||||
""",
|
||||
"tests.components.ps4.test_init",
|
||||
id="attribute_call",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_warning(
|
||||
linter: UnittestLinter,
|
||||
checker: DirectAsyncSetup,
|
||||
code: str,
|
||||
module_name: str,
|
||||
) -> None:
|
||||
"""Test cases that should trigger a warning."""
|
||||
root_node = astroid.parse(code, module_name)
|
||||
walker = ASTWalker(linter)
|
||||
walker.add_checker(checker)
|
||||
walker.walk(root_node)
|
||||
|
||||
messages = linter.release_messages()
|
||||
assert len(messages) == 1
|
||||
assert messages[0].msg_id == "home-assistant-tests-direct-async-setup"
|
||||
|
||||
|
||||
def test_multiple_calls_each_flagged(
|
||||
linter: UnittestLinter,
|
||||
checker: DirectAsyncSetup,
|
||||
) -> None:
|
||||
"""Test that multiple direct calls are each flagged."""
|
||||
root_node = astroid.parse(
|
||||
"""
|
||||
from homeassistant.components.ps4 import async_setup
|
||||
|
||||
async def test_a(hass):
|
||||
await async_setup(hass, {})
|
||||
|
||||
async def test_b(hass):
|
||||
await async_setup(hass, {})
|
||||
""",
|
||||
"tests.components.ps4.test_init",
|
||||
)
|
||||
walker = ASTWalker(linter)
|
||||
walker.add_checker(checker)
|
||||
walker.walk(root_node)
|
||||
|
||||
messages = linter.release_messages()
|
||||
assert len(messages) == 2
|
||||
@@ -1278,13 +1278,17 @@ def test_nested_section_in_serializer() -> None:
|
||||
@pytest.mark.parametrize(
|
||||
("context", "expected_show_advanced"),
|
||||
[
|
||||
({}, False),
|
||||
({"show_advanced_options": False}, False),
|
||||
# The property is deprecated and now unconditionally returns True
|
||||
({}, True),
|
||||
({"show_advanced_options": False}, True),
|
||||
({"show_advanced_options": True}, True),
|
||||
],
|
||||
)
|
||||
async def test_show_advanced_options(
|
||||
manager: MockFlowManager, context: dict[str, Any], expected_show_advanced: bool
|
||||
manager: MockFlowManager,
|
||||
context: dict[str, Any],
|
||||
expected_show_advanced: bool,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test FlowHandler show_advanced_options property."""
|
||||
|
||||
@@ -1303,3 +1307,9 @@ async def test_show_advanced_options(
|
||||
entry = manager.mock_created_entries[0]
|
||||
assert entry["handler"] == "test"
|
||||
assert entry["title"] == "hello"
|
||||
|
||||
assert (
|
||||
"The deprecated function show_advanced_options was called. It will be "
|
||||
"removed in HA Core 2027.6. Use a user friendly way to present additional "
|
||||
"options in the UI, for example a section instead"
|
||||
) in caplog.text
|
||||
|
||||
Reference in New Issue
Block a user