Compare commits

...

54 Commits

Author SHA1 Message Date
Erik Montnemery 78db1e3407 Deprecate the FlowHandler show_advanced_options property (#171754)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-05-26 10:25:41 +02:00
Erik Montnemery 2368a3614d Remove support for advanced mode from schema config flow (#172117)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-05-26 09:50:55 +02:00
Erik Montnemery 5053392cf2 Add in_zones property to mobile_app device tracker (#171814) 2026-05-26 08:51:47 +02:00
Max Michels 6ec11460ed Replace duplicate constants in bluetooth with homeassistant.const imports (#172079) 2026-05-26 08:38:23 +02:00
Paul Tarjan 975e30c048 Remove unreachable Hikvision Shelter Alarm binary sensor (#172152) 2026-05-26 08:30:23 +02:00
J. Nick Koston 7655cb0fc6 Fix blocking time_zone validation in config/core/update websocket command (#172227) 2026-05-26 08:28:34 +02:00
Åke Strandberg 7566839e9d Add missing Miele program phase codes (#172144) 2026-05-26 08:28:31 +02:00
Manu 7db5e82f58 Use non-reloading entry update methods in config flow of ntfy integration (#172222) 2026-05-26 08:25:37 +02:00
Manu 7e67c53417 Use non-reloading entry update method in config flow of PlayStation Netwwork integration (#172223) 2026-05-26 08:25:20 +02:00
Manu 89fb856302 Use non-reloading entry update method in config flow of Xbox integration (#172224) 2026-05-26 08:25:07 +02:00
Manu a2fbd2b1ea Migrate EDL21 to use SerialPortSelector (#172220) 2026-05-26 08:23:34 +02:00
Manu 231ed34133 Bump pysml to 0.1.7 (#172217) 2026-05-26 08:23:05 +02:00
Manu 6cff433b2e Remove artificial throttling of push updates in EDL21 integration (#172213) 2026-05-26 08:22:57 +02:00
Joakim Plate eca83fb7b1 Switch to async_setup in coordinator for gardena setup (#172198)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-26 08:22:35 +02:00
A. Gideonse 2c5adaec5c Bump indevolt-api to 1.8.2 (#172201) 2026-05-26 08:21:56 +02:00
Manu 5d75f1c33b Use non-reloading entry update method in config flow of Habitica integration (#172225) 2026-05-26 08:20:48 +02:00
Manu d628d2314e Fix wrong integration type classification of EDL21 (#172230) 2026-05-26 08:19:46 +02:00
J. Nick Koston a9547ec349 Bump inkbird-ble to 1.4.3 (#172211) 2026-05-25 22:16:07 -05:00
J. Nick Koston 2ec637df84 Bump dbus-fast to 5.0.14 (#172215) 2026-05-25 22:15:54 -05:00
J. Nick Koston 4f50ee5675 Fix ONVIF camera_address using uninitialized inner device attribute (#172219) 2026-05-25 22:15:41 -05:00
J. Nick Koston 0faf96b983 Bump onvif-zeep-async to 4.1.0 (#172212) 2026-05-25 22:15:27 -05:00
Allen Porter c3dacbc601 Bump ical to 13.2.5 (#172214) 2026-05-25 19:23:46 -07:00
renovate[bot] 2659484000 Update uv to 0.11.15 (#172208) 2026-05-25 20:18:52 -05:00
J. Nick Koston 6830ca75f5 Bump aiodiscover to 3.2.4 (#172203) 2026-05-25 20:17:54 -05:00
J. Nick Koston 38b4184dc3 Bump inkbird-ble to 1.4.0 (#172199) 2026-05-25 19:04:41 -05:00
A. Gideonse cfde7975d8 Add main MOS temp to Indevolt (#171476) 2026-05-26 00:42:59 +02:00
J. Nick Koston d7ab696a4c Bump ESPHome stable BLE version to 2026.5.1 (#172196) 2026-05-25 17:00:30 -05:00
J. Nick Koston 7f7dad7f71 Bump bluetooth-adapters to 2.3.0 (#172165) 2026-05-26 00:52:38 +03:00
J. Nick Koston 0ed21dbed7 Bump icmplib to 3.0.4 (#172189) 2026-05-25 23:15:08 +02:00
J. Nick Koston d2b37ee28b Bump aiohttp-asyncmdnsresolver to 0.2.0 (#172188) 2026-05-25 23:03:12 +02:00
J. Nick Koston b82c95e77f Bump ulid-transform to 2.2.9 (#172190) 2026-05-25 15:56:22 -05:00
J. Nick Koston baa61982a1 Bump dbus-fast to 5.0.11 (#172191) 2026-05-25 15:56:09 -05:00
Robert Svensson 8ff6de788d Local helper for Axis serial number (#172172) 2026-05-25 22:24:47 +02:00
J. Nick Koston 640f82642a Bump habluetooth to 6.7.4 (#172162) 2026-05-25 22:15:11 +02:00
Robert Svensson 64ed269f9c Bump to aiounifi v91 (#172175) 2026-05-25 22:04:46 +02:00
Bouwe Westerdijk 2b58ef96eb Refactor set HVAC mode for Plugwise (#172121) 2026-05-25 21:56:44 +02:00
J. Nick Koston 74ca79ac28 Extend INKBIRD active scan duration to cover slower broadcasters (#172171) 2026-05-25 14:55:27 -05:00
Pete Sage afb27bc165 bump soco to 0.31.1 for Sonos (#172168) 2026-05-25 22:53:32 +03:00
J. Nick Koston 0cbf27f44f Restore sensorpro sensor entity data across reloads (#172182) 2026-05-25 14:52:21 -05:00
J. Nick Koston a5ceafa544 Restore tilt_ble sensor entity data across reloads (#172184) 2026-05-25 22:51:56 +03:00
J. Nick Koston cd4d669231 Restore thermobeacon sensor entity data across reloads (#172183) 2026-05-25 22:51:03 +03:00
J. Nick Koston cc411d06b5 Restore victron_ble sensor entity data across reloads (#172185) 2026-05-25 14:50:18 -05:00
J. Nick Koston 1329f12d37 Restore aranet sensor entity data across reloads (#172173) 2026-05-25 22:50:07 +03:00
J. Nick Koston 3899f5347b Restore sensirion_ble sensor entity data across reloads (#172181) 2026-05-25 14:49:59 -05:00
J. Nick Koston cf02cfaa7c Restore rapt_ble sensor entity data across reloads (#172179) 2026-05-25 14:49:43 -05:00
J. Nick Koston e77c16ea1b Restore bluemaestro sensor entity data across reloads (#172174) 2026-05-25 14:49:26 -05:00
J. Nick Koston f1e2f94ee0 Restore moat sensor entity data across reloads (#172177) 2026-05-25 14:49:07 -05:00
J. Nick Koston 3516883b0a Restore ruuvitag_ble sensor entity data across reloads (#172180) 2026-05-25 14:48:15 -05:00
J. Nick Koston c8b70b1a38 Restore kegtron sensor entity data across reloads (#172176) 2026-05-25 14:47:55 -05:00
J. Nick Koston 946625e281 Restore mopeka sensor entity data across reloads (#172178) 2026-05-25 14:47:09 -05:00
J. Nick Koston f4b7840d5c Bump aiodhcpwatcher to 1.2.7 (#172161) 2026-05-25 22:43:14 +03:00
Michael 060f447e4a Fix swallowed exceptions in homeassistant action handlers (#170922)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-25 21:02:49 +02:00
Joost Lekkerkerker d5bae0a2cf Add pylint rule for checking async_setup calls in tests (#171890) 2026-05-25 20:56:44 +02:00
Abílio Costa f9bef804b1 Add infrared receiver support to ESPHome (#171789)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-25 19:42:34 +01:00
91 changed files with 1655 additions and 588 deletions
+5 -1
View File
@@ -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(
+19 -2
View File
@@ -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"
]
}
+3 -1
View File
@@ -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,
}
+2 -2
View File
@@ -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(),
}
)
+2 -2
View File
@@ -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"]
}
-11
View File
@@ -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(
+4 -1
View File
@@ -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"
}
+1 -1
View File
@@ -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/",
+7 -2
View File
@@ -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] = {}
+88 -11
View File
@@ -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"]
}
+3 -1
View File
@@ -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(
+3 -1
View File
@@ -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"]
}
+2
View File
@@ -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",
+3 -1
View File
@@ -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
+3 -1
View File
@@ -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(
+3 -3
View File
@@ -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,
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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"
]
+1 -1
View File
@@ -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},
)
+74 -78
View File
@@ -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)
+3 -1
View File
@@ -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(
+3 -1
View File
@@ -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(
+1 -1
View File
@@ -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(
+3 -1
View File
@@ -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(
+1 -1
View File
@@ -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):
+1 -3
View File
@@ -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()
+11 -11
View File
@@ -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
+1 -1
View File
@@ -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)
+8 -8
View File
@@ -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
+20
View File
@@ -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
View File
@@ -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",
+3 -3
View File
@@ -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
+13 -13
View File
@@ -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
+15 -3
View File
@@ -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
+58 -1
View File
@@ -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,
+5 -2
View File
@@ -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
+21
View File
@@ -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)
+114 -22
View File
@@ -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,
+26 -4
View File
@@ -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
+34 -2
View File
@@ -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"]
}
}
+78
View File
@@ -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"] = []
+1
View File
@@ -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
+2
View File
@@ -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"):
+3 -6
View File
@@ -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"
+5 -243
View File
@@ -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
+13 -3
View File
@@ -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