Compare commits

...

38 Commits

Author SHA1 Message Date
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
Erik Montnemery 6de03f4ed6 Add state attribute in_zones to BaseScannerEntity (#171832) 2026-05-25 18:28:31 +02:00
J. Nick Koston e7f3e5637f Bump aioshelly to 13.26.1 (#172160) 2026-05-25 10:31:21 -05:00
Paul Bottein 80cefc74ec Update rf-protocols to 4.0.0 (#172131) 2026-05-25 17:17:53 +02:00
J. Nick Koston 2f33b4b7f9 Bump aioharmony to 1.0.8 (#172116) 2026-05-25 17:16:47 +02:00
J. Nick Koston cf52a7a509 Bump bluetooth-adapters to 2.2.0 (#172120) 2026-05-25 10:10:54 -05:00
Mattias Arrelid f5835f849a Update anyio to 4.13.0 (#172138) 2026-05-25 09:36:53 -05:00
J. Nick Koston ec5210dca8 Bump led-ble to 1.1.11 (#172154) 2026-05-25 09:35:11 -05:00
Michael 422ea1a9b1 Bump wakeonlan to 3.3.0 (#172150) 2026-05-25 16:13:38 +02:00
76 changed files with 1721 additions and 275 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(
@@ -17,10 +17,10 @@
"requirements": [
"bleak==3.0.2",
"bleak-retry-connector==4.6.1",
"bluetooth-adapters==2.1.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.11",
"habluetooth==6.7.4"
]
}
@@ -357,6 +357,23 @@ class BaseScannerEntity(BaseTrackerEntity):
"""Return true if the device is connected."""
raise NotImplementedError
@final
@property
def state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes."""
attr: dict[str, Any] = {ATTR_IN_ZONES: []}
attr.update(super().state_attributes)
if not self.is_connected:
return attr
attr[ATTR_IN_ZONES] = [
zone.ENTITY_ID_HOME,
*zone.async_get_enclosing_zones(self.hass, zone.ENTITY_ID_HOME),
]
return attr
class ScannerEntityDescription(EntityDescription, frozen_or_thawed=True):
"""A class that describes tracker entities."""
@@ -484,9 +501,12 @@ class ScannerEntity(
# Do this last or else the entity registry update listener has been installed
await super().async_internal_added_to_hass()
@final
# BaseScannerEntity.state_attributes is @final to keep external subclasses
# from tampering with it; ScannerEntity is an in-tree subclass that
# intentionally extends it with ip/mac/hostname.
@final # type: ignore[misc]
@property
def state_attributes(self) -> dict[str, StateType]:
def state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes."""
attr = super().state_attributes
+1 -1
View File
@@ -15,7 +15,7 @@
],
"quality_scale": "internal",
"requirements": [
"aiodhcpwatcher==1.2.6",
"aiodhcpwatcher==1.2.7",
"aiodiscover==3.2.3",
"cached-ipaddress==1.1.1"
]
+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)
),
)
@@ -8,7 +8,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["aioharmony", "slixmpp"],
"requirements": ["aioharmony==1.0.3"],
"requirements": ["aioharmony==1.0.8"],
"ssdp": [
{
"deviceType": "urn:myharmony-com:device:harmony:1",
@@ -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,
@@ -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.0"]
}
+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(
@@ -36,5 +36,5 @@
"documentation": "https://www.home-assistant.io/integrations/led_ble",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["bluetooth-data-tools==1.29.18", "led-ble==1.1.8"]
"requirements": ["bluetooth-data-tools==1.29.18", "led-ble==1.1.11"]
}
@@ -8,6 +8,6 @@
"integration_type": "service",
"iot_class": "local_push",
"quality_scale": "silver",
"requirements": ["mcp==1.26.0", "aiohttp_sse==2.2.0", "anyio==4.10.0"],
"requirements": ["mcp==1.26.0", "aiohttp_sse==2.2.0", "anyio==4.13.0"],
"single_config_entry": true
}
+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(
+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(
@@ -1,7 +0,0 @@
"""Command names for the Novy Cooker Hood RF codes."""
from typing import Final
COMMAND_LIGHT: Final = "light"
COMMAND_PLUS: Final = "plus"
COMMAND_MINUS: Final = "minus"
@@ -3,7 +3,7 @@
import asyncio
from typing import Any
from rf_protocols.codes.novy.cooker_hood import get_codes_for_code
from rf_protocols.codes.novy.cooker_hood import NovyCookerHoodButton
import voluptuous as vol
from homeassistant.components.radio_frequency import (
@@ -19,7 +19,6 @@ from homeassistant.const import CONF_CODE
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er, selector
from .commands import COMMAND_LIGHT
from .const import (
CODE_MAX,
CODE_MIN,
@@ -128,10 +127,8 @@ class NovyCookerHoodConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Toggle the hood light on then off so it ends in its starting state."""
assert self._transmitter_entity_id is not None
command = NovyCookerHoodButton.LIGHT.to_command(channel=self._code)
try:
command = await get_codes_for_code(self._code).async_load_command(
COMMAND_LIGHT
)
await async_send_command(self.hass, self._transmitter_entity_id, command)
await asyncio.sleep(_TOGGLE_GAP)
await async_send_command(self.hass, self._transmitter_entity_id, command)
@@ -3,7 +3,8 @@
import math
from typing import Any
from rf_protocols.codes.novy.cooker_hood import get_codes_for_code
from rf_protocols.codes.novy.cooker_hood import NovyCookerHoodButton
from rf_protocols.commands.novy import NovyCookerHoodCommand
from homeassistant.components.fan import ATTR_PERCENTAGE, FanEntity, FanEntityFeature
from homeassistant.components.radio_frequency import async_send_command
@@ -17,7 +18,6 @@ from homeassistant.util.percentage import (
ranged_value_to_percentage,
)
from .commands import COMMAND_MINUS, COMMAND_PLUS
from .const import SPEED_COUNT
from .entity import NovyCookerHoodEntity
@@ -49,7 +49,7 @@ class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity):
def __init__(self, entry: ConfigEntry) -> None:
"""Initialize the fan."""
super().__init__(entry)
self._codes = get_codes_for_code(entry.data[CONF_CODE])
self._code: int = entry.data[CONF_CODE]
self._level = 0
self._attr_unique_id = entry.entry_id
@@ -103,7 +103,7 @@ class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity):
async def async_increase_speed(self, percentage_step: int | None = None) -> None:
"""Bump speed up by N hardware levels (no recalibration)."""
steps = self._steps_from_percentage(percentage_step)
plus = await self._codes.async_load_command(COMMAND_PLUS)
plus = NovyCookerHoodButton.PLUS.to_command(channel=self._code)
for _ in range(steps):
await self._async_send(plus)
self._level = min(SPEED_COUNT, self._level + steps)
@@ -112,7 +112,7 @@ class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity):
async def async_decrease_speed(self, percentage_step: int | None = None) -> None:
"""Bump speed down by N hardware levels (no recalibration)."""
steps = self._steps_from_percentage(percentage_step)
minus = await self._codes.async_load_command(COMMAND_MINUS)
minus = NovyCookerHoodButton.MINUS.to_command(channel=self._code)
for _ in range(steps):
await self._async_send(minus)
self._level = max(0, self._level - steps)
@@ -127,17 +127,17 @@ class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity):
async def _async_set_level(self, level: int) -> None:
"""Reset to off with `SPEED_COUNT` minus presses, then climb to level."""
minus = await self._codes.async_load_command(COMMAND_MINUS)
minus = NovyCookerHoodButton.MINUS.to_command(channel=self._code)
for _ in range(SPEED_COUNT):
await self._async_send(minus)
if level > 0:
plus = await self._codes.async_load_command(COMMAND_PLUS)
plus = NovyCookerHoodButton.PLUS.to_command(channel=self._code)
for _ in range(level):
await self._async_send(plus)
self._level = level
self.async_write_ha_state()
async def _async_send(self, command: Any) -> None:
async def _async_send(self, command: NovyCookerHoodCommand) -> None:
"""Send a single RF command via the configured transmitter."""
await async_send_command(
self.hass, self._transmitter, command, context=self._context
@@ -2,7 +2,7 @@
from typing import Any
from rf_protocols.codes.novy.cooker_hood import get_codes_for_code
from rf_protocols.codes.novy.cooker_hood import NovyCookerHoodButton
from homeassistant.components.light import ColorMode, LightEntity
from homeassistant.components.radio_frequency import async_send_command
@@ -12,7 +12,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .commands import COMMAND_LIGHT
from .entity import NovyCookerHoodEntity
PARALLEL_UPDATES = 1
@@ -37,7 +36,7 @@ class NovyCookerHoodLight(NovyCookerHoodEntity, LightEntity, RestoreEntity):
def __init__(self, entry: ConfigEntry) -> None:
"""Initialize the light."""
super().__init__(entry)
self._codes = get_codes_for_code(entry.data[CONF_CODE])
self._code = entry.data[CONF_CODE]
self._attr_unique_id = entry.entry_id
async def async_added_to_hass(self) -> None:
@@ -48,19 +47,19 @@ class NovyCookerHoodLight(NovyCookerHoodEntity, LightEntity, RestoreEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on by sending the toggle command."""
await self._async_send_command(COMMAND_LIGHT)
await self._async_send_light()
self._attr_is_on = True
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off by sending the toggle command."""
await self._async_send_command(COMMAND_LIGHT)
await self._async_send_light()
self._attr_is_on = False
self.async_write_ha_state()
async def _async_send_command(self, name: str) -> None:
"""Load the named command and send it via the configured transmitter."""
command = await self._codes.async_load_command(name)
async def _async_send_light(self) -> None:
"""Send the light toggle command via the configured transmitter."""
command = NovyCookerHoodButton.LIGHT.to_command(channel=self._code)
await async_send_command(
self.hass, self._transmitter, command, context=self._context
)
+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"]
}
+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)
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/radio_frequency",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["rf-protocols==3.2.0"]
"requirements": ["rf-protocols==4.0.0"]
}
+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(
@@ -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(
@@ -39,7 +39,7 @@
"getmac==0.9.5",
"samsungctl[websocket]==0.7.1",
"samsungtvws[async,encrypted]==2.7.2",
"wakeonlan==3.1.0",
"wakeonlan==3.3.0",
"async-upnp-client==0.46.2"
],
"ssdp": [
@@ -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(
@@ -17,7 +17,7 @@
"iot_class": "local_push",
"loggers": ["aioshelly"],
"quality_scale": "platinum",
"requirements": ["aioshelly==13.26.0"],
"requirements": ["aioshelly==13.26.1"],
"zeroconf": [
{
"name": "shelly*",
+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):
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/wake_on_lan",
"integration_type": "service",
"iot_class": "local_push",
"requirements": ["wakeonlan==3.1.0"]
"requirements": ["wakeonlan==3.3.0"]
}
+57
View File
@@ -180,6 +180,63 @@ def async_in_zones(
return (closest, [itm[0] for itm in zones])
def async_get_enclosing_zones(hass: HomeAssistant, zone_entity_id: str) -> list[str]:
"""Find zones which fully contain the given zone.
Returns a list of zone entity_ids whose interior contains the given zone
(``zone_dist + input_radius <= other_zone_radius``); a zone whose edge
touches another zone's edge from the inside counts as contained. Passive
zones are included. The queried zone itself is excluded from the result.
The list is sorted by radius then distance, so the smallest enclosing zone
is first.
Returns an empty list if the zone does not exist or is unavailable.
This method must be run in the event loop.
"""
if (
not (input_zone := hass.states.get(zone_entity_id))
or input_zone.state == STATE_UNAVAILABLE
):
return []
input_attrs = input_zone.attributes
input_latitude: float = input_attrs[ATTR_LATITUDE]
input_longitude: float = input_attrs[ATTR_LONGITUDE]
input_radius: float = input_attrs[ATTR_RADIUS]
zones: list[tuple[str, float, float]] = []
# This can be called before async_setup by device tracker
zone_entity_ids = hass.data.get(DATA_ZONE_ENTITY_IDS, ())
for entity_id in zone_entity_ids:
if entity_id == zone_entity_id:
continue
if (
not (zone := hass.states.get(entity_id))
# Skip unavailable zones
or zone.state == STATE_UNAVAILABLE
):
continue
zone_attrs = zone.attributes
if (
zone_dist := distance(
input_latitude,
input_longitude,
zone_attrs[ATTR_LATITUDE],
zone_attrs[ATTR_LONGITUDE],
)
) is None:
continue
zone_radius = zone_attrs[ATTR_RADIUS]
if not zone_dist + input_radius <= zone_radius:
continue
zones.append((zone.entity_id, zone_dist, zone_radius))
zones.sort(key=lambda x: (x[2], x[1]))
return [itm[0] for itm in zones]
def async_active_zone(
hass: HomeAssistant, latitude: float, longitude: float, radius: float = 0
) -> State | None:
+7 -7
View File
@@ -1,10 +1,10 @@
# Automatically generated by gen_requirements_all.py, do not edit
aiodhcpwatcher==1.2.6
aiodhcpwatcher==1.2.7
aiodiscover==3.2.3
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.1.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.11
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,7 +68,7 @@ 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
voluptuous-openapi==0.3.0
@@ -113,7 +113,7 @@ uuid==1000000000.0.0
# even newer versions seem to introduce new issues, it's useful
# for us to pin all these
# requirements so we can directly link HA versions to these library versions.
anyio==4.10.0
anyio==4.13.0
h11==0.16.0
httpcore==1.0.9
+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))
+2 -2
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,7 +72,7 @@ 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",
"voluptuous==0.15.2",
+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
@@ -47,13 +47,13 @@ python-slugify==8.0.4
PyTurboJPEG==1.8.3
PyYAML==6.0.3
requests==2.34.2
rf-protocols==3.2.0
rf-protocols==4.0.0
securetar==2026.4.1
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
voluptuous-openapi==0.3.0
+14 -14
View File
@@ -233,7 +233,7 @@ 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
@@ -279,7 +279,7 @@ aiogithubapi==26.0.0
aioguardian==2026.01.1
# homeassistant.components.harmony
aioharmony==1.0.3
aioharmony==1.0.8
# homeassistant.components.hassio
aiohasupervisor==0.4.3
@@ -405,7 +405,7 @@ aiorussound==5.0.1
aioruuvigateway==0.1.0
# homeassistant.components.shelly
aioshelly==13.26.0
aioshelly==13.26.1
# homeassistant.components.skybell
aioskybell==22.7.0
@@ -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
@@ -522,7 +522,7 @@ anthemav==1.4.1
anthropic==0.96.0
# homeassistant.components.mcp_server
anyio==4.10.0
anyio==4.13.0
# homeassistant.components.weatherkit
apple_weatherkit==1.1.3
@@ -675,7 +675,7 @@ bluecurrent-api==1.3.2
bluemaestro-ble==0.4.1
# homeassistant.components.bluetooth
bluetooth-adapters==2.1.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.11
# 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
@@ -1320,7 +1320,7 @@ ical==13.2.4
icalendar==6.3.1
# homeassistant.components.ping
icmplib==3.0
icmplib==3.0.4
# homeassistant.components.idasen_desk
idasen-ha==2.6.5
@@ -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.0
# homeassistant.components.insteon
insteon-frontend-home-assistant==0.6.2
@@ -1447,7 +1447,7 @@ ld2410-ble==0.1.1
leaone-ble==0.3.0
# homeassistant.components.led_ble
led-ble==1.1.8
led-ble==1.1.11
# homeassistant.components.lektrico
lektricowifi==0.1
@@ -2874,7 +2874,7 @@ renson-endura-delta==1.7.2
reolink-aio==0.20.0
# homeassistant.components.radio_frequency
rf-protocols==3.2.0
rf-protocols==4.0.0
# homeassistant.components.idteck_prox
rfk101py==0.0.1
@@ -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
@@ -3311,7 +3311,7 @@ vtjp==0.2.1
# homeassistant.components.samsungtv
# homeassistant.components.wake_on_lan
wakeonlan==3.1.0
wakeonlan==3.3.0
# homeassistant.components.wallbox
wallbox==0.9.0
+1 -1
View File
@@ -97,7 +97,7 @@ uuid==1000000000.0.0
# even newer versions seem to introduce new issues, it's useful
# for us to pin all these
# requirements so we can directly link HA versions to these library versions.
anyio==4.10.0
anyio==4.13.0
h11==0.16.0
httpcore==1.0.9
+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,
@@ -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,
@@ -770,6 +770,7 @@ async def test_base_scanner_entity_state(
assert entity_state
assert entity_state.attributes == {
ATTR_SOURCE_TYPE: SourceType.BLUETOOTH_LE,
ATTR_IN_ZONES: [],
}
assert entity_state.state == STATE_NOT_HOME
@@ -779,6 +780,12 @@ async def test_base_scanner_entity_state(
entity_state = hass.states.get(entity_id)
assert entity_state
assert entity_state.state == STATE_HOME
# No zone.home in the test state machine, so only the canonical home
# entity_id is reported.
assert entity_state.attributes == {
ATTR_SOURCE_TYPE: SourceType.BLUETOOTH_LE,
ATTR_IN_ZONES: ["zone.home"],
}
base_scanner_entity.set_connected(None)
await hass.async_block_till_done()
@@ -786,6 +793,104 @@ async def test_base_scanner_entity_state(
entity_state = hass.states.get(entity_id)
assert entity_state
assert entity_state.state == STATE_UNKNOWN
# is_connected is None -> empty in_zones (always reported).
assert entity_state.attributes == {
ATTR_SOURCE_TYPE: SourceType.BLUETOOTH_LE,
ATTR_IN_ZONES: [],
}
@pytest.mark.parametrize(
("zones", "expected_in_zones"),
[
pytest.param(
[("zone.home", 50.0, 60.0, 100)],
["zone.home"],
id="home_only",
),
pytest.param(
[
("zone.home", 50.0, 60.0, 100),
("zone.neighborhood", 50.0, 60.0, 500),
],
["zone.home", "zone.neighborhood"],
id="strictly_containing_zone",
),
pytest.param(
[
("zone.home", 50.0, 60.0, 100),
("zone.huge", 50.0, 60.0, 10000),
("zone.medium", 50.0, 60.0, 500),
],
["zone.home", "zone.medium", "zone.huge"],
id="multiple_containing_zones_sorted_by_radius",
),
pytest.param(
[
("zone.home", 50.0, 60.0, 100),
("zone.tiny", 50.0, 60.0, 50),
],
["zone.home"],
id="zone_smaller_than_home_excluded",
),
pytest.param(
[
("zone.home", 50.0, 60.0, 100),
("zone.equal", 50.0, 60.0, 100),
],
# Same center and radius as home: included under the <= predicate.
# zone.home stays first because the strict-result zone.home entry
# is filtered out, and zone.equal is the next entry.
["zone.home", "zone.equal"],
id="zone_equal_to_home_included",
),
pytest.param(
[
("zone.home", 50.0, 60.0, 100),
# Small offset, the home zone is fully inside
# the other zone (~330m + 100 < 500).
("zone.nearby", 50.0030, 60.0, 500),
# Offset by enough that the home zone is not fully inside
# the other zone (~440m + 100 > 500).
("zone.further_away", 50.0040, 60.0, 500),
# Offset by a very large amount, no overlap
# the other zone (~130km + 100 > 500).
("zone.faraway", 51.0, 61.0, 500),
],
["zone.home", "zone.nearby"],
id="offset_zone_excluded",
),
],
)
async def test_base_scanner_entity_in_zones_when_connected(
hass: HomeAssistant,
config_entry: MockConfigEntry,
entity_id: str,
base_scanner_entity: MockBaseScannerEntity,
zones: list[tuple[str, float, float, int]],
expected_in_zones: list[str],
) -> None:
"""Test in_zones content for a connected BaseScannerEntity across zone setups."""
base_scanner_entity._connected = True
for entity, latitude, longitude, radius in zones:
hass.states.async_set(
entity,
"0",
{ATTR_LATITUDE: latitude, ATTR_LONGITUDE: longitude, ATTR_RADIUS: radius},
)
await hass.async_block_till_done()
config_entry = await create_mock_platform(hass, config_entry, [base_scanner_entity])
assert config_entry.state is ConfigEntryState.LOADED
entity_state = hass.states.get(entity_id)
assert entity_state
assert entity_state.state == STATE_HOME
assert entity_state.attributes == {
ATTR_SOURCE_TYPE: SourceType.BLUETOOTH_LE,
ATTR_IN_ZONES: expected_in_zones,
}
@pytest.mark.parametrize(
@@ -819,6 +924,7 @@ async def test_scanner_entity_state(
assert entity_state
assert entity_state.attributes == {
ATTR_SOURCE_TYPE: SourceType.ROUTER,
ATTR_IN_ZONES: [],
ATTR_IP: ip_address,
ATTR_MAC: mac_address,
ATTR_HOST_NAME: hostname,
@@ -4,6 +4,9 @@
'attributes': ReadOnlyDict({
'band': '5 GHz',
'friendly_name': '00:00:5E:00:53:01',
'in_zones': list([
'zone.home',
]),
'mac': '00:00:5E:00:53:01',
'source_type': <SourceType.ROUTER: 'router'>,
'wifi': 'Main',
@@ -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,
@@ -41,6 +41,9 @@
'attributes': ReadOnlyDict({
'friendly_name': 'FreeBSD router',
'icon': 'mdi:router-network',
'in_zones': list([
'zone.home',
]),
'ip': '192.168.50.1',
'mac': '00:00:00:00:00:01',
'source_type': <SourceType.ROUTER: 'router'>,
@@ -95,6 +98,9 @@
'attributes': ReadOnlyDict({
'friendly_name': 'PC_HOME',
'icon': 'mdi:desktop-tower',
'in_zones': list([
'zone.home',
]),
'ip': '192.168.50.3',
'mac': '00:00:00:00:00:03',
'source_type': <SourceType.ROUTER: 'router'>,
@@ -149,6 +155,9 @@
'attributes': ReadOnlyDict({
'friendly_name': 'Samsung The Frame 55',
'icon': 'mdi:television',
'in_zones': list([
'zone.home',
]),
'ip': '192.168.50.2',
'mac': '00:00:00:00:00:02',
'source_type': <SourceType.ROUTER: 'router'>,
+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", {})
@@ -66,6 +66,7 @@
'11011': 85,
'11016': 0,
'11034': 100,
'11042': 32.1,
'142': 1.79,
'1501': 0,
'1502': 0,
@@ -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([
+1 -32
View File
@@ -1,10 +1,6 @@
"""Common fixtures for the Novy Cooker Hood tests."""
from collections.abc import Iterator
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from rf_protocols.loader import CodeCollection
from homeassistant.components.novy_cooker_hood.const import CONF_TRANSMITTER, DOMAIN
from homeassistant.const import CONF_CODE
@@ -12,38 +8,11 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry
from tests.components.radio_frequency.common import (
MockRadioFrequencyCommand,
MockRadioFrequencyEntity,
)
from tests.components.radio_frequency.common import MockRadioFrequencyEntity
TRANSMITTER_ENTITY_ID = "radio_frequency.test_rf_transmitter"
@pytest.fixture(autouse=True)
def mock_get_codes() -> Iterator[MagicMock]:
"""Patch the bundled-codes loader so tests don't hit the filesystem."""
fake_collection = MagicMock(spec=CodeCollection)
fake_collection.async_load_command = AsyncMock(
side_effect=lambda name: MockRadioFrequencyCommand()
)
with (
patch(
"homeassistant.components.novy_cooker_hood.light.get_codes_for_code",
return_value=fake_collection,
),
patch(
"homeassistant.components.novy_cooker_hood.fan.get_codes_for_code",
return_value=fake_collection,
),
patch(
"homeassistant.components.novy_cooker_hood.config_flow.get_codes_for_code",
return_value=fake_collection,
),
):
yield fake_collection
@pytest.fixture
def mock_config_entry(
mock_rf_entity: MockRadioFrequencyEntity,
@@ -1,11 +1,11 @@
"""Test the Novy Hood config flow."""
from collections.abc import Iterator
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import pytest
from rf_protocols.codes.novy.cooker_hood import NovyCookerHoodButton
from homeassistant.components.novy_cooker_hood.commands import COMMAND_LIGHT
from homeassistant.components.novy_cooker_hood.const import CONF_TRANSMITTER, DOMAIN
from homeassistant.components.radio_frequency import DATA_COMPONENT, DOMAIN as RF_DOMAIN
from homeassistant.config_entries import SOURCE_USER
@@ -49,7 +49,6 @@ async def _start_user_flow(hass: HomeAssistant, code: str = "1") -> dict:
async def test_user_flow_test_then_finish(
hass: HomeAssistant,
mock_get_codes: MagicMock,
mock_rf_entity: MockRadioFrequencyEntity,
entity_registry: er.EntityRegistry,
) -> None:
@@ -58,8 +57,10 @@ async def test_user_flow_test_then_finish(
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "test_light"
mock_get_codes.async_load_command.assert_awaited_with(COMMAND_LIGHT)
assert len(mock_rf_entity.send_command_calls) == 2
sent = mock_rf_entity.send_command_calls[0].command
assert sent.key == NovyCookerHoodButton.LIGHT.code
assert sent.channel == 3
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"next_step_id": "finish"}
@@ -77,7 +78,6 @@ async def test_user_flow_test_then_finish(
async def test_user_flow_retry_picks_different_code(
hass: HomeAssistant,
mock_get_codes: MagicMock,
mock_rf_entity: MockRadioFrequencyEntity,
entity_registry: er.EntityRegistry,
) -> None:
@@ -99,9 +99,13 @@ async def test_user_flow_retry_picks_different_code(
},
)
assert result["type"] is FlowResultType.MENU
# One load per test x two tests; two sends per test x two tests.
assert mock_get_codes.async_load_command.await_count == 2
assert len(mock_rf_entity.send_command_calls) == 4
assert [c.command.channel for c in mock_rf_entity.send_command_calls] == [
1,
1,
7,
7,
]
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"next_step_id": "finish"}
@@ -127,7 +131,6 @@ async def test_user_flow_test_transmit_failure(
async def test_recover_after_transmit_failure(
hass: HomeAssistant,
mock_get_codes: MagicMock,
mock_rf_entity: MockRadioFrequencyEntity,
) -> None:
"""The user can Retry from test_failed and complete the flow."""
@@ -183,7 +186,6 @@ async def test_unique_id_already_configured(
async def test_same_transmitter_different_code_is_allowed(
hass: HomeAssistant,
mock_get_codes: MagicMock,
mock_config_entry: MockConfigEntry,
mock_rf_entity: MockRadioFrequencyEntity,
entity_registry: er.EntityRegistry,
@@ -205,7 +207,6 @@ async def test_same_transmitter_different_code_is_allowed(
async def test_reconfigure_updates_entry(
hass: HomeAssistant,
mock_get_codes: MagicMock,
init_novy_cooker_hood: MockConfigEntry,
mock_rf_entity: MockRadioFrequencyEntity,
entity_registry: er.EntityRegistry,
@@ -224,7 +225,9 @@ async def test_reconfigure_updates_entry(
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "test_light"
mock_get_codes.async_load_command.assert_awaited_with(COMMAND_LIGHT)
sent = mock_rf_entity.send_command_calls[-1].command
assert sent.key == NovyCookerHoodButton.LIGHT.code
assert sent.channel == 4
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"next_step_id": "finish"}
@@ -239,7 +242,6 @@ async def test_reconfigure_updates_entry(
async def test_reconfigure_frees_old_unique_id(
hass: HomeAssistant,
mock_get_codes: MagicMock,
init_novy_cooker_hood: MockConfigEntry,
mock_rf_entity: MockRadioFrequencyEntity,
) -> None:
@@ -295,7 +297,6 @@ async def test_reconfigure_aborts_on_collision(
async def test_reconfigure_retry_returns_to_picker(
hass: HomeAssistant,
mock_get_codes: MagicMock,
init_novy_cooker_hood: MockConfigEntry,
mock_rf_entity: MockRadioFrequencyEntity,
) -> None:
@@ -326,7 +327,6 @@ async def test_no_transmitters(hass: HomeAssistant) -> None:
async def test_recover_after_no_transmitters(
hass: HomeAssistant,
mock_get_codes: MagicMock,
) -> None:
"""User can re-init the flow after the radio_frequency integration loads."""
result = await hass.config_entries.flow.async_init(
@@ -1,13 +1,12 @@
"""Tests for the Novy Hood light platform."""
from unittest.mock import MagicMock, call
from rf_protocols.codes.novy.cooker_hood import NovyCookerHoodButton
from homeassistant.components.light import (
DOMAIN as LIGHT_DOMAIN,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
)
from homeassistant.components.novy_cooker_hood.commands import COMMAND_LIGHT
from homeassistant.const import (
ATTR_ASSUMED_STATE,
ATTR_ENTITY_ID,
@@ -28,7 +27,6 @@ ENTITY_ID = "light.novy_cooker_hood_light"
async def test_turn_on_and_off_send_light_once_each(
hass: HomeAssistant,
mock_get_codes: MagicMock,
mock_rf_entity: MockRadioFrequencyEntity,
init_novy_cooker_hood: MockConfigEntry,
) -> None:
@@ -66,11 +64,11 @@ async def test_turn_on_and_off_send_light_once_each(
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == STATE_OFF
assert mock_get_codes.async_load_command.await_args_list == [
call(COMMAND_LIGHT),
call(COMMAND_LIGHT),
]
assert len(mock_rf_entity.send_command_calls) == 2
assert [c.command.key for c in mock_rf_entity.send_command_calls] == [
NovyCookerHoodButton.LIGHT.code,
NovyCookerHoodButton.LIGHT.code,
]
async def test_restore_state(
@@ -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
@@ -4,6 +4,9 @@
'attributes': ReadOnlyDict({
'friendly_name': 'Banana',
'host_name': 'testhost',
'in_zones': list([
'zone.home',
]),
'ip': '192.168.1.102',
'mac': '2C-71-FF-ED-34-83',
'source_type': <SourceType.ROUTER: 'router'>,
@@ -20,6 +23,8 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Banana',
'in_zones': list([
]),
'mac': '2C-71-FF-ED-34-83',
'source_type': <SourceType.ROUTER: 'router'>,
}),
@@ -40,6 +40,9 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Device 6 Switch 1',
'in_zones': list([
'zone.home',
]),
'ip': '10.0.1.1',
'mac': '00:00:00:00:01:01',
'source_type': <SourceType.ROUTER: 'router'>,
@@ -94,6 +97,8 @@
'attributes': ReadOnlyDict({
'friendly_name': 'Device 1 wd_client_1',
'host_name': 'wd_client_1',
'in_zones': list([
]),
'mac': '00:00:00:00:00:02',
'source_type': <SourceType.ROUTER: 'router'>,
}),
@@ -147,6 +152,8 @@
'attributes': ReadOnlyDict({
'friendly_name': 'Device 0 ws_client_1',
'host_name': 'ws_client_1',
'in_zones': list([
]),
'ip': '10.0.0.1',
'mac': '00:00:00:00:00:01',
'source_type': <SourceType.ROUTER: 'router'>,
+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"):
@@ -41,6 +41,8 @@
'attributes': ReadOnlyDict({
'friendly_name': 'LanDevice1',
'host_name': 'LanDevice1',
'in_zones': list([
]),
'ip': '192.168.1.11',
'mac': 'yy:yy:yy:yy:yy:yy',
'source_type': <SourceType.ROUTER: 'router'>,
@@ -95,6 +97,9 @@
'attributes': ReadOnlyDict({
'friendly_name': 'WifiDevice0',
'host_name': 'WifiDevice0',
'in_zones': list([
'zone.home',
]),
'ip': '192.168.1.10',
'mac': 'xx:xx:xx:xx:xx:xx',
'source_type': <SourceType.ROUTER: 'router'>,
+210 -2
View File
@@ -7,11 +7,13 @@ import pytest
from homeassistant import setup
from homeassistant.components import zone
from homeassistant.components.zone import DOMAIN
from homeassistant.components.zone import ATTR_RADIUS, DOMAIN
from homeassistant.const import (
ATTR_EDITABLE,
ATTR_FRIENDLY_NAME,
ATTR_ICON,
ATTR_LATITUDE,
ATTR_LONGITUDE,
ATTR_NAME,
ATTR_PERSONS,
SERVICE_RELOAD,
@@ -580,7 +582,16 @@ async def test_zone_empty_setup(hass: HomeAssistant) -> None:
async def test_unavailable_zone(hass: HomeAssistant) -> None:
"""Test active zone with unavailable zones."""
"""Test active zone with unavailable zones.
Simulates the startup window where a zone has been pre-filled by the entity
registry as ``unavailable`` (``restored: True``) before the zone integration
has had a chance to write the zone's real state. Storage-created zones, and
YAML zones with an explicit ``id:``, get a unique_id and so are registered
in the entity registry; on ``EVENT_HOMEASSISTANT_START`` the registry writes
``unavailable`` for any such registered entity that does not yet have a
state. The zone helpers must skip these placeholder states.
"""
assert await setup.async_setup_component(hass, DOMAIN, {"zone": {}})
hass.states.async_set("zone.bla", "unavailable", {"restored": True})
@@ -592,6 +603,203 @@ async def test_unavailable_zone(hass: HomeAssistant) -> None:
assert zone.in_zone(hass.states.get("zone.bla"), 0, 0) is False
async def test_async_get_enclosing_zones(hass: HomeAssistant) -> None:
"""Test async_get_enclosing_zones returns zones that fully contain the given zone."""
latitude = 32.880600
longitude = -117.237561
assert await setup.async_setup_component(
hass,
zone.DOMAIN,
{
"zone": [
{
"name": "Small Zone",
"latitude": latitude,
"longitude": longitude,
"radius": 100,
},
{
"name": "Medium Zone",
"latitude": latitude,
"longitude": longitude,
"radius": 500,
},
{
"name": "Big Zone",
"latitude": latitude,
"longitude": longitude,
"radius": 2000,
},
{
"name": "Passive Big Zone",
"latitude": latitude,
"longitude": longitude,
"radius": 3000,
"passive": True,
},
]
},
)
# Small zone is enclosed by every larger concentric zone, sorted by radius.
# The queried zone itself is excluded.
assert zone.async_get_enclosing_zones(hass, "zone.small_zone") == [
"zone.medium_zone",
"zone.big_zone",
"zone.passive_big_zone",
]
# Medium zone is enclosed only by the two bigger zones.
assert zone.async_get_enclosing_zones(hass, "zone.medium_zone") == [
"zone.big_zone",
"zone.passive_big_zone",
]
# The biggest active zone is enclosed by the passive zone only.
assert zone.async_get_enclosing_zones(hass, "zone.big_zone") == [
"zone.passive_big_zone",
]
# The largest zone of all is enclosed by nothing.
assert zone.async_get_enclosing_zones(hass, "zone.passive_big_zone") == []
async def test_async_get_enclosing_zones_equal_radius(hass: HomeAssistant) -> None:
"""Test that same-center same-radius zones enclose each other (<= predicate)."""
latitude = 32.880600
longitude = -117.237561
assert await setup.async_setup_component(
hass,
zone.DOMAIN,
{
"zone": [
{
"name": "Zone A",
"latitude": latitude,
"longitude": longitude,
"radius": 1000,
},
{
"name": "Zone B",
"latitude": latitude,
"longitude": longitude,
"radius": 1000,
},
]
},
)
assert zone.async_get_enclosing_zones(hass, "zone.zone_a") == ["zone.zone_b"]
assert zone.async_get_enclosing_zones(hass, "zone.zone_b") == ["zone.zone_a"]
async def test_async_get_enclosing_zones_with_offset(hass: HomeAssistant) -> None:
"""Test full containment accounts for distance from zone center."""
latitude = 32.880600
longitude = -117.237561
assert await setup.async_setup_component(
hass,
zone.DOMAIN,
{
"zone": [
{
"name": "Inner",
"latitude": latitude,
"longitude": longitude,
"radius": 100,
},
{
"name": "Centered",
"latitude": latitude,
"longitude": longitude,
"radius": 1000,
},
{
"name": "Offset",
"latitude": 32.880600,
"longitude": -117.245561, # ~750m to the west
"radius": 1000,
},
]
},
)
# 100m radius zone at the center: both 1000m zones enclose it
# (centered trivially; offset has ~750 + 100 = 850 <= 1000).
assert zone.async_get_enclosing_zones(hass, "zone.inner") == [
"zone.centered",
"zone.offset",
]
async def test_async_get_enclosing_zones_missing_zone(hass: HomeAssistant) -> None:
"""Test async_get_enclosing_zones returns [] for an unknown zone."""
assert await setup.async_setup_component(hass, DOMAIN, {"zone": {}})
assert zone.async_get_enclosing_zones(hass, "zone.does_not_exist") == []
async def test_async_get_enclosing_zones_unavailable_input(hass: HomeAssistant) -> None:
"""Test async_get_enclosing_zones returns [] when the input zone is unavailable.
Simulates the startup window where a zone has been pre-filled by the entity
registry as ``unavailable`` (``restored: True``) before the zone integration
has had a chance to write the zone's real state. See ``test_unavailable_zone``
for the full explanation.
"""
assert await setup.async_setup_component(hass, DOMAIN, {"zone": {}})
hass.states.async_set(
"zone.bla",
"unavailable",
{"restored": True},
)
assert zone.async_get_enclosing_zones(hass, "zone.bla") == []
async def test_async_get_enclosing_zones_skips_unavailable_other(
hass: HomeAssistant,
) -> None:
"""Test other zones that are unavailable are skipped when searching containers.
See ``test_unavailable_zone`` for why an unavailable zone can appear in the
state machine.
"""
latitude = 32.880600
longitude = -117.237561
assert await setup.async_setup_component(
hass,
zone.DOMAIN,
{
"zone": [
{
"name": "Inner",
"latitude": latitude,
"longitude": longitude,
"radius": 100,
},
]
},
)
# A zone that would otherwise enclose Inner, but is unavailable.
hass.states.async_set(
"zone.bigger",
"unavailable",
{
ATTR_LATITUDE: latitude,
ATTR_LONGITUDE: longitude,
ATTR_RADIUS: 1000,
"restored": True,
},
)
assert zone.async_get_enclosing_zones(hass, "zone.inner") == []
async def test_state(hass: HomeAssistant) -> None:
"""Test the state of a zone."""
info = {
@@ -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