Compare commits

..

1 Commits

Author SHA1 Message Date
Erik 1d58d51e51 Add entity option to associate scanner tracker with any zone 2026-05-25 18:29:11 +02:00
57 changed files with 635 additions and 1251 deletions
+1 -5
View File
@@ -193,11 +193,7 @@ async def async_setup_entry(
Aranet4BluetoothSensorEntity, async_add_entities
)
)
entry.async_on_unload(
entry.runtime_data.async_register_processor(
processor, AranetSensorEntityDescription
)
)
entry.async_on_unload(entry.runtime_data.async_register_processor(processor))
class Aranet4BluetoothSensorEntity(
+2 -19
View File
@@ -2,7 +2,7 @@
from collections.abc import Mapping
from ipaddress import ip_address
from typing import TYPE_CHECKING, Any
from typing import Any
from urllib.parse import urlsplit
import voluptuous as vol
@@ -49,9 +49,6 @@ 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"
@@ -96,8 +93,7 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
errors["base"] = "cannot_connect"
else:
if (serial := self._get_serial_number(api)) is None:
return self.async_abort(reason="no_serial_number")
serial = api.vapix.serial_number
config = {
CONF_PROTOCOL: user_input[CONF_PROTOCOL],
CONF_HOST: user_input[CONF_HOST],
@@ -262,19 +258,6 @@ 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,7 +3,6 @@
"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,9 +124,7 @@ async def async_setup_entry(
BlueMaestroBluetoothSensorEntity, async_add_entities
)
)
entry.async_on_unload(
coordinator.async_register_processor(processor, SensorEntityDescription)
)
entry.async_on_unload(coordinator.async_register_processor(processor))
class BlueMaestroBluetoothSensorEntity(
@@ -17,10 +17,10 @@
"requirements": [
"bleak==3.0.2",
"bleak-retry-connector==4.6.1",
"bluetooth-adapters==2.3.0",
"bluetooth-adapters==2.2.0",
"bluetooth-auto-recovery==1.6.4",
"bluetooth-data-tools==1.29.18",
"dbus-fast==5.0.11",
"habluetooth==6.7.4"
"dbus-fast==5.0.9",
"habluetooth==6.7.3"
]
}
@@ -31,6 +31,7 @@ from .const import ( # noqa: F401
ATTR_LOCATION_NAME,
ATTR_MAC,
ATTR_SOURCE_TYPE,
CONF_ASSOCIATED_ZONE,
CONF_CONSIDER_HOME,
CONF_NEW_DEVICE_DEFAULTS,
CONF_SCAN_INTERVAL,
@@ -17,8 +17,19 @@ from homeassistant.const import (
STATE_NOT_HOME,
EntityCategory,
)
from homeassistant.core import Event, HomeAssistant, State, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.core import (
CALLBACK_TYPE,
Event,
EventStateChangedData,
HomeAssistant,
State,
callback,
)
from homeassistant.helpers import (
device_registry as dr,
entity_registry as er,
issue_registry as ir,
)
from homeassistant.helpers.device_registry import (
DeviceInfo,
EventDeviceRegistryUpdatedData,
@@ -27,6 +38,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.entity_platform import EntityPlatform
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.typing import StateType
from homeassistant.util.hass_dict import HassKey
@@ -36,6 +48,7 @@ from .const import (
ATTR_IP,
ATTR_MAC,
ATTR_SOURCE_TYPE,
CONF_ASSOCIATED_ZONE,
CONNECTED_DEVICE_REGISTERED,
DOMAIN,
LOGGER,
@@ -343,14 +356,116 @@ class BaseScannerEntity(BaseTrackerEntity):
addresses being used to identify the device.
"""
_scanner_option_associated_zone: str = zone.ENTITY_ID_HOME
_scanner_option_associated_zone_unsub: CALLBACK_TYPE | None = None
async def async_internal_added_to_hass(self) -> None:
"""Call when the scanner entity is added to hass."""
await super().async_internal_added_to_hass()
if not self.registry_entry:
return
self._async_read_entity_options()
async def async_internal_will_remove_from_hass(self) -> None:
"""Call when the scanner entity is about to be removed from hass."""
if self._scanner_option_associated_zone_unsub is not None:
self._scanner_option_associated_zone_unsub()
self._scanner_option_associated_zone_unsub = None
self._async_clear_associated_zone_issue()
await super().async_internal_will_remove_from_hass()
@callback
def async_registry_entry_updated(self) -> None:
"""Run when the entity registry entry has been updated."""
self._async_read_entity_options()
@callback
def _async_read_entity_options(self) -> None:
"""Read entity options from the entity registry.
Called when the entity registry entry has been updated and before the
scanner entity is added to the state machine.
"""
assert self.registry_entry
if (scanner_options := self.registry_entry.options.get(DOMAIN)) and (
associated_zone := scanner_options.get(CONF_ASSOCIATED_ZONE)
):
new_zone = associated_zone
else:
new_zone = zone.ENTITY_ID_HOME
if new_zone == self._scanner_option_associated_zone:
return
# Tear down tracking for the previous zone.
if self._scanner_option_associated_zone_unsub is not None:
self._scanner_option_associated_zone_unsub()
self._scanner_option_associated_zone_unsub = None
self._async_clear_associated_zone_issue()
self._scanner_option_associated_zone = new_zone
# zone.home is always present so no tracking or issue handling needed.
if new_zone == zone.ENTITY_ID_HOME:
return
self._scanner_option_associated_zone_unsub = async_track_state_change_event(
self.hass, new_zone, self._async_associated_zone_state_changed
)
if self.hass.states.get(new_zone) is None:
self._async_create_associated_zone_issue()
@callback
def _async_associated_zone_state_changed(
self, event: Event[EventStateChangedData]
) -> None:
"""Open or clear the repair issue when the associated zone appears or disappears."""
if event.data["new_state"] is None:
self._async_create_associated_zone_issue()
else:
self._async_clear_associated_zone_issue()
self.async_write_ha_state()
@callback
def _async_create_associated_zone_issue(self) -> None:
"""Create a repair issue prompting the user to reconfigure the scanner."""
ir.async_create_issue(
self.hass,
DOMAIN,
self._associated_zone_issue_id,
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key="associated_zone_missing",
translation_placeholders={
"entity_id": self.entity_id,
"zone": self._scanner_option_associated_zone,
},
)
@callback
def _async_clear_associated_zone_issue(self) -> None:
"""Clear the associated-zone-missing repair issue if it exists."""
ir.async_delete_issue(self.hass, DOMAIN, self._associated_zone_issue_id)
@property
def _associated_zone_issue_id(self) -> str:
"""Return the issue id for the associated-zone-missing repair."""
return f"associated_zone_missing_{self.entity_id}"
@property
def state(self) -> str | None:
"""Return the state of the device."""
if self.is_connected is None:
return None
if self.is_connected:
if not self.is_connected:
return STATE_NOT_HOME
associated_zone = self._scanner_option_associated_zone
if associated_zone == zone.ENTITY_ID_HOME:
return STATE_HOME
return STATE_NOT_HOME
if zone_state := self.hass.states.get(associated_zone):
return zone_state.name
# Configured zone has been removed; state is unknown.
return None
@property
def is_connected(self) -> bool | None:
@@ -367,9 +482,18 @@ class BaseScannerEntity(BaseTrackerEntity):
if not self.is_connected:
return attr
associated_zone = self._scanner_option_associated_zone
# If the configured zone has been removed, in_zones stays empty so the
# attribute does not claim membership in a zone that no longer exists.
if (
associated_zone != zone.ENTITY_ID_HOME
and self.hass.states.get(associated_zone) is None
):
return attr
attr[ATTR_IN_ZONES] = [
zone.ENTITY_ID_HOME,
*zone.async_get_enclosing_zones(self.hass, zone.ENTITY_ID_HOME),
associated_zone,
*zone.async_get_enclosing_zones(self.hass, associated_zone),
]
return attr
@@ -36,6 +36,8 @@ DEFAULT_CONSIDER_HOME: Final = timedelta(seconds=180)
CONF_NEW_DEVICE_DEFAULTS: Final = "new_device_defaults"
CONF_ASSOCIATED_ZONE: Final = "associated_zone"
ATTR_ATTRIBUTES: Final = "attributes"
ATTR_BATTERY: Final = "battery"
ATTR_DEV_ID: Final = "dev_id"
@@ -44,6 +44,12 @@
}
}
},
"issues": {
"associated_zone_missing": {
"description": "The scanner entity `{entity_id}` is associated with the zone `{zone}`, but that zone has been removed.\n\nTo fix this, reconfigure the scanner to use a different zone or recreate the missing zone.",
"title": "Scanner is associated with a removed zone"
}
},
"services": {
"see": {
"description": "Manually update the records of a seen legacy device tracker in the known_devices.yaml file.",
+1 -1
View File
@@ -15,7 +15,7 @@
],
"quality_scale": "internal",
"requirements": [
"aiodhcpwatcher==1.2.7",
"aiodhcpwatcher==1.2.6",
"aiodiscover==3.2.3",
"cached-ipaddress==1.1.1"
]
+1 -1
View File
@@ -27,7 +27,7 @@ DEFAULT_BLUETOOTH_SCANNING_MODE = BluetoothScanningMode.AUTO.value
DEFAULT_PORT: Final = 6053
STABLE_BLE_VERSION_STR = "2026.5.1"
STABLE_BLE_VERSION_STR = "2025.11.0"
STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR)
PROJECT_URLS = {
"esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/",
+2 -7
View File
@@ -53,7 +53,7 @@ def async_static_info_updated(
platform: entity_platform.EntityPlatform,
async_add_entities: AddEntitiesCallback,
info_type: type[_InfoT],
entity_type: Callable[[RuntimeEntryData, EntityInfo, type[_StateT]], _EntityT],
entity_type: type[_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: Callable[[RuntimeEntryData, EntityInfo, type[_StateT]], _EntityT],
entity_type: type[_EntityT],
state_type: type[_StateT],
info_filter: Callable[[_InfoT], bool] | None = None,
) -> None:
@@ -196,11 +196,6 @@ 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] = {}
+11 -88
View File
@@ -1,34 +1,28 @@
"""Infrared platform for ESPHome."""
import functools
from functools import partial
import logging
from typing import TYPE_CHECKING
from aioesphomeapi import EntityInfo, EntityState, InfraredCapability, InfraredInfo
from aioesphomeapi.client import InfraredRFReceiveEventModel
from aioesphomeapi import EntityState, InfraredCapability, InfraredInfo
from homeassistant.components.infrared import (
InfraredCommand,
InfraredEmitterEntity,
InfraredReceivedSignal,
InfraredReceiverEntity,
)
from homeassistant.core import CALLBACK_TYPE, callback
from homeassistant.components.infrared import InfraredCommand, InfraredEmitterEntity
from homeassistant.core import 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]):
"""Common base for ESPHome infrared entities."""
class EsphomeInfraredEntity(
EsphomeEntity[InfraredInfo, EntityState], InfraredEmitterEntity
):
"""ESPHome infrared emitter entity using native API."""
@callback
def _on_device_update(self) -> None:
@@ -38,10 +32,6 @@ class _EsphomeInfraredEntity(EsphomeEntity[InfraredInfo, EntityState]):
# 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."""
@@ -56,77 +46,10 @@ class EsphomeInfraredEmitterEntity(_EsphomeInfraredEntity, InfraredEmitterEntity
)
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(
async_setup_entry = partial(
platform_async_setup_entry,
info_type=InfraredInfo,
entity_type=_make_infrared_entity,
entity_type=EsphomeInfraredEntity,
state_type=EntityState,
info_filter=lambda info: bool(
info.capabilities
& (InfraredCapability.TRANSMITTER | InfraredCapability.RECEIVER)
),
info_filter=lambda info: bool(info.capabilities & InfraredCapability.TRANSMITTER),
)
@@ -289,12 +289,10 @@ 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)
except (HomeAssistantError, FileNotFoundError) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="core_config_reload_failed",
translation_placeholders={"error": str(err)},
) from err
# pylint: disable-next=home-assistant-action-swallowed-exception
except HomeAssistantError as err:
_LOGGER.error(err)
return
# auth only processed during startup
await core_config.async_process_ha_core_config(hass, conf.get(DOMAIN) or {})
@@ -183,12 +183,10 @@ async def async_setup_platform(
"""Reload the scene config."""
try:
config = await conf_util.async_hass_config_yaml(hass)
except (HomeAssistantError, FileNotFoundError) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="scene_config_reload_failed",
translation_placeholders={"error": str(err)},
) from err
# pylint: disable-next=home-assistant-action-swallowed-exception
except HomeAssistantError as err:
_LOGGER.error(err)
return
integration = await async_get_integration(hass, SCENE_DOMAIN)
@@ -21,9 +21,6 @@
"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."
},
@@ -51,9 +48,6 @@
"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,7 +116,6 @@ 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,16 +612,6 @@ 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,9 +295,6 @@
"main_current": {
"name": "Main current"
},
"main_mos_temperature": {
"name": "Main MOS temperature"
},
"main_serial_number": {
"name": "Main serial number"
},
@@ -27,11 +27,6 @@ _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]
@@ -62,7 +57,6 @@ 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.4.0"]
"requirements": ["inkbird-ble==1.2.3"]
}
+1 -3
View File
@@ -117,9 +117,7 @@ async def async_setup_entry(
INKBIRDBluetoothSensorEntity, async_add_entities
)
)
entry.async_on_unload(
entry.runtime_data.async_register_processor(processor, SensorEntityDescription)
)
entry.async_on_unload(entry.runtime_data.async_register_processor(processor))
class INKBIRDBluetoothSensorEntity(
+1 -3
View File
@@ -116,9 +116,7 @@ async def async_setup_entry(
KegtronBluetoothSensorEntity, async_add_entities
)
)
entry.async_on_unload(
coordinator.async_register_processor(processor, SensorEntityDescription)
)
entry.async_on_unload(coordinator.async_register_processor(processor))
class KegtronBluetoothSensorEntity(
+1 -3
View File
@@ -111,9 +111,7 @@ async def async_setup_entry(
MoatBluetoothSensorEntity, async_add_entities
)
)
entry.async_on_unload(
coordinator.async_register_processor(processor, SensorEntityDescription)
)
entry.async_on_unload(coordinator.async_register_processor(processor))
class MoatBluetoothSensorEntity(
+1 -3
View File
@@ -123,9 +123,7 @@ async def async_setup_entry(
MopekaBluetoothSensorEntity, async_add_entities
)
)
entry.async_on_unload(
coordinator.async_register_processor(processor, SensorEntityDescription)
)
entry.async_on_unload(coordinator.async_register_processor(processor))
class MopekaBluetoothSensorEntity(
+1 -1
View File
@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["icmplib"],
"quality_scale": "internal",
"requirements": ["icmplib==3.0.4"]
"requirements": ["icmplib==3.0"]
}
+78 -74
View File
@@ -27,15 +27,6 @@ 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."""
@@ -94,6 +85,22 @@ 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,
@@ -103,18 +110,18 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity):
super().__init__(coordinator, device_id)
self._attr_unique_id = f"{device_id}-climate"
self._api = coordinator.api
gateway_id: str = self._api.gateway_id
gateway_id: str = coordinator.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._api.cooling_present and self._api.smile.name != "Adam":
if (
self.coordinator.api.cooling_present
and coordinator.api.smile.name != "Adam"
):
self._attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
)
@@ -133,18 +140,10 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity):
self.device["thermostat"]["resolution"], 0.1
)
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 current_temperature(self) -> float:
"""Return the current temperature."""
return self.device["sensors"]["temperature"]
@property
def extra_restore_state_data(self) -> PlugwiseClimateExtraStoredData:
@@ -154,11 +153,6 @@ 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.
@@ -203,7 +197,7 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity):
if self.device.get("available_schedules"):
hvac_modes.append(HVACMode.AUTO)
if self._api.cooling_present:
if self.coordinator.api.cooling_present:
if "regulation_modes" in self._gateway_data:
if "heating" in self._gateway_data["regulation_modes"]:
hvac_modes.append(HVACMode.HEAT)
@@ -253,69 +247,79 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity):
if mode := kwargs.get(ATTR_HVAC_MODE):
await self.async_set_hvac_mode(mode)
await self._api.set_temperature(self._location, data)
await self.coordinator.api.set_temperature(self._location, data)
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.
"""
def _regulation_mode_for_hvac(self, hvac_mode: HVACMode) -> str | None:
"""Return the API regulation value for a manual HVAC mode, or None."""
if hvac_mode == HVACMode.HEAT:
mode = HVACAction.HEATING.value
return HVACAction.HEATING.value
if hvac_mode == HVACMode.COOL:
mode = HVACAction.COOLING.value
return mode
return HVACAction.COOLING.value
return None
@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
# Adam only: set to HVACMode.OFF
api = self.coordinator.api
current_schedule = self.device.get("select_schedule")
# OFF: single API call
if hvac_mode == HVACMode.OFF:
await self._api.set_regulation_mode(hvac_mode.value)
await api.set_regulation_mode(hvac_mode.value)
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 (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
# Transition to manual mode
if schedule_is_active:
await self._api.set_schedule_state(
# 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(
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
# Common - transition from auto = schedule off
if self.hvac_mode == HVACMode.AUTO:
await self._api.set_schedule_state(
self._location, STATE_OFF, current_schedule
# 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,
)
self._last_active_schedule = current_schedule
return
# 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)
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)
@plugwise_command
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode."""
await self._api.set_preset(self._location, preset_mode)
await self.coordinator.api.set_preset(self._location, preset_mode)
+1 -3
View File
@@ -105,9 +105,7 @@ async def async_setup_entry(
RAPTPillBluetoothSensorEntity, async_add_entities
)
)
entry.async_on_unload(
coordinator.async_register_processor(processor, SensorEntityDescription)
)
entry.async_on_unload(coordinator.async_register_processor(processor))
class RAPTPillBluetoothSensorEntity(
@@ -207,9 +207,7 @@ async def async_setup_entry(
RuuvitagBluetoothSensorEntity, async_add_entities
)
)
entry.async_on_unload(
coordinator.async_register_processor(processor, SensorEntityDescription)
)
entry.async_on_unload(coordinator.async_register_processor(processor))
class RuuvitagBluetoothSensorEntity(
@@ -112,9 +112,7 @@ async def async_setup_entry(
SensirionBluetoothSensorEntity, async_add_entities
)
)
entry.async_on_unload(
coordinator.async_register_processor(processor, SensorEntityDescription)
)
entry.async_on_unload(coordinator.async_register_processor(processor))
class SensirionBluetoothSensorEntity(
+1 -3
View File
@@ -123,9 +123,7 @@ async def async_setup_entry(
SensorProBluetoothSensorEntity, async_add_entities
)
)
entry.async_on_unload(
coordinator.async_register_processor(processor, SensorEntityDescription)
)
entry.async_on_unload(coordinator.async_register_processor(processor))
class SensorProBluetoothSensorEntity(
+1 -1
View File
@@ -12,7 +12,7 @@
"quality_scale": "bronze",
"requirements": [
"defusedxml==0.7.1",
"soco==0.31.1",
"soco==0.30.15",
"sonos-websocket==0.1.3"
],
"ssdp": [
@@ -119,9 +119,7 @@ async def async_setup_entry(
ThermoBeaconBluetoothSensorEntity, async_add_entities
)
)
entry.async_on_unload(
coordinator.async_register_processor(processor, SensorEntityDescription)
)
entry.async_on_unload(coordinator.async_register_processor(processor))
class ThermoBeaconBluetoothSensorEntity(
+1 -3
View File
@@ -92,9 +92,7 @@ async def async_setup_entry(
TiltBluetoothSensorEntity, async_add_entities
)
)
entry.async_on_unload(
coordinator.async_register_processor(processor, SensorEntityDescription)
)
entry.async_on_unload(coordinator.async_register_processor(processor))
class TiltBluetoothSensorEntity(
+1 -1
View File
@@ -9,5 +9,5 @@
"iot_class": "local_push",
"loggers": ["aiounifi"],
"quality_scale": "silver",
"requirements": ["aiounifi==91"]
"requirements": ["aiounifi==90"]
}
@@ -519,11 +519,7 @@ async def async_setup_entry(
VictronBLESensorEntity, async_add_entities
)
)
entry.async_on_unload(
coordinator.async_register_processor(
processor, VictronBLESensorEntityDescription
)
)
entry.async_on_unload(coordinator.async_register_processor(processor))
class VictronBLESensorEntity(PassiveBluetoothProcessorEntity, SensorEntity):
+6 -6
View File
@@ -1,10 +1,10 @@
# Automatically generated by gen_requirements_all.py, do not edit
aiodhcpwatcher==1.2.7
aiodhcpwatcher==1.2.6
aiodiscover==3.2.3
aiodns==4.0.4
aiogithubapi==26.0.0
aiohttp-asyncmdnsresolver==0.2.0
aiohttp-asyncmdnsresolver==0.1.1
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.3.0
bluetooth-adapters==2.2.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.11
dbus-fast==5.0.9
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.4
habluetooth==6.7.3
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.9
ulid-transform==2.2.1
urllib3>=2.0
uv==0.11.14
voluptuous-openapi==0.3.0
-20
View File
@@ -99,7 +99,6 @@ 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
@@ -341,22 +340,3 @@ 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).
@@ -1,104 +0,0 @@
"""Checker for direct calls to ``async_setup`` from tests.
Tests should not invoke an integration's ``async_setup`` directly.
Instead, tests should let Home Assistant perform the setup via the
normal pipeline:
* For integrations with config entries, add a ``MockConfigEntry`` and
call ``await hass.config_entries.async_setup(entry.entry_id)``.
* For integrations without config entries (system integrations), use
``await async_setup_component(hass, DOMAIN, {...})`` from
``homeassistant.setup``.
This checker flags any ``await <something>.async_setup(...)`` or
``await async_setup(...)`` call in a test module whose target resolves
to a function defined in an integration's ``__init__`` module under
``homeassistant.components.*``.
"""
import astroid
from astroid import nodes
from pylint.checkers import BaseChecker
from pylint.lint import PyLinter
from pylint_home_assistant.helpers.module_info import is_test_module, parse_module
def _is_integration_async_setup(call: nodes.Call) -> bool:
"""Return True if *call* targets an integration's ``async_setup``."""
func = call.func
if isinstance(func, nodes.Attribute):
if func.attrname != "async_setup":
return False
elif isinstance(func, nodes.Name):
if func.name != "async_setup":
return False
else:
return False
try:
inferred_values = list(func.infer())
except astroid.InferenceError, astroid.AstroidError:
return False
seen_qnames: set[str] = set()
for inferred in inferred_values:
if inferred is astroid.Uninferable:
continue
if not isinstance(inferred, (nodes.FunctionDef, nodes.AsyncFunctionDef)):
continue
qname = inferred.qname()
if not qname or qname in seen_qnames:
continue
seen_qnames.add(qname)
# qname is the function's fully-qualified name, e.g.
# ``homeassistant.components.sun.async_setup``. Strip the function
# name to get the module and parse it.
module_qname = qname.rsplit(".", 1)[0]
parsed = parse_module(module_qname)
if parsed is None:
continue
# ``async_setup`` lives in the integration's ``__init__``.
if parsed.module is None:
return True
return False
class DirectAsyncSetup(BaseChecker):
"""Checker for direct calls to async_setup in tests."""
name = "home_assistant_tests_direct_async_setup"
priority = -1
msgs = {
"W7422": (
"Do not call `async_setup` directly from tests; set up a "
"`MockConfigEntry` via `hass.config_entries.async_setup` or use "
"`async_setup_component` instead",
"home-assistant-tests-direct-async-setup",
"Used when a test module calls an integration's `async_setup` "
"directly. Tests should let Home Assistant drive the setup so "
"the full setup pipeline is exercised.",
),
}
options = ()
_in_test_module: bool = False
def visit_module(self, node: nodes.Module) -> None:
"""Record whether the current module is a test module."""
self._in_test_module = is_test_module(node.name)
def visit_call(self, node: nodes.Call) -> None:
"""Flag direct calls to an integration's async_setup."""
if not self._in_test_module:
return
if _is_integration_async_setup(node):
self.add_message(
"home-assistant-tests-direct-async-setup",
node=node,
)
def register(linter: PyLinter) -> None:
"""Register the checker."""
linter.register_checker(DirectAsyncSetup(linter))
+2 -2
View File
@@ -31,7 +31,7 @@ dependencies = [
"aiohttp==3.13.5",
"aiohttp_cors==0.8.1",
"aiohttp-fast-zlib==0.3.0",
"aiohttp-asyncmdnsresolver==0.2.0",
"aiohttp-asyncmdnsresolver==0.1.1",
"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.9",
"ulid-transform==2.2.1",
"urllib3>=2.0",
"uv==0.11.14",
"voluptuous==0.15.2",
+2 -2
View File
@@ -5,7 +5,7 @@
# Home Assistant Core
aiodns==4.0.4
aiogithubapi==26.0.0
aiohttp-asyncmdnsresolver==0.2.0
aiohttp-asyncmdnsresolver==0.1.1
aiohttp-fast-zlib==0.3.0
aiohttp==3.13.5
aiohttp_cors==0.8.1
@@ -53,7 +53,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.9
ulid-transform==2.2.1
urllib3>=2.0
uv==0.11.14
voluptuous-openapi==0.3.0
+8 -8
View File
@@ -233,7 +233,7 @@ aiocentriconnect==0.2.3
aiocomelit==2.0.3
# homeassistant.components.dhcp
aiodhcpwatcher==1.2.7
aiodhcpwatcher==1.2.6
# homeassistant.components.dhcp
aiodiscover==3.2.3
@@ -438,7 +438,7 @@ aiotedee==0.3.0
aiotractive==1.0.3
# homeassistant.components.unifi
aiounifi==91
aiounifi==90
# homeassistant.components.usb
aiousbwatcher==1.1.2
@@ -675,7 +675,7 @@ bluecurrent-api==1.3.2
bluemaestro-ble==0.4.1
# homeassistant.components.bluetooth
bluetooth-adapters==2.3.0
bluetooth-adapters==2.2.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.11
dbus-fast==5.0.9
# 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.4
habluetooth==6.7.3
# 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.4
icmplib==3.0
# 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.4.0
inkbird-ble==1.2.3
# homeassistant.components.insteon
insteon-frontend-home-assistant==0.6.2
@@ -3018,7 +3018,7 @@ smart-meter-texas==0.5.5
snapcast==2.3.7
# homeassistant.components.sonos
soco==0.31.1
soco==0.30.15
# homeassistant.components.solaredge_local
solaredge-local==0.2.3
+3 -15
View File
@@ -140,7 +140,6 @@ 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]:
@@ -150,15 +149,12 @@ 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, status: int = 200
) -> AiohttpClientMockResponse:
def _text_response(url: URL, text: str) -> AiohttpClientMockResponse:
return AiohttpClientMockResponse(
"post",
url,
text=text,
headers={"Content-Type": "text/plain"},
status=status,
)
async def _param_cgi_response(
@@ -176,9 +172,7 @@ def fixture_request(
if group == "root.Output":
return _text_response(url, PORTS_RESPONSE)
if group == "root.Properties":
return _text_response(
url, param_properties_payload, param_properties_status_code
)
return _text_response(url, param_properties_payload)
if group == "root.PTZ":
return _text_response(url, PTZ_RESPONSE)
if group == "root.StreamProfile":
@@ -282,15 +276,9 @@ 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:
"""MQTT status code."""
"""Property parameter data."""
return 200
+1 -58
View File
@@ -36,7 +36,7 @@ from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import API_DISCOVERY_BASIC_DEVICE_INFO, DEFAULT_HOST, MAC, MODEL, NAME
from .const import DEFAULT_HOST, MAC, MODEL, NAME
from tests.common import MockConfigEntry
@@ -146,63 +146,6 @@ 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,6 +1,5 @@
"""Test bluetooth diagnostics."""
import sys
from unittest.mock import ANY, MagicMock, patch
from bleak.backends.scanner import AdvertisementData, BLEDevice
@@ -44,7 +43,6 @@ 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(
@@ -257,7 +255,7 @@ async def test_diagnostics(
"type": "FakeHaScanner",
"current_mode": {
"__type": "<enum 'BluetoothScanningMode'>",
"repr": "<BluetoothScanningMode.PASSIVE: 'passive'>",
"repr": "<BluetoothScanningMode.AUTO: 'auto'>",
},
"requested_mode": {
"__type": "<enum 'BluetoothScanningMode'>",
@@ -327,10 +325,8 @@ 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,
@@ -350,10 +346,8 @@ 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,
@@ -11,6 +11,7 @@ from homeassistant.components.device_tracker import (
ATTR_IP,
ATTR_MAC,
ATTR_SOURCE_TYPE,
CONF_ASSOCIATED_ZONE,
DOMAIN,
SourceType,
)
@@ -35,7 +36,11 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers import (
device_registry as dr,
entity_registry as er,
issue_registry as ir,
)
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -893,6 +898,322 @@ async def test_base_scanner_entity_in_zones_when_connected(
}
@pytest.mark.parametrize("unique_id", ["unique_scanner"])
async def test_base_scanner_entity_associated_zone_option(
hass: HomeAssistant,
config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
entity_id: str,
base_scanner_entity: MockBaseScannerEntity,
) -> None:
"""Test the associated_zone entity option overrides which zone in_zones reports.
The scanner reports being connected to a non-default zone; state and in_zones
must follow the configured zone, and a zone enclosing the configured one is
included in in_zones too.
"""
hass.states.async_set(
"zone.home",
"0",
{ATTR_LATITUDE: 50.0, ATTR_LONGITUDE: 60.0, ATTR_RADIUS: 1000},
)
hass.states.async_set(
"zone.kitchen",
"0",
{ATTR_LATITUDE: 50.0, ATTR_LONGITUDE: 60.0, ATTR_RADIUS: 50},
)
await hass.async_block_till_done()
base_scanner_entity._connected = True
config_entry = await create_mock_platform(hass, config_entry, [base_scanner_entity])
assert config_entry.state is ConfigEntryState.LOADED
# Default: no option set -> associated with zone.home.
entity_state = hass.states.get(entity_id)
assert entity_state
assert entity_state.state == STATE_HOME
assert entity_state.attributes[ATTR_IN_ZONES] == ["zone.home"]
# Set the option -> associated_zone replaces zone.home; zone.home now shows
# up via the enclosing-zones lookup.
entity_registry.async_update_entity_options(
entity_id,
DOMAIN,
{CONF_ASSOCIATED_ZONE: "zone.kitchen"},
)
await hass.async_block_till_done()
assert base_scanner_entity._scanner_option_associated_zone == "zone.kitchen"
entity_state = hass.states.get(entity_id)
assert entity_state
# zone.kitchen is the configured zone -> state is the zone's name.
assert entity_state.state == "kitchen"
assert entity_state.attributes[ATTR_IN_ZONES] == ["zone.kitchen", "zone.home"]
# Clearing the option falls back to zone.home.
entity_registry.async_update_entity_options(entity_id, DOMAIN, None)
await hass.async_block_till_done()
assert base_scanner_entity._scanner_option_associated_zone == "zone.home"
entity_state = hass.states.get(entity_id)
assert entity_state
assert entity_state.state == STATE_HOME
assert entity_state.attributes[ATTR_IN_ZONES] == ["zone.home"]
@pytest.mark.parametrize("unique_id", ["unique_scanner"])
async def test_base_scanner_entity_associated_zone_removed_after_set(
hass: HomeAssistant,
config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
issue_registry: ir.IssueRegistry,
entity_id: str,
base_scanner_entity: MockBaseScannerEntity,
) -> None:
"""Test scanner state and repair issue when associated zone is removed.
When the user picks a zone via the associated_zone option and then deletes
that zone, the scanner falls back to ``state == "unknown"`` and a repair
issue is opened prompting the user to reconfigure.
"""
hass.states.async_set(
"zone.home",
"0",
{ATTR_LATITUDE: 50.0, ATTR_LONGITUDE: 60.0, ATTR_RADIUS: 1000},
)
hass.states.async_set(
"zone.kitchen",
"0",
{ATTR_LATITUDE: 50.0, ATTR_LONGITUDE: 60.0, ATTR_RADIUS: 50},
)
await hass.async_block_till_done()
base_scanner_entity._connected = True
config_entry = await create_mock_platform(hass, config_entry, [base_scanner_entity])
assert config_entry.state is ConfigEntryState.LOADED
entity_registry.async_update_entity_options(
entity_id,
DOMAIN,
{CONF_ASSOCIATED_ZONE: "zone.kitchen"},
)
await hass.async_block_till_done()
# Sanity check before removal.
entity_state = hass.states.get(entity_id)
assert entity_state
assert entity_state.state == "kitchen"
assert entity_state.attributes[ATTR_IN_ZONES] == ["zone.kitchen", "zone.home"]
issue_id = f"associated_zone_missing_{entity_id}"
assert issue_registry.async_get_issue(DOMAIN, issue_id) is None
# Remove the associated zone.
hass.states.async_remove("zone.kitchen")
await hass.async_block_till_done()
entity_state = hass.states.get(entity_id)
assert entity_state
assert entity_state.state == STATE_UNKNOWN
assert entity_state.attributes[ATTR_IN_ZONES] == []
issue = issue_registry.async_get_issue(DOMAIN, issue_id)
assert issue is not None
assert issue.severity is ir.IssueSeverity.WARNING
assert issue.translation_key == "associated_zone_missing"
assert issue.translation_placeholders == {
"entity_id": entity_id,
"zone": "zone.kitchen",
}
# Restore the zone -> issue is cleared, state recovers.
hass.states.async_set(
"zone.kitchen",
"0",
{ATTR_LATITUDE: 50.0, ATTR_LONGITUDE: 60.0, ATTR_RADIUS: 50},
)
await hass.async_block_till_done()
entity_state = hass.states.get(entity_id)
assert entity_state
assert entity_state.state == "kitchen"
assert entity_state.attributes[ATTR_IN_ZONES] == ["zone.kitchen", "zone.home"]
assert issue_registry.async_get_issue(DOMAIN, issue_id) is None
@pytest.mark.parametrize("unique_id", ["unique_scanner"])
async def test_base_scanner_entity_associated_zone_missing_at_setup(
hass: HomeAssistant,
config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
issue_registry: ir.IssueRegistry,
entity_id: str,
base_scanner_entity: MockBaseScannerEntity,
) -> None:
"""Test repair issue is created when the configured zone is missing at setup."""
hass.states.async_set(
"zone.home",
"0",
{ATTR_LATITUDE: 50.0, ATTR_LONGITUDE: 60.0, ATTR_RADIUS: 1000},
)
await hass.async_block_till_done()
# Pre-register the entity option pointing at a zone that does not exist.
entity_registry.async_get_or_create(
DOMAIN,
TEST_DOMAIN,
base_scanner_entity.unique_id,
suggested_object_id="entity1",
)
entity_registry.async_update_entity_options(
entity_id,
DOMAIN,
{CONF_ASSOCIATED_ZONE: "zone.never_existed"},
)
base_scanner_entity._connected = True
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_UNKNOWN
assert entity_state.attributes[ATTR_IN_ZONES] == []
issue_id = f"associated_zone_missing_{entity_id}"
issue = issue_registry.async_get_issue(DOMAIN, issue_id)
assert issue is not None
assert issue.translation_placeholders == {
"entity_id": entity_id,
"zone": "zone.never_existed",
}
@pytest.mark.parametrize("unique_id", ["unique_scanner"])
async def test_base_scanner_entity_associated_zone_issue_cleared_on_option_change(
hass: HomeAssistant,
config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
issue_registry: ir.IssueRegistry,
entity_id: str,
base_scanner_entity: MockBaseScannerEntity,
) -> None:
"""Test the repair issue is cleared when the user clears the option."""
hass.states.async_set(
"zone.home",
"0",
{ATTR_LATITUDE: 50.0, ATTR_LONGITUDE: 60.0, ATTR_RADIUS: 1000},
)
await hass.async_block_till_done()
base_scanner_entity._connected = True
config_entry = await create_mock_platform(hass, config_entry, [base_scanner_entity])
assert config_entry.state is ConfigEntryState.LOADED
entity_registry.async_update_entity_options(
entity_id,
DOMAIN,
{CONF_ASSOCIATED_ZONE: "zone.never_existed"},
)
await hass.async_block_till_done()
issue_id = f"associated_zone_missing_{entity_id}"
assert issue_registry.async_get_issue(DOMAIN, issue_id) is not None
# Clearing the option restores the default and clears the repair issue.
entity_registry.async_update_entity_options(entity_id, DOMAIN, None)
await hass.async_block_till_done()
assert issue_registry.async_get_issue(DOMAIN, issue_id) is None
entity_state = hass.states.get(entity_id)
assert entity_state
assert entity_state.state == STATE_HOME
@pytest.mark.parametrize("unique_id", ["unique_scanner"])
async def test_base_scanner_entity_associated_zone_issue_cleared_on_unload(
hass: HomeAssistant,
config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
issue_registry: ir.IssueRegistry,
entity_id: str,
base_scanner_entity: MockBaseScannerEntity,
) -> None:
"""Test the repair issue is cleared when the entity is removed from hass."""
hass.states.async_set(
"zone.home",
"0",
{ATTR_LATITUDE: 50.0, ATTR_LONGITUDE: 60.0, ATTR_RADIUS: 1000},
)
await hass.async_block_till_done()
base_scanner_entity._connected = True
config_entry = await create_mock_platform(hass, config_entry, [base_scanner_entity])
assert config_entry.state is ConfigEntryState.LOADED
entity_registry.async_update_entity_options(
entity_id,
DOMAIN,
{CONF_ASSOCIATED_ZONE: "zone.never_existed"},
)
await hass.async_block_till_done()
issue_id = f"associated_zone_missing_{entity_id}"
assert issue_registry.async_get_issue(DOMAIN, issue_id) is not None
assert await hass.config_entries.async_unload(config_entry.entry_id)
await hass.async_block_till_done()
assert issue_registry.async_get_issue(DOMAIN, issue_id) is None
@pytest.mark.parametrize("unique_id", ["unique_scanner"])
async def test_base_scanner_entity_associated_zone_option_set_before_add(
hass: HomeAssistant,
config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
entity_id: str,
base_scanner_entity: MockBaseScannerEntity,
) -> None:
"""Test associated_zone option set before the entity is added is honored."""
hass.states.async_set(
"zone.home",
"0",
{ATTR_LATITUDE: 50.0, ATTR_LONGITUDE: 60.0, ATTR_RADIUS: 1000},
)
hass.states.async_set(
"zone.kitchen",
"0",
{ATTR_LATITUDE: 50.0, ATTR_LONGITUDE: 60.0, ATTR_RADIUS: 50},
)
await hass.async_block_till_done()
# Pre-register the entity with the option set before the platform is set up.
entity_registry.async_get_or_create(
DOMAIN,
TEST_DOMAIN,
base_scanner_entity.unique_id,
suggested_object_id="entity1",
)
entity_registry.async_update_entity_options(
entity_id,
DOMAIN,
{CONF_ASSOCIATED_ZONE: "zone.kitchen"},
)
base_scanner_entity._connected = True
config_entry = await create_mock_platform(hass, config_entry, [base_scanner_entity])
assert config_entry.state is ConfigEntryState.LOADED
assert base_scanner_entity._scanner_option_associated_zone == "zone.kitchen"
entity_state = hass.states.get(entity_id)
assert entity_state
assert entity_state.state == "kitchen"
assert entity_state.attributes[ATTR_IN_ZONES] == ["zone.kitchen", "zone.home"]
@pytest.mark.parametrize(
("ip_address", "mac_address", "hostname"),
[("0.0.0.0", "ad:de:ef:be:ed:fe", "test.hostname.org")],
@@ -425,7 +425,6 @@ 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)
+22 -114
View File
@@ -6,17 +6,10 @@ 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
@@ -40,47 +33,32 @@ async def _mock_ir_device(
@pytest.mark.parametrize(
("capabilities", "expected_device_class", "emitter_count", "receiver_count"),
("capabilities", "entity_created"),
[
pytest.param(
InfraredCapability.TRANSMITTER,
InfraredDeviceClass.EMITTER,
1,
0,
id="transmitter",
),
pytest.param(
InfraredCapability.RECEIVER,
InfraredDeviceClass.RECEIVER,
0,
1,
id="receiver",
),
(InfraredCapability.TRANSMITTER, True),
(InfraredCapability.RECEIVER, False),
(InfraredCapability.TRANSMITTER | InfraredCapability.RECEIVER, True),
(InfraredCapability(0), False),
],
)
async def test_infrared_entity_single_capability(
async def test_infrared_entity_transmitter(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
capabilities: InfraredCapability,
expected_device_class: InfraredDeviceClass,
emitter_count: int,
receiver_count: int,
entity_created: bool,
) -> None:
"""Test infrared entity is created with the right device class per capability."""
"""Test infrared entity with transmitter capability is created."""
await _mock_ir_device(mock_esphome_device, mock_client, capabilities)
state = hass.states.get(ENTITY_ID)
assert (state is not None) == (expected_device_class is not None)
assert state.attributes["device_class"] == expected_device_class
assert (state is not None) == entity_created
emitters = infrared.async_get_emitters(hass)
assert len(emitters) == emitter_count
receivers = infrared.async_get_receivers(hass)
assert len(receivers) == receiver_count
assert (len(emitters) == 1) == entity_created
async def test_infrared_entity_dual_capability(
async def test_infrared_multiple_entities_mixed_capabilities(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
@@ -99,6 +77,12 @@ async def test_infrared_entity_dual_capability(
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,
@@ -106,18 +90,13 @@ async def test_infrared_entity_dual_capability(
states=[],
)
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
# 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
emitters = infrared.async_get_emitters(hass)
assert len(emitters) == 1
receivers = infrared.async_get_receivers(hass)
assert len(receivers) == 1
assert len(emitters) == 2
async def test_infrared_send_command_success(
@@ -167,77 +146,6 @@ 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,
+4 -26
View File
@@ -132,40 +132,18 @@ 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,
files_patch: dict[str, str],
expected_error: str,
mock_process, mock_error, hass: HomeAssistant
) -> 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_patch, True),
pytest.raises(
HomeAssistantError,
match=(
"Failed to reload the Home Assistant Core configuration - "
f"{expected_error}"
),
),
):
with patch_yaml_files(files, True):
await hass.services.async_call(
ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG, blocking=True
)
assert mock_error.called is False
assert mock_error.called
assert mock_process.called is False
+2 -34
View File
@@ -4,17 +4,15 @@ 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 HomeAssistantError, ServiceValidationError
from homeassistant.exceptions import ServiceValidationError
from homeassistant.setup import async_setup_component
from tests.common import async_capture_events, async_mock_service, patch_yaml_files
from tests.common import async_capture_events, async_mock_service
async def test_reload_config_service(hass: HomeAssistant) -> None:
@@ -48,36 +46,6 @@ 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,7 +66,6 @@
'11011': 85,
'11016': 0,
'11034': 100,
'11042': 32.1,
'142': 1.79,
'1501': 0,
'1502': 0,
@@ -5641,64 +5641,6 @@
'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,276 +0,0 @@
{
"056ee145a816487eaa69243c3280f8bf": {
"available": true,
"binary_sensors": {
"dhw_state": false,
"flame_state": false,
"heating_state": false
},
"dev_class": "heater_central",
"location": "bc93488efab249e5bc54fd7e175a6f91",
"max_dhw_temperature": {
"lower_bound": 40.0,
"resolution": 0.01,
"setpoint": 60.0,
"upper_bound": 60.0
},
"maximum_boiler_temperature": {
"lower_bound": 25.0,
"resolution": 0.01,
"setpoint": 50.0,
"upper_bound": 95.0
},
"model": "Generic heater",
"name": "OpenTherm",
"sensors": {
"intended_boiler_temperature": 0.0,
"water_temperature": 37.0
},
"switches": {
"dhw_cm_switch": false
}
},
"14df5c4dc8cb4ba69f9d1ac0eaf7c5c6": {
"available": true,
"binary_sensors": {
"low_battery": false
},
"dev_class": "zone_thermostat",
"firmware": "2025-11-10T01:00:00+01:00",
"hardware": "1",
"location": "f2bf9048bef64cc5b6d5110154e33c81",
"model": "Emma Pro",
"model_id": "170-01",
"name": "Emma",
"sensors": {
"battery": 100,
"humidity": 65.0,
"setpoint": 20.0,
"temperature": 19.5
},
"temperature_offset": {
"lower_bound": -2.0,
"resolution": 0.1,
"setpoint": 0.0,
"upper_bound": 2.0
},
"vendor": "Plugwise",
"zigbee_mac_address": "60EFABFFFE89CBA0"
},
"1772a4ea304041adb83f357b751341ff": {
"available": true,
"binary_sensors": {
"low_battery": false
},
"dev_class": "thermostatic_radiator_valve",
"firmware": "2020-11-04T01:00:00+01:00",
"hardware": "1",
"location": "f871b8c4d63549319221e294e4f88074",
"model": "Tom",
"model_id": "106-03",
"name": "Tom Badkamer",
"sensors": {
"battery": 60,
"setpoint": 25.0,
"temperature": 18.6,
"temperature_difference": -0.4,
"valve_position": 100.0
},
"temperature_offset": {
"lower_bound": -2.0,
"resolution": 0.1,
"setpoint": 0.1,
"upper_bound": 2.0
},
"vendor": "Plugwise",
"zigbee_mac_address": "000D6F000C8FCBA0"
},
"ad4838d7d35c4d6ea796ee12ae5aedf8": {
"dev_class": "thermostat",
"location": "f2bf9048bef64cc5b6d5110154e33c81",
"model": "ThermoTouch",
"model_id": "143.1",
"name": "Anna",
"sensors": {
"setpoint": 20.0,
"temperature": 19.1
},
"vendor": "Plugwise"
},
"c9293d1d68ee48fc8843c6f0dee2b6be": {
"dev_class": "pumping",
"members": [
"854f8a9b0e7e425db97f1f110e1ce4b3",
"ad4838d7d35c4d6ea796ee12ae5aedf8"
],
"model": "Group",
"name": "Vloerverwarming",
"sensors": {
"electricity_consumed": 45.0,
"electricity_produced": 0.0,
"temperature": 20.1
},
"vendor": "Plugwise"
},
"da224107914542988a88561b4452b0f6": {
"binary_sensors": {
"plugwise_notification": false
},
"dev_class": "gateway",
"firmware": "3.9.0",
"gateway_modes": ["away", "full", "vacation"],
"hardware": "AME Smile 2.0 board",
"location": "bc93488efab249e5bc54fd7e175a6f91",
"mac_address": "D40FB201CBA0",
"model": "Gateway",
"model_id": "smile_open_therm",
"name": "Adam",
"notifications": {},
"regulation_modes": ["bleeding_cold", "heating", "off", "bleeding_hot"],
"select_gateway_mode": "full",
"select_regulation_mode": "off",
"sensors": {
"outdoor_temperature": -1.25
},
"vendor": "Plugwise",
"zigbee_mac_address": "000D6F000D5ACBA0"
},
"da575e9e09b947e281fb6e3ebce3b174": {
"available": true,
"binary_sensors": {
"low_battery": false
},
"dev_class": "zone_thermometer",
"firmware": "2020-09-01T02:00:00+02:00",
"hardware": "1",
"location": "f2bf9048bef64cc5b6d5110154e33c81",
"model": "Jip",
"model_id": "168-01",
"name": "Jip",
"sensors": {
"battery": 100,
"humidity": 65.8,
"setpoint": 20.0,
"temperature": 19.3
},
"vendor": "Plugwise",
"zigbee_mac_address": "70AC08FFFEE1CBA0"
},
"e2f4322d57924fa090fbbc48b3a140dc": {
"available": true,
"binary_sensors": {
"low_battery": false
},
"dev_class": "zone_thermostat",
"firmware": "2016-10-10T02:00:00+02:00",
"hardware": "255",
"location": "f871b8c4d63549319221e294e4f88074",
"model": "Lisa",
"model_id": "158-01",
"name": "Lisa Badkamer",
"sensors": {
"battery": 71,
"setpoint": 15.0,
"temperature": 17.9
},
"temperature_offset": {
"lower_bound": -2.0,
"resolution": 0.1,
"setpoint": 0.0,
"upper_bound": 2.0
},
"vendor": "Plugwise",
"zigbee_mac_address": "000D6F000C86CBA0"
},
"e8ef2a01ed3b4139a53bf749204fe6b4": {
"dev_class": "switching",
"members": [
"2568cc4b9c1e401495d4741a5f89bee1",
"29542b2b6a6a4169acecc15c72a599b8"
],
"model": "Group",
"name": "Test",
"sensors": {
"electricity_consumed": 16.5,
"electricity_produced": 0.0
},
"switches": {
"relay": true
},
"vendor": "Plugwise"
},
"f2bf9048bef64cc5b6d5110154e33c81": {
"active_preset": "home",
"available_schedules": [
"Badkamer",
"Vakantie",
"Weekschema",
"Test",
"off"
],
"climate_mode": "off",
"control_state": "idle",
"dev_class": "climate",
"model": "ThermoZone",
"name": "Living room",
"preset_modes": ["vacation", "no_frost", "asleep", "home", "away"],
"select_schedule": "off",
"select_zone_profile": "active",
"sensors": {
"electricity_consumed": 60.8,
"electricity_produced": 0.0,
"temperature": 19.1
},
"thermostat": {
"lower_bound": 1.0,
"resolution": 0.01,
"setpoint": 20.0,
"upper_bound": 35.0
},
"thermostats": {
"primary": [
"ad4838d7d35c4d6ea796ee12ae5aedf8",
"14df5c4dc8cb4ba69f9d1ac0eaf7c5c6",
"da575e9e09b947e281fb6e3ebce3b174"
],
"secondary": []
},
"vendor": "Plugwise",
"zone_profiles": ["active", "off", "passive"]
},
"f871b8c4d63549319221e294e4f88074": {
"active_preset": "vacation",
"available_schedules": [
"Badkamer",
"Vakantie",
"Weekschema",
"Test",
"off"
],
"climate_mode": "off",
"control_state": "idle",
"dev_class": "climate",
"model": "ThermoZone",
"name": "Bathroom",
"preset_modes": ["vacation", "no_frost", "asleep", "home", "away"],
"select_schedule": "Badkamer",
"select_zone_profile": "passive",
"sensors": {
"electricity_consumed": 0.0,
"electricity_produced": 0.0,
"temperature": 17.9
},
"thermostat": {
"lower_bound": 0.0,
"resolution": 0.01,
"setpoint": 15.0,
"upper_bound": 99.9
},
"thermostats": {
"primary": ["e2f4322d57924fa090fbbc48b3a140dc"],
"secondary": ["1772a4ea304041adb83f357b751341ff"]
},
"vendor": "Plugwise",
"zone_profiles": ["active", "off", "passive"]
}
}
-78
View File
@@ -275,64 +275,6 @@ 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(
@@ -619,26 +561,6 @@ async def test_anna_climate_entity_climate_changes(
"standaard",
)
data = mock_smile_anna.async_update.return_value
data["3cb70739631c4d17a86b8b12e8a5161b"]["climate_mode"] = "heat"
with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data):
freezer.tick(timedelta(minutes=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
{ATTR_ENTITY_ID: "climate.anna", ATTR_HVAC_MODE: HVACMode.AUTO},
blocking=True,
)
assert mock_smile_anna.set_schedule_state.call_count == 2
mock_smile_anna.set_schedule_state.assert_called_with(
"c784ee9fdab44e1395b8dee7d7a497d5",
STATE_ON,
"standaard",
)
# Mock user deleting last schedule from app or browser
data = mock_smile_anna.async_update.return_value
data["3cb70739631c4d17a86b8b12e8a5161b"]["available_schedules"] = []
-1
View File
@@ -114,7 +114,6 @@ MOCK_GAMES_LOCKED = {MOCK_ID: MOCK_GAMES_DATA_LOCKED}
async def test_ps4_integration_setup(hass: HomeAssistant) -> None:
"""Test PS4 integration is setup."""
# pylint: disable-next=home-assistant-tests-direct-async-setup
await ps4.async_setup(hass, {})
await hass.async_block_till_done()
assert hass.data[PS4_DATA].protocol is not None
-2
View File
@@ -19,7 +19,6 @@ 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
@@ -35,7 +34,6 @@ 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"):
@@ -1,152 +0,0 @@
"""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