mirror of
https://github.com/home-assistant/core.git
synced 2026-05-26 02:35:12 +02:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38b4184dc3 | |||
| cfde7975d8 | |||
| d7ab696a4c | |||
| 7f7dad7f71 | |||
| 0ed21dbed7 | |||
| d2b37ee28b | |||
| b82c95e77f | |||
| baa61982a1 | |||
| 8ff6de788d | |||
| 640f82642a | |||
| 64ed269f9c | |||
| 2b58ef96eb | |||
| 74ca79ac28 | |||
| afb27bc165 | |||
| 0cbf27f44f | |||
| a5ceafa544 | |||
| cd4d669231 | |||
| cc411d06b5 | |||
| 1329f12d37 | |||
| 3899f5347b | |||
| cf02cfaa7c | |||
| e77c16ea1b | |||
| f1e2f94ee0 | |||
| 3516883b0a | |||
| c8b70b1a38 | |||
| 946625e281 | |||
| f4b7840d5c | |||
| 060f447e4a | |||
| d5bae0a2cf | |||
| f9bef804b1 | |||
| 6de03f4ed6 | |||
| e7f3e5637f | |||
| 80cefc74ec | |||
| 2f33b4b7f9 | |||
| cf52a7a509 | |||
| f5835f849a | |||
| ec5210dca8 | |||
| 422ea1a9b1 |
@@ -193,7 +193,11 @@ async def async_setup_entry(
|
||||
Aranet4BluetoothSensorEntity, async_add_entities
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(entry.runtime_data.async_register_processor(processor))
|
||||
entry.async_on_unload(
|
||||
entry.runtime_data.async_register_processor(
|
||||
processor, AranetSensorEntityDescription
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class Aranet4BluetoothSensorEntity(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from collections.abc import Mapping
|
||||
from ipaddress import ip_address
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import voluptuous as vol
|
||||
@@ -49,6 +49,9 @@ from .const import (
|
||||
from .errors import AuthenticationRequired, CannotConnect
|
||||
from .hub import AxisHub, get_axis_api
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import axis
|
||||
|
||||
AXIS_OUI = {"00:40:8c", "ac:cc:8e", "b8:a4:4f", "e8:27:25"}
|
||||
DEFAULT_PORT = 443
|
||||
DEFAULT_PROTOCOL = "https"
|
||||
@@ -93,7 +96,8 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
errors["base"] = "cannot_connect"
|
||||
|
||||
else:
|
||||
serial = api.vapix.serial_number
|
||||
if (serial := self._get_serial_number(api)) is None:
|
||||
return self.async_abort(reason="no_serial_number")
|
||||
config = {
|
||||
CONF_PROTOCOL: user_input[CONF_PROTOCOL],
|
||||
CONF_HOST: user_input[CONF_HOST],
|
||||
@@ -258,6 +262,19 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
return await self.async_step_user()
|
||||
|
||||
@staticmethod
|
||||
def _get_serial_number(api: axis.AxisDevice) -> str | None:
|
||||
"""Retrieve the device serial number from the Axis API.
|
||||
|
||||
Tries basic_device_info first, then property_handler. Returns None if not found.
|
||||
"""
|
||||
vapix = api.vapix
|
||||
if vapix.basic_device_info.initialized:
|
||||
return vapix.basic_device_info["0"].serial_number
|
||||
if vapix.params.property_handler.initialized:
|
||||
return vapix.params.property_handler["0"].system_serial_number
|
||||
return None
|
||||
|
||||
|
||||
class AxisOptionsFlowHandler(OptionsFlow):
|
||||
"""Handle Axis device options."""
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"link_local_address": "Link local addresses are not supported",
|
||||
"no_serial_number": "Could not retrieve a serial number from the device. Please check device connectivity and try again.",
|
||||
"not_axis_device": "Discovered device not an Axis device",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
|
||||
@@ -124,7 +124,9 @@ async def async_setup_entry(
|
||||
BlueMaestroBluetoothSensorEntity, async_add_entities
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(coordinator.async_register_processor(processor))
|
||||
entry.async_on_unload(
|
||||
coordinator.async_register_processor(processor, SensorEntityDescription)
|
||||
)
|
||||
|
||||
|
||||
class BlueMaestroBluetoothSensorEntity(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
@@ -27,7 +27,7 @@ DEFAULT_BLUETOOTH_SCANNING_MODE = BluetoothScanningMode.AUTO.value
|
||||
|
||||
DEFAULT_PORT: Final = 6053
|
||||
|
||||
STABLE_BLE_VERSION_STR = "2025.11.0"
|
||||
STABLE_BLE_VERSION_STR = "2026.5.1"
|
||||
STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR)
|
||||
PROJECT_URLS = {
|
||||
"esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/",
|
||||
|
||||
@@ -53,7 +53,7 @@ def async_static_info_updated(
|
||||
platform: entity_platform.EntityPlatform,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
info_type: type[_InfoT],
|
||||
entity_type: type[_EntityT],
|
||||
entity_type: Callable[[RuntimeEntryData, EntityInfo, type[_StateT]], _EntityT],
|
||||
state_type: type[_StateT],
|
||||
infos: list[EntityInfo],
|
||||
) -> None:
|
||||
@@ -188,7 +188,7 @@ async def platform_async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
*,
|
||||
info_type: type[_InfoT],
|
||||
entity_type: type[_EntityT],
|
||||
entity_type: Callable[[RuntimeEntryData, EntityInfo, type[_StateT]], _EntityT],
|
||||
state_type: type[_StateT],
|
||||
info_filter: Callable[[_InfoT], bool] | None = None,
|
||||
) -> None:
|
||||
@@ -196,6 +196,11 @@ async def platform_async_setup_entry(
|
||||
|
||||
This method is in charge of receiving, distributing and storing
|
||||
info and state updates.
|
||||
|
||||
`entity_type` is any callable that builds an entity from
|
||||
`(entry_data, info, state_type)`. A regular entity class satisfies this,
|
||||
and platforms with multiple entity classes can pass a factory function
|
||||
that picks the class per static info.
|
||||
"""
|
||||
entry_data = entry.runtime_data
|
||||
entry_data.info[info_type] = {}
|
||||
|
||||
@@ -1,28 +1,34 @@
|
||||
"""Infrared platform for ESPHome."""
|
||||
|
||||
from functools import partial
|
||||
import functools
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aioesphomeapi import EntityState, InfraredCapability, InfraredInfo
|
||||
from aioesphomeapi import EntityInfo, EntityState, InfraredCapability, InfraredInfo
|
||||
from aioesphomeapi.client import InfraredRFReceiveEventModel
|
||||
|
||||
from homeassistant.components.infrared import InfraredCommand, InfraredEmitterEntity
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.components.infrared import (
|
||||
InfraredCommand,
|
||||
InfraredEmitterEntity,
|
||||
InfraredReceivedSignal,
|
||||
InfraredReceiverEntity,
|
||||
)
|
||||
from homeassistant.core import CALLBACK_TYPE, callback
|
||||
|
||||
from .entity import (
|
||||
EsphomeEntity,
|
||||
convert_api_error_ha_error,
|
||||
platform_async_setup_entry,
|
||||
)
|
||||
from .entry_data import RuntimeEntryData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
class EsphomeInfraredEntity(
|
||||
EsphomeEntity[InfraredInfo, EntityState], InfraredEmitterEntity
|
||||
):
|
||||
"""ESPHome infrared emitter entity using native API."""
|
||||
class _EsphomeInfraredEntity(EsphomeEntity[InfraredInfo, EntityState]):
|
||||
"""Common base for ESPHome infrared entities."""
|
||||
|
||||
@callback
|
||||
def _on_device_update(self) -> None:
|
||||
@@ -32,6 +38,10 @@ class EsphomeInfraredEntity(
|
||||
# Infrared entities should go available as soon as the device comes online
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class EsphomeInfraredEmitterEntity(_EsphomeInfraredEntity, InfraredEmitterEntity):
|
||||
"""ESPHome infrared emitter entity using native API."""
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_send_command(self, command: InfraredCommand) -> None:
|
||||
"""Send an IR command."""
|
||||
@@ -46,10 +56,77 @@ class EsphomeInfraredEntity(
|
||||
)
|
||||
|
||||
|
||||
async_setup_entry = partial(
|
||||
class EsphomeInfraredReceiverEntity(_EsphomeInfraredEntity, InfraredReceiverEntity):
|
||||
"""ESPHome infrared receiver entity using native API."""
|
||||
|
||||
_unsub_receive: CALLBACK_TYPE | None = None
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks including IR receive subscription."""
|
||||
await super().async_added_to_hass()
|
||||
self._async_subscribe_receive()
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Unsubscribe from the device on entity removal."""
|
||||
await super().async_will_remove_from_hass()
|
||||
if self._unsub_receive is not None:
|
||||
self._unsub_receive()
|
||||
self._unsub_receive = None
|
||||
|
||||
@callback
|
||||
def _async_subscribe_receive(self) -> None:
|
||||
"""Subscribe to IR receive events if the device is connected."""
|
||||
# Subscribing requires an active API connection; defer to
|
||||
# _on_device_update when the device is not (yet) available.
|
||||
if self._unsub_receive is not None or not self._entry_data.available:
|
||||
return
|
||||
self._unsub_receive = self._client.subscribe_infrared_rf_receive(
|
||||
self._on_infrared_rf_receive
|
||||
)
|
||||
|
||||
@callback
|
||||
def _on_device_update(self) -> None:
|
||||
"""Call when device updates or entry data changes."""
|
||||
super()._on_device_update()
|
||||
if self._entry_data.available:
|
||||
self._async_subscribe_receive()
|
||||
elif self._unsub_receive is not None:
|
||||
self._unsub_receive = None
|
||||
|
||||
@callback
|
||||
def _on_infrared_rf_receive(self, event: InfraredRFReceiveEventModel) -> None:
|
||||
"""Handle a received IR signal from the device."""
|
||||
if (
|
||||
event.key != self._static_info.key
|
||||
or event.device_id != self._static_info.device_id
|
||||
):
|
||||
return
|
||||
self._handle_received_signal(InfraredReceivedSignal(timings=event.timings))
|
||||
|
||||
|
||||
def _make_infrared_entity(
|
||||
entry_data: RuntimeEntryData,
|
||||
info: EntityInfo,
|
||||
state_type: type[EntityState],
|
||||
) -> _EsphomeInfraredEntity:
|
||||
"""Build the right infrared entity based on the InfraredInfo capabilities."""
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(info, InfraredInfo)
|
||||
cls = (
|
||||
EsphomeInfraredReceiverEntity
|
||||
if info.capabilities & InfraredCapability.RECEIVER
|
||||
else EsphomeInfraredEmitterEntity
|
||||
)
|
||||
return cls(entry_data, info, state_type)
|
||||
|
||||
|
||||
async_setup_entry = functools.partial(
|
||||
platform_async_setup_entry,
|
||||
info_type=InfraredInfo,
|
||||
entity_type=EsphomeInfraredEntity,
|
||||
entity_type=_make_infrared_entity,
|
||||
state_type=EntityState,
|
||||
info_filter=lambda info: bool(info.capabilities & InfraredCapability.TRANSMITTER),
|
||||
info_filter=lambda info: bool(
|
||||
info.capabilities
|
||||
& (InfraredCapability.TRANSMITTER | InfraredCapability.RECEIVER)
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -117,7 +117,9 @@ async def async_setup_entry(
|
||||
INKBIRDBluetoothSensorEntity, async_add_entities
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(entry.runtime_data.async_register_processor(processor))
|
||||
entry.async_on_unload(
|
||||
entry.runtime_data.async_register_processor(processor, SensorEntityDescription)
|
||||
)
|
||||
|
||||
|
||||
class INKBIRDBluetoothSensorEntity(
|
||||
|
||||
@@ -116,7 +116,9 @@ async def async_setup_entry(
|
||||
KegtronBluetoothSensorEntity, async_add_entities
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(coordinator.async_register_processor(processor))
|
||||
entry.async_on_unload(
|
||||
coordinator.async_register_processor(processor, SensorEntityDescription)
|
||||
)
|
||||
|
||||
|
||||
class KegtronBluetoothSensorEntity(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["icmplib"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["icmplib==3.0"]
|
||||
"requirements": ["icmplib==3.0.4"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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*",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"quality_scale": "bronze",
|
||||
"requirements": [
|
||||
"defusedxml==0.7.1",
|
||||
"soco==0.30.15",
|
||||
"soco==0.31.1",
|
||||
"sonos-websocket==0.1.3"
|
||||
],
|
||||
"ssdp": [
|
||||
|
||||
@@ -119,7 +119,9 @@ async def async_setup_entry(
|
||||
ThermoBeaconBluetoothSensorEntity, async_add_entities
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(coordinator.async_register_processor(processor))
|
||||
entry.async_on_unload(
|
||||
coordinator.async_register_processor(processor, SensorEntityDescription)
|
||||
)
|
||||
|
||||
|
||||
class ThermoBeaconBluetoothSensorEntity(
|
||||
|
||||
@@ -92,7 +92,9 @@ async def async_setup_entry(
|
||||
TiltBluetoothSensorEntity, async_add_entities
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(coordinator.async_register_processor(processor))
|
||||
entry.async_on_unload(
|
||||
coordinator.async_register_processor(processor, SensorEntityDescription)
|
||||
)
|
||||
|
||||
|
||||
class TiltBluetoothSensorEntity(
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiounifi"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aiounifi==90"]
|
||||
"requirements": ["aiounifi==91"]
|
||||
}
|
||||
|
||||
@@ -519,7 +519,11 @@ async def async_setup_entry(
|
||||
VictronBLESensorEntity, async_add_entities
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(coordinator.async_register_processor(processor))
|
||||
entry.async_on_unload(
|
||||
coordinator.async_register_processor(
|
||||
processor, VictronBLESensorEntityDescription
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class VictronBLESensorEntity(PassiveBluetoothProcessorEntity, SensorEntity):
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+3
-3
@@ -5,7 +5,7 @@
|
||||
# Home Assistant Core
|
||||
aiodns==4.0.4
|
||||
aiogithubapi==26.0.0
|
||||
aiohttp-asyncmdnsresolver==0.1.1
|
||||
aiohttp-asyncmdnsresolver==0.2.0
|
||||
aiohttp-fast-zlib==0.3.0
|
||||
aiohttp==3.13.5
|
||||
aiohttp_cors==0.8.1
|
||||
@@ -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
|
||||
|
||||
Generated
+14
-14
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -140,6 +140,7 @@ def fixture_request(
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
port_management_payload: dict[str, Any],
|
||||
param_properties_payload: str,
|
||||
param_properties_status_code: int,
|
||||
param_ports_payload: str,
|
||||
mqtt_status_code: int,
|
||||
) -> Callable[[str], None]:
|
||||
@@ -149,12 +150,15 @@ def fixture_request(
|
||||
def _url_pattern(path: str) -> re.Pattern[str]:
|
||||
return re.compile(rf"^https?://{re.escape(host)}(?::\d+)?{path}$")
|
||||
|
||||
def _text_response(url: URL, text: str) -> AiohttpClientMockResponse:
|
||||
def _text_response(
|
||||
url: URL, text: str, status: int = 200
|
||||
) -> AiohttpClientMockResponse:
|
||||
return AiohttpClientMockResponse(
|
||||
"post",
|
||||
url,
|
||||
text=text,
|
||||
headers={"Content-Type": "text/plain"},
|
||||
status=status,
|
||||
)
|
||||
|
||||
async def _param_cgi_response(
|
||||
@@ -172,7 +176,9 @@ def fixture_request(
|
||||
if group == "root.Output":
|
||||
return _text_response(url, PORTS_RESPONSE)
|
||||
if group == "root.Properties":
|
||||
return _text_response(url, param_properties_payload)
|
||||
return _text_response(
|
||||
url, param_properties_payload, param_properties_status_code
|
||||
)
|
||||
if group == "root.PTZ":
|
||||
return _text_response(url, PTZ_RESPONSE)
|
||||
if group == "root.StreamProfile":
|
||||
@@ -276,9 +282,15 @@ def fixture_param_ports_data() -> str:
|
||||
return PORTS_RESPONSE
|
||||
|
||||
|
||||
@pytest.fixture(name="param_properties_status_code")
|
||||
def fixture_param_properties_status_code() -> int:
|
||||
"""Property parameter status code."""
|
||||
return 200
|
||||
|
||||
|
||||
@pytest.fixture(name="mqtt_status_code")
|
||||
def fixture_mqtt_status_code() -> int:
|
||||
"""Property parameter data."""
|
||||
"""MQTT status code."""
|
||||
return 200
|
||||
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from .const import DEFAULT_HOST, MAC, MODEL, NAME
|
||||
from .const import API_DISCOVERY_BASIC_DEVICE_INFO, DEFAULT_HOST, MAC, MODEL, NAME
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
@@ -146,6 +146,63 @@ async def test_flow_fails_on_api(
|
||||
assert result["errors"] == {"base": error}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_default_requests")
|
||||
@pytest.mark.parametrize("param_properties_status_code", [404])
|
||||
async def test_flow_aborts_if_no_serial_number(hass: HomeAssistant) -> None:
|
||||
"""Test that config flow aborts if property_handler is not initialized and no serial is found."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_PROTOCOL: "http",
|
||||
CONF_HOST: "1.2.3.4",
|
||||
CONF_USERNAME: "user",
|
||||
CONF_PASSWORD: "pass",
|
||||
CONF_PORT: 80,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "no_serial_number"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_default_requests")
|
||||
@pytest.mark.parametrize("api_discovery_items", [API_DISCOVERY_BASIC_DEVICE_INFO])
|
||||
async def test_flow_succeeds_with_basic_device_info(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test that config flow succeeds when basic device info is present (positive path)."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_PROTOCOL: "http",
|
||||
CONF_HOST: "1.2.3.4",
|
||||
CONF_USERNAME: "user",
|
||||
CONF_PASSWORD: "pass",
|
||||
CONF_PORT: 80,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == f"M1065-LW - {MAC}"
|
||||
assert result["data"][CONF_HOST] == "1.2.3.4"
|
||||
assert result["data"][CONF_MODEL] == "M1065-LW"
|
||||
assert result["data"][CONF_NAME] == f"M1065-LW - {MAC}"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_default_requests")
|
||||
async def test_flow_create_entry_multiple_existing_entries_of_same_model(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'>,
|
||||
|
||||
@@ -132,18 +132,40 @@ async def test_reload_core_conf(hass: HomeAssistant) -> None:
|
||||
@patch("homeassistant.config.os.path.isfile", Mock(return_value=True))
|
||||
@patch("homeassistant.components.homeassistant._LOGGER.error")
|
||||
@patch("homeassistant.core_config.async_process_ha_core_config")
|
||||
@pytest.mark.parametrize(
|
||||
("files_patch", "expected_error"),
|
||||
[
|
||||
(
|
||||
{config.YAML_CONFIG_FILE: yaml.dump(["invalid", "config"])},
|
||||
"YAML file .*configuration.yaml does not contain a dict",
|
||||
),
|
||||
({"not_existing": "blabla"}, "File not found: .*configuration.yaml"),
|
||||
],
|
||||
)
|
||||
async def test_reload_core_with_wrong_conf(
|
||||
mock_process, mock_error, hass: HomeAssistant
|
||||
mock_process,
|
||||
mock_error,
|
||||
hass: HomeAssistant,
|
||||
files_patch: dict[str, str],
|
||||
expected_error: str,
|
||||
) -> None:
|
||||
"""Test reload core conf service."""
|
||||
files = {config.YAML_CONFIG_FILE: yaml.dump(["invalid", "config"])}
|
||||
await async_setup_component(hass, ha.DOMAIN, {})
|
||||
with patch_yaml_files(files, True):
|
||||
with (
|
||||
patch_yaml_files(files_patch, True),
|
||||
pytest.raises(
|
||||
HomeAssistantError,
|
||||
match=(
|
||||
"Failed to reload the Home Assistant Core configuration - "
|
||||
f"{expected_error}"
|
||||
),
|
||||
),
|
||||
):
|
||||
await hass.services.async_call(
|
||||
ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG, blocking=True
|
||||
)
|
||||
|
||||
assert mock_error.called
|
||||
assert mock_error.called is False
|
||||
assert mock_process.called is False
|
||||
|
||||
|
||||
|
||||
@@ -4,15 +4,17 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import voluptuous as vol
|
||||
import yaml
|
||||
|
||||
from homeassistant import config
|
||||
from homeassistant.components.homeassistant import scene as ha_scene
|
||||
from homeassistant.components.homeassistant.scene import EVENT_SCENE_RELOADED
|
||||
from homeassistant.const import STATE_UNKNOWN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import async_capture_events, async_mock_service
|
||||
from tests.common import async_capture_events, async_mock_service, patch_yaml_files
|
||||
|
||||
|
||||
async def test_reload_config_service(hass: HomeAssistant) -> None:
|
||||
@@ -46,6 +48,36 @@ async def test_reload_config_service(hass: HomeAssistant) -> None:
|
||||
assert hass.states.get("scene.bye") is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("files_patch", "expected_error"),
|
||||
[
|
||||
(
|
||||
{config.YAML_CONFIG_FILE: yaml.dump(["invalid", "config"])},
|
||||
"YAML file .*configuration.yaml does not contain a dict",
|
||||
),
|
||||
({"not_existing": "blabla"}, "File not found: .*configuration.yaml"),
|
||||
],
|
||||
)
|
||||
async def test_reload_config_service_failed(
|
||||
hass: HomeAssistant, files_patch: dict[str, str], expected_error: str
|
||||
) -> None:
|
||||
"""Test error handling when the reload config service fails."""
|
||||
assert await async_setup_component(hass, "scene", {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
with (
|
||||
patch_yaml_files(files_patch, True),
|
||||
pytest.raises(
|
||||
HomeAssistantError,
|
||||
match=(
|
||||
"Failed to reload the Home Assistant scene platform configuration - "
|
||||
f"{expected_error}"
|
||||
),
|
||||
),
|
||||
):
|
||||
await hass.services.async_call("scene", "reload", blocking=True)
|
||||
|
||||
|
||||
async def test_apply_service(hass: HomeAssistant) -> None:
|
||||
"""Test the apply service."""
|
||||
assert await async_setup_component(hass, "scene", {})
|
||||
|
||||
@@ -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,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"]
|
||||
}
|
||||
}
|
||||
@@ -275,6 +275,64 @@ async def test_adam_2_climate_snapshot(
|
||||
await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("chosen_env", ["m_adam_heating_off_schedule"], indirect=True)
|
||||
@pytest.mark.parametrize("cooling_present", [False], indirect=True)
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_adam_off_regulation_mode_change(
|
||||
hass: HomeAssistant,
|
||||
mock_smile_adam_heat_cool: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test changing from regulation off mode."""
|
||||
mock_restore_cache_with_extra_data(
|
||||
hass,
|
||||
[
|
||||
(
|
||||
State("climate.living_room", "heat"),
|
||||
PlugwiseClimateExtraStoredData(
|
||||
last_active_schedule=None,
|
||||
previous_action_mode="heating",
|
||||
).as_dict(),
|
||||
),
|
||||
(
|
||||
State("climate.bathroom", "heat"),
|
||||
PlugwiseClimateExtraStoredData(
|
||||
last_active_schedule="Badkamer",
|
||||
previous_action_mode="heating",
|
||||
).as_dict(),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert (state := hass.states.get("climate.living_room"))
|
||||
assert state.state == "off"
|
||||
|
||||
# Verify a HomeAssistantError is raised setting a schedule from regulation-off-mode with last_active_schedule = None
|
||||
with pytest.raises(HomeAssistantError):
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
{ATTR_ENTITY_ID: "climate.living_room", ATTR_HVAC_MODE: HVACMode.AUTO},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
# Verify that the active schedule is turned off when transitioning from regulation-off-mode to a manual mode
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
{ATTR_ENTITY_ID: "climate.bathroom", ATTR_HVAC_MODE: HVACMode.HEAT},
|
||||
blocking=True,
|
||||
)
|
||||
mock_smile_adam_heat_cool.set_schedule_state.assert_called_with(
|
||||
"f871b8c4d63549319221e294e4f88074", STATE_OFF, "Badkamer"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("chosen_env", ["m_adam_cooling"], indirect=True)
|
||||
@pytest.mark.parametrize("cooling_present", [True], indirect=True)
|
||||
async def test_adam_3_climate_entity_attributes(
|
||||
@@ -561,6 +619,26 @@ async def test_anna_climate_entity_climate_changes(
|
||||
"standaard",
|
||||
)
|
||||
|
||||
data = mock_smile_anna.async_update.return_value
|
||||
data["3cb70739631c4d17a86b8b12e8a5161b"]["climate_mode"] = "heat"
|
||||
with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data):
|
||||
freezer.tick(timedelta(minutes=1))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
{ATTR_ENTITY_ID: "climate.anna", ATTR_HVAC_MODE: HVACMode.AUTO},
|
||||
blocking=True,
|
||||
)
|
||||
assert mock_smile_anna.set_schedule_state.call_count == 2
|
||||
mock_smile_anna.set_schedule_state.assert_called_with(
|
||||
"c784ee9fdab44e1395b8dee7d7a497d5",
|
||||
STATE_ON,
|
||||
"standaard",
|
||||
)
|
||||
|
||||
# Mock user deleting last schedule from app or browser
|
||||
data = mock_smile_anna.async_update.return_value
|
||||
data["3cb70739631c4d17a86b8b12e8a5161b"]["available_schedules"] = []
|
||||
|
||||
@@ -114,6 +114,7 @@ MOCK_GAMES_LOCKED = {MOCK_ID: MOCK_GAMES_DATA_LOCKED}
|
||||
|
||||
async def test_ps4_integration_setup(hass: HomeAssistant) -> None:
|
||||
"""Test PS4 integration is setup."""
|
||||
# pylint: disable-next=home-assistant-tests-direct-async-setup
|
||||
await ps4.async_setup(hass, {})
|
||||
await hass.async_block_till_done()
|
||||
assert hass.data[PS4_DATA].protocol is not None
|
||||
|
||||
@@ -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'>,
|
||||
|
||||
@@ -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'>,
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user