diff --git a/.strict-typing b/.strict-typing index b34cbfa5fca65..68d67ae85b2f9 100644 --- a/.strict-typing +++ b/.strict-typing @@ -67,6 +67,7 @@ homeassistant.components.alert.* homeassistant.components.alexa.* homeassistant.components.alexa_devices.* homeassistant.components.alpha_vantage.* +homeassistant.components.altruist.* homeassistant.components.amazon_polly.* homeassistant.components.amberelectric.* homeassistant.components.ambient_network.* diff --git a/CODEOWNERS b/CODEOWNERS index 1ceb6ff0e7de0..9f312c77b1e5d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -93,6 +93,8 @@ build.json @home-assistant/supervisor /tests/components/alexa/ @home-assistant/cloud @ochlocracy @jbouwh /homeassistant/components/alexa_devices/ @chemelli74 /tests/components/alexa_devices/ @chemelli74 +/homeassistant/components/altruist/ @airalab @LoSk-p +/tests/components/altruist/ @airalab @LoSk-p /homeassistant/components/amazon_polly/ @jschlyter /homeassistant/components/amberelectric/ @madpilot /tests/components/amberelectric/ @madpilot @@ -1580,6 +1582,8 @@ build.json @home-assistant/supervisor /tests/components/tile/ @bachya /homeassistant/components/tilt_ble/ @apt-itude /tests/components/tilt_ble/ @apt-itude +/homeassistant/components/tilt_pi/ @michaelheyman +/tests/components/tilt_pi/ @michaelheyman /homeassistant/components/time/ @home-assistant/core /tests/components/time/ @home-assistant/core /homeassistant/components/time_date/ @fabaff diff --git a/homeassistant/brands/switchbot.json b/homeassistant/brands/switchbot.json index 0909b24a14699..43963109ee7fb 100644 --- a/homeassistant/brands/switchbot.json +++ b/homeassistant/brands/switchbot.json @@ -1,5 +1,6 @@ { "domain": "switchbot", "name": "SwitchBot", - "integrations": ["switchbot", "switchbot_cloud"] + "integrations": ["switchbot", "switchbot_cloud"], + "iot_standards": ["matter"] } diff --git a/homeassistant/brands/tilt.json b/homeassistant/brands/tilt.json new file mode 100644 index 0000000000000..0b78925780f0d --- /dev/null +++ b/homeassistant/brands/tilt.json @@ -0,0 +1,5 @@ +{ + "domain": "tilt", + "name": "Tilt", + "integrations": ["tilt_ble", "tilt_pi"] +} diff --git a/homeassistant/components/ai_task/__init__.py b/homeassistant/components/ai_task/__init__.py index 8b3d6e04966aa..7fec89f384ea5 100644 --- a/homeassistant/components/ai_task/__init__.py +++ b/homeassistant/components/ai_task/__init__.py @@ -5,6 +5,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import ( HassJobType, HomeAssistant, @@ -17,9 +18,17 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType -from .const import DATA_COMPONENT, DATA_PREFERENCES, DOMAIN, AITaskEntityFeature +from .const import ( + ATTR_INSTRUCTIONS, + ATTR_TASK_NAME, + DATA_COMPONENT, + DATA_PREFERENCES, + DOMAIN, + SERVICE_GENERATE_TEXT, + AITaskEntityFeature, +) from .entity import AITaskEntity -from .http import async_setup as async_setup_conversation_http +from .http import async_setup as async_setup_http from .task import GenTextTask, GenTextTaskResult, async_generate_text __all__ = [ @@ -45,16 +54,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.data[DATA_COMPONENT] = entity_component hass.data[DATA_PREFERENCES] = AITaskPreferences(hass) await hass.data[DATA_PREFERENCES].async_load() - async_setup_conversation_http(hass) + async_setup_http(hass) hass.services.async_register( DOMAIN, - "generate_text", + SERVICE_GENERATE_TEXT, async_service_generate_text, schema=vol.Schema( { - vol.Required("task_name"): cv.string, - vol.Optional("entity_id"): cv.entity_id, - vol.Required("instructions"): cv.string, + vol.Required(ATTR_TASK_NAME): cv.string, + vol.Optional(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_INSTRUCTIONS): cv.string, } ), supports_response=SupportsResponse.ONLY, diff --git a/homeassistant/components/ai_task/const.py b/homeassistant/components/ai_task/const.py index 6978617858354..b6058c11b45bc 100644 --- a/homeassistant/components/ai_task/const.py +++ b/homeassistant/components/ai_task/const.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import IntFlag -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Final from homeassistant.util.hass_dict import HassKey @@ -17,6 +17,11 @@ DATA_COMPONENT: HassKey[EntityComponent[AITaskEntity]] = HassKey(DOMAIN) DATA_PREFERENCES: HassKey[AITaskPreferences] = HassKey(f"{DOMAIN}_preferences") +SERVICE_GENERATE_TEXT = "generate_text" + +ATTR_INSTRUCTIONS: Final = "instructions" +ATTR_TASK_NAME: Final = "task_name" + DEFAULT_SYSTEM_PROMPT = ( "You are a Home Assistant expert and help users with their tasks." ) diff --git a/homeassistant/components/ai_task/services.yaml b/homeassistant/components/ai_task/services.yaml index 32715bf77d73b..12e3975fca665 100644 --- a/homeassistant/components/ai_task/services.yaml +++ b/homeassistant/components/ai_task/services.yaml @@ -6,7 +6,7 @@ generate_text: selector: text: instructions: - example: "Generate a funny notification that garage door was left open" + example: "Generate a funny notification that the garage door was left open" required: true selector: text: diff --git a/homeassistant/components/ai_task/strings.json b/homeassistant/components/ai_task/strings.json index 1cdbf20ba4feb..f994aaebe8e58 100644 --- a/homeassistant/components/ai_task/strings.json +++ b/homeassistant/components/ai_task/strings.json @@ -5,7 +5,7 @@ "description": "Use AI to run a task that generates text.", "fields": { "task_name": { - "name": "Task Name", + "name": "Task name", "description": "Name of the task." }, "instructions": { diff --git a/homeassistant/components/ai_task/task.py b/homeassistant/components/ai_task/task.py index d0c59fdd09aac..1ba5838d18b13 100644 --- a/homeassistant/components/ai_task/task.py +++ b/homeassistant/components/ai_task/task.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from .const import DATA_COMPONENT, DATA_PREFERENCES, AITaskEntityFeature @@ -21,14 +22,16 @@ async def async_generate_text( entity_id = hass.data[DATA_PREFERENCES].gen_text_entity_id if entity_id is None: - raise ValueError("No entity_id provided and no preferred entity set") + raise HomeAssistantError("No entity_id provided and no preferred entity set") entity = hass.data[DATA_COMPONENT].get_entity(entity_id) if entity is None: - raise ValueError(f"AI Task entity {entity_id} not found") + raise HomeAssistantError(f"AI Task entity {entity_id} not found") if AITaskEntityFeature.GENERATE_TEXT not in entity.supported_features: - raise ValueError(f"AI Task entity {entity_id} does not support generating text") + raise HomeAssistantError( + f"AI Task entity {entity_id} does not support generating text" + ) return await entity.internal_async_generate_text( GenTextTask( diff --git a/homeassistant/components/altruist/__init__.py b/homeassistant/components/altruist/__init__.py new file mode 100644 index 0000000000000..6040b347bb56d --- /dev/null +++ b/homeassistant/components/altruist/__init__.py @@ -0,0 +1,27 @@ +"""The Altruist integration.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import AltruistConfigEntry, AltruistDataUpdateCoordinator + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: AltruistConfigEntry) -> bool: + """Set up Altruist from a config entry.""" + + coordinator = AltruistDataUpdateCoordinator(hass, entry) + + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: AltruistConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/altruist/config_flow.py b/homeassistant/components/altruist/config_flow.py new file mode 100644 index 0000000000000..ec3c8f9d8f952 --- /dev/null +++ b/homeassistant/components/altruist/config_flow.py @@ -0,0 +1,107 @@ +"""Config flow for the Altruist integration.""" + +import logging +from typing import Any + +from altruistclient import AltruistClient, AltruistDeviceModel, AltruistError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from .const import CONF_HOST, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class AltruistConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Altruist.""" + + device: AltruistDeviceModel + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + ip_address = "" + if user_input is not None: + ip_address = user_input[CONF_HOST] + try: + client = await AltruistClient.from_ip_address( + async_get_clientsession(self.hass), ip_address + ) + except AltruistError: + errors["base"] = "no_device_found" + else: + self.device = client.device + await self.async_set_unique_id( + client.device_id, raise_on_progress=False + ) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=self.device.id, + data={ + CONF_HOST: ip_address, + }, + ) + + data_schema = self.add_suggested_values_to_schema( + vol.Schema({vol.Required(CONF_HOST): str}), + {CONF_HOST: ip_address}, + ) + + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors=errors, + description_placeholders={ + "ip_address": ip_address, + }, + ) + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + _LOGGER.debug("Zeroconf discovery: %s", discovery_info) + try: + client = await AltruistClient.from_ip_address( + async_get_clientsession(self.hass), str(discovery_info.ip_address) + ) + except AltruistError: + return self.async_abort(reason="no_device_found") + + self.device = client.device + _LOGGER.debug("Zeroconf device: %s", client.device) + await self.async_set_unique_id(client.device_id) + self._abort_if_unique_id_configured() + self.context.update( + { + "title_placeholders": { + "name": self.device.id, + } + } + ) + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery.""" + if user_input is not None: + return self.async_create_entry( + title=self.device.id, + data={ + CONF_HOST: self.device.ip_address, + }, + ) + + self._set_confirm_only() + return self.async_show_form( + step_id="discovery_confirm", + description_placeholders={ + "model": self.device.id, + }, + ) diff --git a/homeassistant/components/altruist/const.py b/homeassistant/components/altruist/const.py new file mode 100644 index 0000000000000..93cbbd2c53579 --- /dev/null +++ b/homeassistant/components/altruist/const.py @@ -0,0 +1,5 @@ +"""Constants for the Altruist integration.""" + +DOMAIN = "altruist" + +CONF_HOST = "host" diff --git a/homeassistant/components/altruist/coordinator.py b/homeassistant/components/altruist/coordinator.py new file mode 100644 index 0000000000000..0a537e62af681 --- /dev/null +++ b/homeassistant/components/altruist/coordinator.py @@ -0,0 +1,64 @@ +"""Coordinator module for Altruist integration in Home Assistant. + +This module defines the AltruistDataUpdateCoordinator class, which manages +data updates for Altruist sensors using the AltruistClient. +""" + +from datetime import timedelta +import logging + +from altruistclient import AltruistClient, AltruistError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_HOST + +_LOGGER = logging.getLogger(__name__) + +UPDATE_INTERVAL = timedelta(seconds=15) + +type AltruistConfigEntry = ConfigEntry[AltruistDataUpdateCoordinator] + + +class AltruistDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]): + """Coordinates data updates for Altruist sensors.""" + + client: AltruistClient + + def __init__( + self, + hass: HomeAssistant, + config_entry: AltruistConfigEntry, + ) -> None: + """Initialize the data update coordinator for Altruist sensors.""" + device_id = config_entry.unique_id + super().__init__( + hass, + logger=_LOGGER, + config_entry=config_entry, + name=f"Altruist {device_id}", + update_interval=UPDATE_INTERVAL, + ) + self._ip_address = config_entry.data[CONF_HOST] + + async def _async_setup(self) -> None: + try: + self.client = await AltruistClient.from_ip_address( + async_get_clientsession(self.hass), self._ip_address + ) + await self.client.fetch_data() + except AltruistError as e: + raise ConfigEntryNotReady("Error in Altruist setup") from e + + async def _async_update_data(self) -> dict[str, str]: + try: + fetched_data = await self.client.fetch_data() + except AltruistError as ex: + raise UpdateFailed( + f"The Altruist {self.client.device_id} is unavailable: {ex}" + ) from ex + return {item["value_type"]: item["value"] for item in fetched_data} diff --git a/homeassistant/components/altruist/icons.json b/homeassistant/components/altruist/icons.json new file mode 100644 index 0000000000000..9c012b87b6d1e --- /dev/null +++ b/homeassistant/components/altruist/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "sensor": { + "pm_10": { + "default": "mdi:thought-bubble" + }, + "pm_25": { + "default": "mdi:thought-bubble-outline" + }, + "radiation": { + "default": "mdi:radioactive" + } + } + } +} diff --git a/homeassistant/components/altruist/manifest.json b/homeassistant/components/altruist/manifest.json new file mode 100644 index 0000000000000..534830a9b705c --- /dev/null +++ b/homeassistant/components/altruist/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "altruist", + "name": "Altruist", + "codeowners": ["@airalab", "@LoSk-p"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/altruist", + "integration_type": "device", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["altruistclient==0.1.1"], + "zeroconf": ["_altruist._tcp.local."] +} diff --git a/homeassistant/components/altruist/quality_scale.yaml b/homeassistant/components/altruist/quality_scale.yaml new file mode 100644 index 0000000000000..4566ac5f6df29 --- /dev/null +++ b/homeassistant/components/altruist/quality_scale.yaml @@ -0,0 +1,83 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not provide additional actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + This integration does not provide options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + Device type integration + entity-category: todo + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No known use cases for repair issues or flows, yet + stale-devices: + status: exempt + comment: | + Device type integration + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/altruist/sensor.py b/homeassistant/components/altruist/sensor.py new file mode 100644 index 0000000000000..f02c442e5cd79 --- /dev/null +++ b/homeassistant/components/altruist/sensor.py @@ -0,0 +1,249 @@ +"""Defines the Altruist sensor platform.""" + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_MILLION, + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfPressure, + UnitOfSoundPressure, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import AltruistConfigEntry +from .coordinator import AltruistDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class AltruistSensorEntityDescription(SensorEntityDescription): + """Class to describe a Sensor entity.""" + + native_value_fn: Callable[[str], float] = float + state_class = SensorStateClass.MEASUREMENT + + +SENSOR_DESCRIPTIONS = [ + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.HUMIDITY, + key="BME280_humidity", + translation_key="humidity", + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=2, + translation_placeholders={"sensor_name": "BME280"}, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.PRESSURE, + key="BME280_pressure", + translation_key="pressure", + native_unit_of_measurement=UnitOfPressure.PA, + suggested_unit_of_measurement=UnitOfPressure.MMHG, + suggested_display_precision=0, + translation_placeholders={"sensor_name": "BME280"}, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.TEMPERATURE, + key="BME280_temperature", + translation_key="temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=2, + translation_placeholders={"sensor_name": "BME280"}, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.PRESSURE, + key="BMP_pressure", + translation_key="pressure", + native_unit_of_measurement=UnitOfPressure.PA, + suggested_unit_of_measurement=UnitOfPressure.MMHG, + suggested_display_precision=0, + translation_placeholders={"sensor_name": "BMP"}, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.TEMPERATURE, + key="BMP_temperature", + translation_key="temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=2, + translation_placeholders={"sensor_name": "BMP"}, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.TEMPERATURE, + key="BMP280_temperature", + translation_key="temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=2, + translation_placeholders={"sensor_name": "BMP280"}, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.PRESSURE, + key="BMP280_pressure", + translation_key="pressure", + native_unit_of_measurement=UnitOfPressure.PA, + suggested_unit_of_measurement=UnitOfPressure.MMHG, + suggested_display_precision=0, + translation_placeholders={"sensor_name": "BMP280"}, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.HUMIDITY, + key="HTU21D_humidity", + translation_key="humidity", + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=2, + translation_placeholders={"sensor_name": "HTU21D"}, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.TEMPERATURE, + key="HTU21D_temperature", + translation_key="temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=2, + translation_placeholders={"sensor_name": "HTU21D"}, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.PM10, + translation_key="pm_10", + key="SDS_P1", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + suggested_display_precision=2, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.PM25, + translation_key="pm_25", + key="SDS_P2", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + suggested_display_precision=2, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.HUMIDITY, + key="SHT3X_humidity", + translation_key="humidity", + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=2, + translation_placeholders={"sensor_name": "SHT3X"}, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.TEMPERATURE, + key="SHT3X_temperature", + translation_key="temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=2, + translation_placeholders={"sensor_name": "SHT3X"}, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + key="signal", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=0, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.SOUND_PRESSURE, + key="PCBA_noiseMax", + translation_key="noise_max", + native_unit_of_measurement=UnitOfSoundPressure.DECIBEL, + suggested_display_precision=0, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.SOUND_PRESSURE, + key="PCBA_noiseAvg", + translation_key="noise_avg", + native_unit_of_measurement=UnitOfSoundPressure.DECIBEL, + suggested_display_precision=0, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + translation_key="co2", + key="CCS_CO2", + suggested_display_precision=2, + translation_placeholders={"sensor_name": "CCS"}, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + key="CCS_TVOC", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + suggested_display_precision=2, + ), + AltruistSensorEntityDescription( + key="GC", + native_unit_of_measurement="μR/h", + translation_key="radiation", + suggested_display_precision=2, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.CO2, + translation_key="co2", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + key="SCD4x_co2", + suggested_display_precision=2, + translation_placeholders={"sensor_name": "SCD4x"}, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: AltruistConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add sensors for passed config_entry in HA.""" + coordinator = config_entry.runtime_data + async_add_entities( + AltruistSensor(coordinator, sensor_description) + for sensor_description in SENSOR_DESCRIPTIONS + if sensor_description.key in coordinator.data + ) + + +class AltruistSensor(CoordinatorEntity[AltruistDataUpdateCoordinator], SensorEntity): + """Implementation of a Altruist sensor.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: AltruistDataUpdateCoordinator, + description: AltruistSensorEntityDescription, + ) -> None: + """Initialize the Altruist sensor.""" + super().__init__(coordinator) + self._device = coordinator.client.device + self.entity_description: AltruistSensorEntityDescription = description + self._attr_unique_id = f"{self._device.id}-{description.key}" + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, self._device.id)}, + manufacturer="Robonomics", + model="Altruist", + sw_version=self._device.fw_version, + configuration_url=f"http://{self._device.ip_address}", + serial_number=self._device.id, + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return ( + super().available and self.entity_description.key in self.coordinator.data + ) + + @property + def native_value(self) -> float | int: + """Return the native value of the sensor.""" + string_value = self.coordinator.data[self.entity_description.key] + return self.entity_description.native_value_fn(string_value) diff --git a/homeassistant/components/altruist/strings.json b/homeassistant/components/altruist/strings.json new file mode 100644 index 0000000000000..a466e1e3c9d95 --- /dev/null +++ b/homeassistant/components/altruist/strings.json @@ -0,0 +1,51 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "discovery_confirm": { + "description": "Do you want to start setup {model}?" + }, + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Altruist IP address or hostname in the local network" + }, + "description": "Fill in Altruist IP address or hostname in your local network" + } + }, + "abort": { + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "no_device_found": "[%key:common::config_flow::error::cannot_connect%]" + } + }, + "entity": { + "sensor": { + "humidity": { + "name": "{sensor_name} humidity" + }, + "pressure": { + "name": "{sensor_name} pressure" + }, + "temperature": { + "name": "{sensor_name} temperature" + }, + "noise_max": { + "name": "Maximum noise" + }, + "noise_avg": { + "name": "Average noise" + }, + "co2": { + "name": "{sensor_name} CO2" + }, + "radiation": { + "name": "Radiation level" + } + } + } +} diff --git a/homeassistant/components/bosch_alarm/manifest.json b/homeassistant/components/bosch_alarm/manifest.json index 160d614195932..0003d80cc4fbf 100644 --- a/homeassistant/components/bosch_alarm/manifest.json +++ b/homeassistant/components/bosch_alarm/manifest.json @@ -11,6 +11,6 @@ "documentation": "https://www.home-assistant.io/integrations/bosch_alarm", "integration_type": "device", "iot_class": "local_push", - "quality_scale": "bronze", + "quality_scale": "platinum", "requirements": ["bosch-alarm-mode2==0.4.6"] } diff --git a/homeassistant/components/bosch_alarm/quality_scale.yaml b/homeassistant/components/bosch_alarm/quality_scale.yaml index 474dc348fd823..f26050b4883d8 100644 --- a/homeassistant/components/bosch_alarm/quality_scale.yaml +++ b/homeassistant/components/bosch_alarm/quality_scale.yaml @@ -28,38 +28,41 @@ rules: # Silver action-exceptions: done config-entry-unloading: done - docs-configuration-parameters: todo - docs-installation-parameters: todo - entity-unavailable: todo + docs-configuration-parameters: + status: exempt + comment: | + No options flow is provided. + docs-installation-parameters: done + entity-unavailable: done integration-owner: done - log-when-unavailable: todo + log-when-unavailable: done parallel-updates: done reauthentication-flow: done test-coverage: done # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: done discovery: done - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: todo - docs-supported-functions: todo - docs-troubleshooting: todo - docs-use-cases: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: status: exempt comment: | Device type integration - entity-category: todo - entity-device-class: todo - entity-disabled-by-default: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: done entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: | diff --git a/homeassistant/components/devolo_home_control/__init__.py b/homeassistant/components/devolo_home_control/__init__.py index 80320e2a8494c..51e4152be98d3 100644 --- a/homeassistant/components/devolo_home_control/__init__.py +++ b/homeassistant/components/devolo_home_control/__init__.py @@ -18,7 +18,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.device_registry import DeviceEntry -from .const import DOMAIN, GATEWAY_SERIAL_PATTERN, PLATFORMS +from .const import DOMAIN, PLATFORMS type DevoloHomeControlConfigEntry = ConfigEntry[list[HomeControl]] @@ -33,10 +33,6 @@ async def async_setup_entry( check_mydevolo_and_get_gateway_ids, mydevolo ) - if entry.unique_id and GATEWAY_SERIAL_PATTERN.match(entry.unique_id): - uuid = await hass.async_add_executor_job(mydevolo.uuid) - hass.config_entries.async_update_entry(entry, unique_id=uuid) - def shutdown(event: Event) -> None: for gateway in entry.runtime_data: gateway.websocket_disconnect( diff --git a/homeassistant/components/devolo_home_control/const.py b/homeassistant/components/devolo_home_control/const.py index bd2282ad99ff5..e517e2699160d 100644 --- a/homeassistant/components/devolo_home_control/const.py +++ b/homeassistant/components/devolo_home_control/const.py @@ -1,7 +1,5 @@ """Constants for the devolo_home_control integration.""" -import re - from homeassistant.const import Platform DOMAIN = "devolo_home_control" @@ -14,5 +12,4 @@ Platform.SIREN, Platform.SWITCH, ] -GATEWAY_SERIAL_PATTERN = re.compile(r"\d{16}") SUPPORTED_MODEL_TYPES = ["2600", "2601"] diff --git a/homeassistant/components/imgw_pib/config_flow.py b/homeassistant/components/imgw_pib/config_flow.py index 805bfa2ccb3dc..78d77737a39a8 100644 --- a/homeassistant/components/imgw_pib/config_flow.py +++ b/homeassistant/components/imgw_pib/config_flow.py @@ -45,7 +45,9 @@ async def async_step_user( try: imgwpib = await ImgwPib.create( - client_session, hydrological_station_id=station_id + client_session, + hydrological_station_id=station_id, + hydrological_details=False, ) hydrological_data = await imgwpib.get_hydrological_data() except (ClientError, TimeoutError, ApiError): diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index e2d6e2bf584da..42d536da8f52a 100644 --- a/homeassistant/components/imgw_pib/manifest.json +++ b/homeassistant/components/imgw_pib/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/imgw_pib", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["imgw_pib==1.0.10"] + "requirements": ["imgw_pib==1.1.0"] } diff --git a/homeassistant/components/lametric/__init__.py b/homeassistant/components/lametric/__init__.py index 89659fbd2c00d..efc784354e1d6 100644 --- a/homeassistant/components/lametric/__init__.py +++ b/homeassistant/components/lametric/__init__.py @@ -1,14 +1,13 @@ """Support for LaMetric time.""" from homeassistant.components import notify as hass_notify -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, PLATFORMS -from .coordinator import LaMetricDataUpdateCoordinator +from .coordinator import LaMetricConfigEntry, LaMetricDataUpdateCoordinator from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -17,16 +16,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the LaMetric integration.""" async_setup_services(hass) - hass.data[DOMAIN] = {"hass_config": config} + return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: LaMetricConfigEntry) -> bool: """Set up LaMetric from a config entry.""" coordinator = LaMetricDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # Set up notify platform, no entry support for notify component yet, @@ -37,15 +36,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: Platform.NOTIFY, DOMAIN, {CONF_NAME: coordinator.data.name, "entry_id": entry.entry_id}, - hass.data[DOMAIN]["hass_config"], + {}, ) ) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: LaMetricConfigEntry) -> bool: """Unload LaMetric config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - del hass.data[DOMAIN][entry.entry_id] await hass_notify.async_reload(hass, DOMAIN) return unload_ok diff --git a/homeassistant/components/lametric/button.py b/homeassistant/components/lametric/button.py index 3c7d754fa0b1e..7b141665a4fa9 100644 --- a/homeassistant/components/lametric/button.py +++ b/homeassistant/components/lametric/button.py @@ -9,13 +9,11 @@ from demetriek import LaMetricDevice from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import LaMetricDataUpdateCoordinator +from .coordinator import LaMetricConfigEntry, LaMetricDataUpdateCoordinator from .entity import LaMetricEntity from .helpers import lametric_exception_handler @@ -57,11 +55,11 @@ class LaMetricButtonEntityDescription(ButtonEntityDescription): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LaMetricConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LaMetric button based on a config entry.""" - coordinator: LaMetricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( LaMetricButtonEntity( coordinator=coordinator, diff --git a/homeassistant/components/lametric/coordinator.py b/homeassistant/components/lametric/coordinator.py index c292b2971b6ef..5430150636680 100644 --- a/homeassistant/components/lametric/coordinator.py +++ b/homeassistant/components/lametric/coordinator.py @@ -13,13 +13,15 @@ from .const import DOMAIN, LOGGER, SCAN_INTERVAL +type LaMetricConfigEntry = ConfigEntry[LaMetricDataUpdateCoordinator] + class LaMetricDataUpdateCoordinator(DataUpdateCoordinator[Device]): """The LaMetric Data Update Coordinator.""" - config_entry: ConfigEntry + config_entry: LaMetricConfigEntry - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: LaMetricConfigEntry) -> None: """Initialize the LaMatric coordinator.""" self.lametric = LaMetricDevice( host=entry.data[CONF_HOST], diff --git a/homeassistant/components/lametric/diagnostics.py b/homeassistant/components/lametric/diagnostics.py index c14ed998ace75..9df72ee40fafa 100644 --- a/homeassistant/components/lametric/diagnostics.py +++ b/homeassistant/components/lametric/diagnostics.py @@ -6,11 +6,9 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import LaMetricDataUpdateCoordinator +from .coordinator import LaMetricConfigEntry TO_REDACT = { "device_id", @@ -21,10 +19,10 @@ async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: LaMetricConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: LaMetricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data # Round-trip via JSON to trigger serialization data = json.loads(coordinator.data.to_json()) return async_redact_data(data, TO_REDACT) diff --git a/homeassistant/components/lametric/entity.py b/homeassistant/components/lametric/entity.py index eb33165087086..4764974b5e0a5 100644 --- a/homeassistant/components/lametric/entity.py +++ b/homeassistant/components/lametric/entity.py @@ -31,4 +31,5 @@ def __init__(self, coordinator: LaMetricDataUpdateCoordinator) -> None: name=coordinator.data.name, sw_version=coordinator.data.os_version, serial_number=coordinator.data.serial_number, + configuration_url=f"https://{coordinator.data.wifi.ip}/", ) diff --git a/homeassistant/components/lametric/helpers.py b/homeassistant/components/lametric/helpers.py index 8620b0c7cd956..55b5ef1bb8b5b 100644 --- a/homeassistant/components/lametric/helpers.py +++ b/homeassistant/components/lametric/helpers.py @@ -12,7 +12,7 @@ from homeassistant.helpers import device_registry as dr from .const import DOMAIN -from .coordinator import LaMetricDataUpdateCoordinator +from .coordinator import LaMetricConfigEntry, LaMetricDataUpdateCoordinator from .entity import LaMetricEntity @@ -57,15 +57,9 @@ def async_get_coordinator_by_device_id( if (device_entry := device_registry.async_get(device_id)) is None: raise ValueError(f"Unknown LaMetric device ID: {device_id}") - for entry_id in device_entry.config_entries: - if ( - (entry := hass.config_entries.async_get_entry(entry_id)) - and entry.domain == DOMAIN - and entry.entry_id in hass.data[DOMAIN] - ): - coordinator: LaMetricDataUpdateCoordinator = hass.data[DOMAIN][ - entry.entry_id - ] - return coordinator + entry: LaMetricConfigEntry + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + if entry.entry_id in device_entry.config_entries: + return entry.runtime_data raise ValueError(f"No coordinator for device ID: {device_id}") diff --git a/homeassistant/components/lametric/manifest.json b/homeassistant/components/lametric/manifest.json index 4c4359d0ddbe3..d6aceaaebdb2c 100644 --- a/homeassistant/components/lametric/manifest.json +++ b/homeassistant/components/lametric/manifest.json @@ -13,7 +13,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["demetriek"], - "requirements": ["demetriek==1.2.0"], + "requirements": ["demetriek==1.3.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:LaMetric:1" diff --git a/homeassistant/components/lametric/notify.py b/homeassistant/components/lametric/notify.py index 195924e2da5af..db453d2fc20ce 100644 --- a/homeassistant/components/lametric/notify.py +++ b/homeassistant/components/lametric/notify.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any from demetriek import ( AlarmSound, @@ -24,8 +24,8 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.enum import try_parse_enum -from .const import CONF_CYCLES, CONF_ICON_TYPE, CONF_PRIORITY, CONF_SOUND, DOMAIN -from .coordinator import LaMetricDataUpdateCoordinator +from .const import CONF_CYCLES, CONF_ICON_TYPE, CONF_PRIORITY, CONF_SOUND +from .coordinator import LaMetricConfigEntry async def async_get_service( @@ -36,10 +36,12 @@ async def async_get_service( """Get the LaMetric notification service.""" if discovery_info is None: return None - coordinator: LaMetricDataUpdateCoordinator = hass.data[DOMAIN][ + entry: LaMetricConfigEntry | None = hass.config_entries.async_get_entry( discovery_info["entry_id"] - ] - return LaMetricNotificationService(coordinator.lametric) + ) + if TYPE_CHECKING: + assert entry is not None + return LaMetricNotificationService(entry.runtime_data.lametric) class LaMetricNotificationService(BaseNotificationService): diff --git a/homeassistant/components/lametric/number.py b/homeassistant/components/lametric/number.py index 7f356741d76c7..acd196d4b3411 100644 --- a/homeassistant/components/lametric/number.py +++ b/homeassistant/components/lametric/number.py @@ -9,13 +9,11 @@ from demetriek import Device, LaMetricDevice, Range from homeassistant.components.number import NumberEntity, NumberEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import LaMetricDataUpdateCoordinator +from .coordinator import LaMetricConfigEntry, LaMetricDataUpdateCoordinator from .entity import LaMetricEntity from .helpers import lametric_exception_handler @@ -57,11 +55,11 @@ class LaMetricNumberEntityDescription(NumberEntityDescription): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LaMetricConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LaMetric number based on a config entry.""" - coordinator: LaMetricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( LaMetricNumberEntity( coordinator=coordinator, diff --git a/homeassistant/components/lametric/quality_scale.yaml b/homeassistant/components/lametric/quality_scale.yaml index a8982bb938b02..a01115bab3ef4 100644 --- a/homeassistant/components/lametric/quality_scale.yaml +++ b/homeassistant/components/lametric/quality_scale.yaml @@ -17,7 +17,7 @@ rules: Entities of this integration does not explicitly subscribe to events. entity-unique-id: done has-entity-name: done - runtime-data: todo + runtime-data: done test-before-configure: done test-before-setup: done unique-config-entry: done @@ -33,6 +33,7 @@ rules: parallel-updates: todo reauthentication-flow: done test-coverage: done + # Gold devices: done diagnostics: done diff --git a/homeassistant/components/lametric/select.py b/homeassistant/components/lametric/select.py index eab7cd5997c9d..993ec7c909a54 100644 --- a/homeassistant/components/lametric/select.py +++ b/homeassistant/components/lametric/select.py @@ -9,13 +9,11 @@ from demetriek import BrightnessMode, Device, LaMetricDevice from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import LaMetricDataUpdateCoordinator +from .coordinator import LaMetricConfigEntry, LaMetricDataUpdateCoordinator from .entity import LaMetricEntity from .helpers import lametric_exception_handler @@ -42,11 +40,11 @@ class LaMetricSelectEntityDescription(SelectEntityDescription): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LaMetricConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LaMetric select based on a config entry.""" - coordinator: LaMetricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( LaMetricSelectEntity( coordinator=coordinator, diff --git a/homeassistant/components/lametric/sensor.py b/homeassistant/components/lametric/sensor.py index a5d5da3c0463d..309c80932043f 100644 --- a/homeassistant/components/lametric/sensor.py +++ b/homeassistant/components/lametric/sensor.py @@ -12,13 +12,11 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import LaMetricDataUpdateCoordinator +from .coordinator import LaMetricConfigEntry, LaMetricDataUpdateCoordinator from .entity import LaMetricEntity @@ -44,11 +42,11 @@ class LaMetricSensorEntityDescription(SensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LaMetricConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LaMetric sensor based on a config entry.""" - coordinator: LaMetricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( LaMetricSensorEntity( coordinator=coordinator, diff --git a/homeassistant/components/lametric/switch.py b/homeassistant/components/lametric/switch.py index 85e61164639dc..8e4fb611d3e5d 100644 --- a/homeassistant/components/lametric/switch.py +++ b/homeassistant/components/lametric/switch.py @@ -9,13 +9,11 @@ from demetriek import Device, LaMetricDevice from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import LaMetricDataUpdateCoordinator +from .coordinator import LaMetricConfigEntry, LaMetricDataUpdateCoordinator from .entity import LaMetricEntity from .helpers import lametric_exception_handler @@ -47,11 +45,11 @@ class LaMetricSwitchEntityDescription(SwitchEntityDescription): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LaMetricConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LaMetric switch based on a config entry.""" - coordinator: LaMetricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( LaMetricSwitchEntity( coordinator=coordinator, diff --git a/homeassistant/components/landisgyr_heat_meter/__init__.py b/homeassistant/components/landisgyr_heat_meter/__init__.py index 7e7ebe61eb7de..669de160811d9 100644 --- a/homeassistant/components/landisgyr_heat_meter/__init__.py +++ b/homeassistant/components/landisgyr_heat_meter/__init__.py @@ -7,20 +7,19 @@ import ultraheat_api -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries from .const import DOMAIN -from .coordinator import UltraheatCoordinator +from .coordinator import UltraheatConfigEntry, UltraheatCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: UltraheatConfigEntry) -> bool: """Set up heat meter from a config entry.""" _LOGGER.debug("Initializing %s integration on %s", DOMAIN, entry.data[CONF_DEVICE]) @@ -30,22 +29,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = UltraheatCoordinator(hass, entry, api) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: UltraheatConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - return unload_ok - -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: UltraheatConfigEntry +) -> bool: """Migrate old entry.""" _LOGGER.debug("Migrating from version %s", config_entry.version) diff --git a/homeassistant/components/landisgyr_heat_meter/coordinator.py b/homeassistant/components/landisgyr_heat_meter/coordinator.py index 4214fa1db3eb6..bda19fd6fc3b7 100644 --- a/homeassistant/components/landisgyr_heat_meter/coordinator.py +++ b/homeassistant/components/landisgyr_heat_meter/coordinator.py @@ -15,14 +15,19 @@ _LOGGER = logging.getLogger(__name__) +type UltraheatConfigEntry = ConfigEntry[UltraheatCoordinator] + class UltraheatCoordinator(DataUpdateCoordinator[HeatMeterResponse]): """Coordinator for getting data from the ultraheat api.""" - config_entry: ConfigEntry + config_entry: UltraheatConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, api: HeatMeterService + self, + hass: HomeAssistant, + config_entry: UltraheatConfigEntry, + api: HeatMeterService, ) -> None: """Initialize my coordinator.""" super().__init__( diff --git a/homeassistant/components/landisgyr_heat_meter/sensor.py b/homeassistant/components/landisgyr_heat_meter/sensor.py index 9bb4af572fd43..6a7d7c631032a 100644 --- a/homeassistant/components/landisgyr_heat_meter/sensor.py +++ b/homeassistant/components/landisgyr_heat_meter/sensor.py @@ -15,7 +15,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( EntityCategory, UnitOfEnergy, @@ -29,13 +28,11 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util -from . import DOMAIN +from .const import DOMAIN +from .coordinator import UltraheatConfigEntry, UltraheatCoordinator _LOGGER = logging.getLogger(__name__) @@ -270,14 +267,12 @@ class HeatMeterSensorEntityDescription(SensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: UltraheatConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" unique_id = entry.entry_id - coordinator: DataUpdateCoordinator[HeatMeterResponse] = hass.data[DOMAIN][ - entry.entry_id - ] + coordinator = entry.runtime_data model = entry.data["model"] @@ -295,7 +290,7 @@ async def async_setup_entry( class HeatMeterSensor( - CoordinatorEntity[DataUpdateCoordinator[HeatMeterResponse]], + CoordinatorEntity[UltraheatCoordinator], SensorEntity, ): """Representation of a Sensor.""" @@ -304,7 +299,7 @@ class HeatMeterSensor( def __init__( self, - coordinator: DataUpdateCoordinator[HeatMeterResponse], + coordinator: UltraheatCoordinator, description: HeatMeterSensorEntityDescription, device: DeviceInfo, ) -> None: @@ -312,7 +307,7 @@ def __init__( super().__init__(coordinator) self.key = description.key self._attr_unique_id = ( - f"{coordinator.config_entry.data['device_number']}_{description.key}" # type: ignore[union-attr] + f"{coordinator.config_entry.data['device_number']}_{description.key}" ) self._attr_name = f"Heat Meter {description.name}" self.entity_description = description diff --git a/homeassistant/components/laundrify/__init__.py b/homeassistant/components/laundrify/__init__.py index 7e3dd84834894..b45ca25bd2e7b 100644 --- a/homeassistant/components/laundrify/__init__.py +++ b/homeassistant/components/laundrify/__init__.py @@ -7,21 +7,19 @@ from laundrify_aio import LaundrifyAPI from laundrify_aio.exceptions import ApiConnectionException, UnauthorizedException -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN -from .coordinator import LaundrifyUpdateCoordinator +from .coordinator import LaundrifyConfigEntry, LaundrifyUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: LaundrifyConfigEntry) -> bool: """Set up laundrify from a config entry.""" session = async_get_clientsession(hass) @@ -38,26 +36,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - "api": api_client, - "coordinator": coordinator, - } + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: LaundrifyConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - return unload_ok - -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_migrate_entry(hass: HomeAssistant, entry: LaundrifyConfigEntry) -> bool: """Migrate entry.""" _LOGGER.debug("Migrating from version %s", entry.version) diff --git a/homeassistant/components/laundrify/binary_sensor.py b/homeassistant/components/laundrify/binary_sensor.py index 82f4f7609dcaf..0cfbaae6c2011 100644 --- a/homeassistant/components/laundrify/binary_sensor.py +++ b/homeassistant/components/laundrify/binary_sensor.py @@ -10,28 +10,25 @@ BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER, MODELS -from .coordinator import LaundrifyUpdateCoordinator +from .coordinator import LaundrifyConfigEntry, LaundrifyUpdateCoordinator _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config: ConfigEntry, + entry: LaundrifyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors from a config entry created in the integrations UI.""" - coordinator: LaundrifyUpdateCoordinator = hass.data[DOMAIN][config.entry_id][ - "coordinator" - ] + coordinator = entry.runtime_data async_add_entities( LaundrifyPowerPlug(coordinator, device) for device in coordinator.data.values() diff --git a/homeassistant/components/laundrify/coordinator.py b/homeassistant/components/laundrify/coordinator.py index 928e30a9ed567..cca1cb2122c32 100644 --- a/homeassistant/components/laundrify/coordinator.py +++ b/homeassistant/components/laundrify/coordinator.py @@ -16,6 +16,8 @@ _LOGGER = logging.getLogger(__name__) +type LaundrifyConfigEntry = ConfigEntry[LaundrifyUpdateCoordinator] + class LaundrifyUpdateCoordinator(DataUpdateCoordinator[dict[str, LaundrifyDevice]]): """Class to manage fetching laundrify API data.""" diff --git a/homeassistant/components/laundrify/sensor.py b/homeassistant/components/laundrify/sensor.py index 3c343861b0aa3..7caa6a9b04442 100644 --- a/homeassistant/components/laundrify/sensor.py +++ b/homeassistant/components/laundrify/sensor.py @@ -10,7 +10,6 @@ SensorEntity, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo @@ -18,21 +17,19 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import LaundrifyUpdateCoordinator +from .coordinator import LaundrifyConfigEntry, LaundrifyUpdateCoordinator _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config: ConfigEntry, + entry: LaundrifyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add power sensor for passed config_entry in HA.""" - coordinator: LaundrifyUpdateCoordinator = hass.data[DOMAIN][config.entry_id][ - "coordinator" - ] + coordinator = entry.runtime_data sensor_entities: list[LaundrifyPowerSensor | LaundrifyEnergySensor] = [] for device in coordinator.data.values(): diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py index 11cee726eb0b4..efc981b754cd2 100644 --- a/homeassistant/components/lcn/__init__.py +++ b/homeassistant/components/lcn/__init__.py @@ -16,7 +16,6 @@ ) from pypck.lcn_defs import LcnEvent -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DEVICE_ID, CONF_DOMAIN, @@ -38,21 +37,20 @@ from homeassistant.helpers.typing import ConfigType from .const import ( - ADD_ENTITIES_CALLBACKS, CONF_ACKNOWLEDGE, CONF_DIM_MODE, CONF_DOMAIN_DATA, CONF_SK_NUM_TRIES, CONF_TARGET_VALUE_LOCKED, CONF_TRANSITION, - CONNECTION, - DEVICE_CONNECTIONS, DOMAIN, PLATFORMS, ) from .helpers import ( AddressType, InputType, + LcnConfigEntry, + LcnRuntimeData, async_update_config_entry, generate_unique_id, purge_device_registry, @@ -69,18 +67,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the LCN component.""" - hass.data.setdefault(DOMAIN, {}) - async_setup_services(hass) await register_panel_and_ws_api(hass) return True -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, config_entry: LcnConfigEntry) -> bool: """Set up a connection to PCHK host from a config entry.""" - if config_entry.entry_id in hass.data[DOMAIN]: - return False settings = { "SK_NUM_TRIES": config_entry.data[CONF_SK_NUM_TRIES], @@ -114,11 +108,11 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b ) from ex _LOGGER.debug('LCN connected to "%s"', config_entry.title) - hass.data[DOMAIN][config_entry.entry_id] = { - CONNECTION: lcn_connection, - DEVICE_CONNECTIONS: {}, - ADD_ENTITIES_CALLBACKS: {}, - } + config_entry.runtime_data = LcnRuntimeData( + connection=lcn_connection, + device_connections={}, + add_entities_callbacks={}, + ) # Update config_entry with LCN device serials await async_update_config_entry(hass, config_entry) @@ -146,7 +140,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: LcnConfigEntry +) -> bool: """Migrate old entry.""" _LOGGER.debug( "Migrating configuration from version %s.%s", @@ -195,7 +191,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> async def async_migrate_entities( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: LcnConfigEntry ) -> None: """Migrate entity registry.""" @@ -217,25 +213,24 @@ def update_unique_id(entity_entry: er.RegistryEntry) -> dict[str, str] | None: await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id) -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, config_entry: LcnConfigEntry) -> bool: """Close connection to PCHK host represented by config_entry.""" # forward unloading to platforms unload_ok = await hass.config_entries.async_unload_platforms( config_entry, PLATFORMS ) - if unload_ok and config_entry.entry_id in hass.data[DOMAIN]: - host = hass.data[DOMAIN].pop(config_entry.entry_id) - await host[CONNECTION].async_close() + if unload_ok: + await config_entry.runtime_data.connection.async_close() return unload_ok def async_host_event_received( - hass: HomeAssistant, config_entry: ConfigEntry, event: pypck.lcn_defs.LcnEvent + hass: HomeAssistant, config_entry: LcnConfigEntry, event: pypck.lcn_defs.LcnEvent ) -> None: """Process received event from LCN.""" - lcn_connection = hass.data[DOMAIN][config_entry.entry_id][CONNECTION] + lcn_connection = config_entry.runtime_data.connection async def reload_config_entry() -> None: """Close connection and schedule config entry for reload.""" @@ -258,7 +253,7 @@ async def reload_config_entry() -> None: def async_host_input_received( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, device_registry: dr.DeviceRegistry, inp: pypck.inputs.Input, ) -> None: @@ -266,7 +261,7 @@ def async_host_input_received( if not isinstance(inp, pypck.inputs.ModInput): return - lcn_connection = hass.data[DOMAIN][config_entry.entry_id][CONNECTION] + lcn_connection = config_entry.runtime_data.connection logical_address = lcn_connection.physical_to_logical(inp.physical_source_addr) address = ( logical_address.seg_id, diff --git a/homeassistant/components/lcn/binary_sensor.py b/homeassistant/components/lcn/binary_sensor.py index 65afae56f22f0..d8418c6d838cb 100644 --- a/homeassistant/components/lcn/binary_sensor.py +++ b/homeassistant/components/lcn/binary_sensor.py @@ -11,7 +11,6 @@ BinarySensorEntity, ) from homeassistant.components.script import scripts_with_entity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DOMAIN, CONF_ENTITIES, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -22,19 +21,13 @@ ) from homeassistant.helpers.typing import ConfigType -from .const import ( - ADD_ENTITIES_CALLBACKS, - BINSENSOR_PORTS, - CONF_DOMAIN_DATA, - DOMAIN, - SETPOINTS, -) +from .const import BINSENSOR_PORTS, CONF_DOMAIN_DATA, DOMAIN, SETPOINTS from .entity import LcnEntity -from .helpers import InputType +from .helpers import InputType, LcnConfigEntry def add_lcn_entities( - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, entity_configs: Iterable[ConfigType], ) -> None: @@ -53,7 +46,7 @@ def add_lcn_entities( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LCN switch entities from a config entry.""" @@ -63,7 +56,7 @@ async def async_setup_entry( async_add_entities, ) - hass.data[DOMAIN][config_entry.entry_id][ADD_ENTITIES_CALLBACKS].update( + config_entry.runtime_data.add_entities_callbacks.update( {DOMAIN_BINARY_SENSOR: add_entities} ) @@ -79,7 +72,7 @@ async def async_setup_entry( class LcnRegulatorLockSensor(LcnEntity, BinarySensorEntity): """Representation of a LCN binary sensor for regulator locks.""" - def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: + def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: """Initialize the LCN binary sensor.""" super().__init__(config, config_entry) @@ -138,7 +131,7 @@ def input_received(self, input_obj: InputType) -> None: class LcnBinarySensor(LcnEntity, BinarySensorEntity): """Representation of a LCN binary sensor for binary sensor ports.""" - def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: + def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: """Initialize the LCN binary sensor.""" super().__init__(config, config_entry) @@ -174,7 +167,7 @@ def input_received(self, input_obj: InputType) -> None: class LcnLockKeysSensor(LcnEntity, BinarySensorEntity): """Representation of a LCN sensor for key locks.""" - def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: + def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: """Initialize the LCN sensor.""" super().__init__(config, config_entry) diff --git a/homeassistant/components/lcn/climate.py b/homeassistant/components/lcn/climate.py index e91ae7237149d..5dc1419cecc62 100644 --- a/homeassistant/components/lcn/climate.py +++ b/homeassistant/components/lcn/climate.py @@ -12,7 +12,6 @@ ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, CONF_DOMAIN, @@ -26,23 +25,21 @@ from homeassistant.helpers.typing import ConfigType from .const import ( - ADD_ENTITIES_CALLBACKS, CONF_DOMAIN_DATA, CONF_LOCKABLE, CONF_MAX_TEMP, CONF_MIN_TEMP, CONF_SETPOINT, CONF_TARGET_VALUE_LOCKED, - DOMAIN, ) from .entity import LcnEntity -from .helpers import InputType +from .helpers import InputType, LcnConfigEntry PARALLEL_UPDATES = 0 def add_lcn_entities( - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, entity_configs: Iterable[ConfigType], ) -> None: @@ -56,7 +53,7 @@ def add_lcn_entities( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LCN switch entities from a config entry.""" @@ -66,7 +63,7 @@ async def async_setup_entry( async_add_entities, ) - hass.data[DOMAIN][config_entry.entry_id][ADD_ENTITIES_CALLBACKS].update( + config_entry.runtime_data.add_entities_callbacks.update( {DOMAIN_CLIMATE: add_entities} ) @@ -82,7 +79,7 @@ async def async_setup_entry( class LcnClimate(LcnEntity, ClimateEntity): """Representation of a LCN climate device.""" - def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: + def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: """Initialize of a LCN climate device.""" super().__init__(config, config_entry) diff --git a/homeassistant/components/lcn/const.py b/homeassistant/components/lcn/const.py index d67c02ed56adc..d8831c66f0b16 100644 --- a/homeassistant/components/lcn/const.py +++ b/homeassistant/components/lcn/const.py @@ -15,12 +15,8 @@ ] DOMAIN = "lcn" -DATA_LCN = "lcn" DEFAULT_NAME = "pchk" -ADD_ENTITIES_CALLBACKS = "add_entities_callbacks" -CONNECTION = "connection" -DEVICE_CONNECTIONS = "device_connections" CONF_HARDWARE_SERIAL = "hardware_serial" CONF_SOFTWARE_SERIAL = "software_serial" CONF_HARDWARE_TYPE = "hardware_type" diff --git a/homeassistant/components/lcn/cover.py b/homeassistant/components/lcn/cover.py index 068d8f5ba1199..cb292f7cadfe8 100644 --- a/homeassistant/components/lcn/cover.py +++ b/homeassistant/components/lcn/cover.py @@ -12,28 +12,25 @@ CoverEntity, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DOMAIN, CONF_ENTITIES from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from .const import ( - ADD_ENTITIES_CALLBACKS, CONF_DOMAIN_DATA, CONF_MOTOR, CONF_POSITIONING_MODE, CONF_REVERSE_TIME, - DOMAIN, ) from .entity import LcnEntity -from .helpers import InputType +from .helpers import InputType, LcnConfigEntry PARALLEL_UPDATES = 0 def add_lcn_entities( - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, entity_configs: Iterable[ConfigType], ) -> None: @@ -50,7 +47,7 @@ def add_lcn_entities( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LCN cover entities from a config entry.""" @@ -60,7 +57,7 @@ async def async_setup_entry( async_add_entities, ) - hass.data[DOMAIN][config_entry.entry_id][ADD_ENTITIES_CALLBACKS].update( + config_entry.runtime_data.add_entities_callbacks.update( {DOMAIN_COVER: add_entities} ) @@ -81,7 +78,7 @@ class LcnOutputsCover(LcnEntity, CoverEntity): _attr_is_opening = False _attr_assumed_state = True - def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: + def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: """Initialize the LCN cover.""" super().__init__(config, config_entry) @@ -188,7 +185,7 @@ class LcnRelayCover(LcnEntity, CoverEntity): positioning_mode: pypck.lcn_defs.MotorPositioningMode - def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: + def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: """Initialize the LCN cover.""" super().__init__(config, config_entry) diff --git a/homeassistant/components/lcn/entity.py b/homeassistant/components/lcn/entity.py index a1940fc7ac3d5..f94251983b4fe 100644 --- a/homeassistant/components/lcn/entity.py +++ b/homeassistant/components/lcn/entity.py @@ -2,7 +2,6 @@ from collections.abc import Callable -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, CONF_DOMAIN, CONF_NAME from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -13,6 +12,7 @@ AddressType, DeviceConnectionType, InputType, + LcnConfigEntry, generate_unique_id, get_device_connection, get_resource, @@ -29,7 +29,7 @@ class LcnEntity(Entity): def __init__( self, config: ConfigType, - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, ) -> None: """Initialize the LCN device.""" self.config = config diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py index 1bc4c6caa4131..515f64b6e31ce 100644 --- a/homeassistant/components/lcn/helpers.py +++ b/homeassistant/components/lcn/helpers.py @@ -3,11 +3,14 @@ from __future__ import annotations import asyncio +from collections.abc import Callable, Iterable from copy import deepcopy +from dataclasses import dataclass import re from typing import cast import pypck +from pypck.connection import PchkConnectionManager from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -33,12 +36,27 @@ CONF_HARDWARE_TYPE, CONF_SCENES, CONF_SOFTWARE_SERIAL, - CONNECTION, - DEVICE_CONNECTIONS, DOMAIN, ) + +@dataclass +class LcnRuntimeData: + """Data for LCN config entry.""" + + connection: PchkConnectionManager + """Connection to PCHK host.""" + + device_connections: dict[str, DeviceConnectionType] + """Logical addresses of devices connected to the host.""" + + add_entities_callbacks: dict[str, Callable[[Iterable[ConfigType]], None]] + """Callbacks to add entities for platforms.""" + + # typing +type LcnConfigEntry = ConfigEntry[LcnRuntimeData] + type AddressType = tuple[int, int, bool] type DeviceConnectionType = pypck.module.ModuleConnection | pypck.module.GroupConnection @@ -62,10 +80,10 @@ def get_device_connection( - hass: HomeAssistant, address: AddressType, config_entry: ConfigEntry + hass: HomeAssistant, address: AddressType, config_entry: LcnConfigEntry ) -> DeviceConnectionType: """Return a lcn device_connection.""" - host_connection = hass.data[DOMAIN][config_entry.entry_id][CONNECTION] + host_connection = config_entry.runtime_data.connection addr = pypck.lcn_addr.LcnAddr(*address) return host_connection.get_address_conn(addr) @@ -165,7 +183,7 @@ def purge_device_registry( device_registry.async_remove_device(device_id) -def register_lcn_host_device(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +def register_lcn_host_device(hass: HomeAssistant, config_entry: LcnConfigEntry) -> None: """Register LCN host for given config_entry in device registry.""" device_registry = dr.async_get(hass) @@ -179,7 +197,7 @@ def register_lcn_host_device(hass: HomeAssistant, config_entry: ConfigEntry) -> def register_lcn_address_devices( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: LcnConfigEntry ) -> None: """Register LCN modules and groups defined in config_entry as devices in device registry. @@ -217,9 +235,9 @@ def register_lcn_address_devices( model=device_model, ) - hass.data[DOMAIN][config_entry.entry_id][DEVICE_CONNECTIONS][ - device_entry.id - ] = get_device_connection(hass, address, config_entry) + config_entry.runtime_data.device_connections[device_entry.id] = ( + get_device_connection(hass, address, config_entry) + ) async def async_update_device_config( @@ -254,7 +272,7 @@ async def async_update_device_config( async def async_update_config_entry( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: LcnConfigEntry ) -> None: """Fill missing values in config_entry with infos from LCN bus.""" device_configs = deepcopy(config_entry.data[CONF_DEVICES]) diff --git a/homeassistant/components/lcn/light.py b/homeassistant/components/lcn/light.py index cba7c0888b790..cd6b5c7057ef4 100644 --- a/homeassistant/components/lcn/light.py +++ b/homeassistant/components/lcn/light.py @@ -14,29 +14,26 @@ LightEntity, LightEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DOMAIN, CONF_ENTITIES from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from .const import ( - ADD_ENTITIES_CALLBACKS, CONF_DIMMABLE, CONF_DOMAIN_DATA, CONF_OUTPUT, CONF_TRANSITION, - DOMAIN, OUTPUT_PORTS, ) from .entity import LcnEntity -from .helpers import InputType +from .helpers import InputType, LcnConfigEntry PARALLEL_UPDATES = 0 def add_lcn_entities( - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, entity_configs: Iterable[ConfigType], ) -> None: @@ -53,7 +50,7 @@ def add_lcn_entities( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LCN light entities from a config entry.""" @@ -63,7 +60,7 @@ async def async_setup_entry( async_add_entities, ) - hass.data[DOMAIN][config_entry.entry_id][ADD_ENTITIES_CALLBACKS].update( + config_entry.runtime_data.add_entities_callbacks.update( {DOMAIN_LIGHT: add_entities} ) @@ -83,7 +80,7 @@ class LcnOutputLight(LcnEntity, LightEntity): _attr_is_on = False _attr_brightness = 255 - def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: + def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: """Initialize the LCN light.""" super().__init__(config, config_entry) @@ -175,7 +172,7 @@ class LcnRelayLight(LcnEntity, LightEntity): _attr_supported_color_modes = {ColorMode.ONOFF} _attr_is_on = False - def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: + def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: """Initialize the LCN light.""" super().__init__(config, config_entry) diff --git a/homeassistant/components/lcn/scene.py b/homeassistant/components/lcn/scene.py index 072d0a2075784..1d6839b5d9132 100644 --- a/homeassistant/components/lcn/scene.py +++ b/homeassistant/components/lcn/scene.py @@ -7,28 +7,26 @@ import pypck from homeassistant.components.scene import DOMAIN as DOMAIN_SCENE, Scene -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DOMAIN, CONF_ENTITIES, CONF_SCENE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from .const import ( - ADD_ENTITIES_CALLBACKS, CONF_DOMAIN_DATA, CONF_OUTPUTS, CONF_REGISTER, CONF_TRANSITION, - DOMAIN, OUTPUT_PORTS, ) from .entity import LcnEntity +from .helpers import LcnConfigEntry PARALLEL_UPDATES = 0 def add_lcn_entities( - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, entity_configs: Iterable[ConfigType], ) -> None: @@ -42,7 +40,7 @@ def add_lcn_entities( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LCN switch entities from a config entry.""" @@ -52,7 +50,7 @@ async def async_setup_entry( async_add_entities, ) - hass.data[DOMAIN][config_entry.entry_id][ADD_ENTITIES_CALLBACKS].update( + config_entry.runtime_data.add_entities_callbacks.update( {DOMAIN_SCENE: add_entities} ) @@ -68,7 +66,7 @@ async def async_setup_entry( class LcnScene(LcnEntity, Scene): """Representation of a LCN scene.""" - def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: + def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: """Initialize the LCN scene.""" super().__init__(config, config_entry) diff --git a/homeassistant/components/lcn/sensor.py b/homeassistant/components/lcn/sensor.py index 0c78ea6637a5d..fd90c02438320 100644 --- a/homeassistant/components/lcn/sensor.py +++ b/homeassistant/components/lcn/sensor.py @@ -11,7 +11,6 @@ SensorDeviceClass, SensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, CONF_DOMAIN, @@ -29,9 +28,7 @@ from homeassistant.helpers.typing import ConfigType from .const import ( - ADD_ENTITIES_CALLBACKS, CONF_DOMAIN_DATA, - DOMAIN, LED_PORTS, S0_INPUTS, SETPOINTS, @@ -39,7 +36,7 @@ VARIABLES, ) from .entity import LcnEntity -from .helpers import InputType +from .helpers import InputType, LcnConfigEntry DEVICE_CLASS_MAPPING = { pypck.lcn_defs.VarUnit.CELSIUS: SensorDeviceClass.TEMPERATURE, @@ -67,7 +64,7 @@ def add_lcn_entities( - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, entity_configs: Iterable[ConfigType], ) -> None: @@ -86,7 +83,7 @@ def add_lcn_entities( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LCN switch entities from a config entry.""" @@ -96,7 +93,7 @@ async def async_setup_entry( async_add_entities, ) - hass.data[DOMAIN][config_entry.entry_id][ADD_ENTITIES_CALLBACKS].update( + config_entry.runtime_data.add_entities_callbacks.update( {DOMAIN_SENSOR: add_entities} ) @@ -112,7 +109,7 @@ async def async_setup_entry( class LcnVariableSensor(LcnEntity, SensorEntity): """Representation of a LCN sensor for variables.""" - def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: + def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: """Initialize the LCN sensor.""" super().__init__(config, config_entry) @@ -157,7 +154,7 @@ def input_received(self, input_obj: InputType) -> None: class LcnLedLogicSensor(LcnEntity, SensorEntity): """Representation of a LCN sensor for leds and logicops.""" - def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: + def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: """Initialize the LCN sensor.""" super().__init__(config, config_entry) diff --git a/homeassistant/components/lcn/services.py b/homeassistant/components/lcn/services.py index 33550d9785d18..15d60639a1cf5 100644 --- a/homeassistant/components/lcn/services.py +++ b/homeassistant/components/lcn/services.py @@ -36,7 +36,6 @@ CONF_TRANSITION, CONF_VALUE, CONF_VARIABLE, - DEVICE_CONNECTIONS, DOMAIN, LED_PORTS, LED_STATUS, @@ -49,7 +48,7 @@ VAR_UNITS, VARIABLES, ) -from .helpers import DeviceConnectionType, is_states_string +from .helpers import DeviceConnectionType, LcnConfigEntry, is_states_string class LcnServiceCall: @@ -68,18 +67,28 @@ def __init__(self, hass: HomeAssistant) -> None: def get_device_connection(self, service: ServiceCall) -> DeviceConnectionType: """Get address connection object.""" + entries: list[LcnConfigEntry] = self.hass.config_entries.async_loaded_entries( + DOMAIN + ) device_id = service.data[CONF_DEVICE_ID] device_registry = dr.async_get(self.hass) - if not (device := device_registry.async_get(device_id)): + if not (device := device_registry.async_get(device_id)) or not ( + entry := next( + ( + entry + for entry in entries + if entry.entry_id == device.primary_config_entry + ), + None, + ) + ): raise ServiceValidationError( translation_domain=DOMAIN, translation_key="invalid_device_id", translation_placeholders={"device_id": device_id}, ) - return self.hass.data[DOMAIN][device.primary_config_entry][DEVICE_CONNECTIONS][ - device_id - ] + return entry.runtime_data.device_connections[device_id] async def async_call_service(self, service: ServiceCall) -> ServiceResponse: """Execute service call.""" diff --git a/homeassistant/components/lcn/switch.py b/homeassistant/components/lcn/switch.py index 6267a081bc9f0..f0bb432fef9fb 100644 --- a/homeassistant/components/lcn/switch.py +++ b/homeassistant/components/lcn/switch.py @@ -7,29 +7,20 @@ import pypck from homeassistant.components.switch import DOMAIN as DOMAIN_SWITCH, SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DOMAIN, CONF_ENTITIES from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType -from .const import ( - ADD_ENTITIES_CALLBACKS, - CONF_DOMAIN_DATA, - CONF_OUTPUT, - DOMAIN, - OUTPUT_PORTS, - RELAY_PORTS, - SETPOINTS, -) +from .const import CONF_DOMAIN_DATA, CONF_OUTPUT, OUTPUT_PORTS, RELAY_PORTS, SETPOINTS from .entity import LcnEntity -from .helpers import InputType +from .helpers import InputType, LcnConfigEntry PARALLEL_UPDATES = 0 def add_lcn_switch_entities( - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, entity_configs: Iterable[ConfigType], ) -> None: @@ -52,7 +43,7 @@ def add_lcn_switch_entities( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LCN switch entities from a config entry.""" @@ -62,7 +53,7 @@ async def async_setup_entry( async_add_entities, ) - hass.data[DOMAIN][config_entry.entry_id][ADD_ENTITIES_CALLBACKS].update( + config_entry.runtime_data.add_entities_callbacks.update( {DOMAIN_SWITCH: add_entities} ) @@ -80,7 +71,7 @@ class LcnOutputSwitch(LcnEntity, SwitchEntity): _attr_is_on = False - def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: + def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: """Initialize the LCN switch.""" super().__init__(config, config_entry) @@ -129,7 +120,7 @@ class LcnRelaySwitch(LcnEntity, SwitchEntity): _attr_is_on = False - def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: + def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: """Initialize the LCN switch.""" super().__init__(config, config_entry) @@ -179,7 +170,7 @@ class LcnRegulatorLockSwitch(LcnEntity, SwitchEntity): _attr_is_on = False - def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: + def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: """Initialize the LCN switch.""" super().__init__(config, config_entry) @@ -235,7 +226,7 @@ class LcnKeyLockSwitch(LcnEntity, SwitchEntity): _attr_is_on = False - def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: + def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: """Initialize the LCN switch.""" super().__init__(config, config_entry) diff --git a/homeassistant/components/lcn/websocket.py b/homeassistant/components/lcn/websocket.py index 545ee1e0043cd..87399afc295bb 100644 --- a/homeassistant/components/lcn/websocket.py +++ b/homeassistant/components/lcn/websocket.py @@ -4,15 +4,17 @@ from collections.abc import Awaitable, Callable from functools import wraps -from typing import TYPE_CHECKING, Any, Final +from typing import Any, Final import lcn_frontend as lcn_panel import voluptuous as vol from homeassistant.components import panel_custom, websocket_api from homeassistant.components.http import StaticPathConfig -from homeassistant.components.websocket_api import AsyncWebSocketCommandHandler -from homeassistant.config_entries import ConfigEntry +from homeassistant.components.websocket_api import ( + ActiveConnection, + AsyncWebSocketCommandHandler, +) from homeassistant.const import ( CONF_ADDRESS, CONF_DEVICES, @@ -28,16 +30,15 @@ ) from .const import ( - ADD_ENTITIES_CALLBACKS, CONF_DOMAIN_DATA, CONF_HARDWARE_SERIAL, CONF_HARDWARE_TYPE, CONF_SOFTWARE_SERIAL, - CONNECTION, DOMAIN, ) from .helpers import ( DeviceConnectionType, + LcnConfigEntry, async_update_device_config, generate_unique_id, get_device_config, @@ -58,11 +59,8 @@ DOMAIN_DATA_SWITCH, ) -if TYPE_CHECKING: - from homeassistant.components.websocket_api import ActiveConnection - type AsyncLcnWebSocketCommandHandler = Callable[ - [HomeAssistant, ActiveConnection, dict[str, Any], ConfigEntry], Awaitable[None] + [HomeAssistant, ActiveConnection, dict[str, Any], LcnConfigEntry], Awaitable[None] ] URL_BASE: Final = "/lcn_static" @@ -127,7 +125,7 @@ async def websocket_get_device_configs( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict, - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, ) -> None: """Get device configs.""" connection.send_result(msg["id"], config_entry.data[CONF_DEVICES]) @@ -147,7 +145,7 @@ async def websocket_get_entity_configs( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict, - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, ) -> None: """Get entities configs.""" if CONF_ADDRESS in msg: @@ -178,10 +176,10 @@ async def websocket_scan_devices( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict, - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, ) -> None: """Scan for new devices.""" - host_connection = hass.data[DOMAIN][config_entry.entry_id][CONNECTION] + host_connection = config_entry.runtime_data.connection await host_connection.scan_modules() for device_connection in host_connection.address_conns.values(): @@ -210,7 +208,7 @@ async def websocket_add_device( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict, - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, ) -> None: """Add a device.""" if get_device_config(msg[CONF_ADDRESS], config_entry): @@ -256,7 +254,7 @@ async def websocket_delete_device( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict, - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, ) -> None: """Delete a device.""" device_config = get_device_config(msg[CONF_ADDRESS], config_entry) @@ -318,7 +316,7 @@ async def websocket_add_entity( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict, - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, ) -> None: """Add an entity.""" if not (device_config := get_device_config(msg[CONF_ADDRESS], config_entry)): @@ -347,9 +345,7 @@ async def websocket_add_entity( } # Create new entity and add to corresponding component - add_entities = hass.data[DOMAIN][msg["entry_id"]][ADD_ENTITIES_CALLBACKS][ - msg[CONF_DOMAIN] - ] + add_entities = config_entry.runtime_data.add_entities_callbacks[msg[CONF_DOMAIN]] add_entities([entity_config]) # Add entity config to config_entry @@ -386,7 +382,7 @@ async def websocket_delete_entity( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict, - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, ) -> None: """Delete an entity.""" entity_config = next( @@ -426,7 +422,7 @@ async def websocket_delete_entity( async def async_create_or_update_device_in_config_entry( hass: HomeAssistant, device_connection: DeviceConnectionType, - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, ) -> None: """Create or update device in config_entry according to given device_connection.""" address = ( @@ -455,7 +451,7 @@ async def async_create_or_update_device_in_config_entry( def get_entity_entry( - hass: HomeAssistant, entity_config: dict, config_entry: ConfigEntry + hass: HomeAssistant, entity_config: dict, config_entry: LcnConfigEntry ) -> er.RegistryEntry | None: """Get entity RegistryEntry from entity_config.""" entity_registry = er.async_get(hass) diff --git a/homeassistant/components/ld2410_ble/__init__.py b/homeassistant/components/ld2410_ble/__init__.py index db67010823dd8..1a9f3cc57e6b3 100644 --- a/homeassistant/components/ld2410_ble/__init__.py +++ b/homeassistant/components/ld2410_ble/__init__.py @@ -11,21 +11,19 @@ from homeassistant.components import bluetooth from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackMatcher -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN from .coordinator import LD2410BLECoordinator -from .models import LD2410BLEData +from .models import LD2410BLEConfigEntry, LD2410BLEData PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: LD2410BLEConfigEntry) -> bool: """Set up LD2410 BLE from a config entry.""" address: str = entry.data[CONF_ADDRESS] @@ -69,9 +67,7 @@ def _async_update_ble( ) ) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = LD2410BLEData( - entry.title, ld2410_ble, coordinator - ) + entry.runtime_data = LD2410BLEData(entry.title, ld2410_ble, coordinator) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) @@ -86,17 +82,17 @@ async def _async_stop(event: Event) -> None: return True -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_listener( + hass: HomeAssistant, entry: LD2410BLEConfigEntry +) -> None: """Handle options update.""" - data: LD2410BLEData = hass.data[DOMAIN][entry.entry_id] - if entry.title != data.title: + if entry.title != entry.runtime_data.title: await hass.config_entries.async_reload(entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: LD2410BLEConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - data: LD2410BLEData = hass.data[DOMAIN].pop(entry.entry_id) - await data.device.stop() + await entry.runtime_data.device.stop() return unload_ok diff --git a/homeassistant/components/ld2410_ble/binary_sensor.py b/homeassistant/components/ld2410_ble/binary_sensor.py index 3ba43e0d6dc50..ef10a2007c5c7 100644 --- a/homeassistant/components/ld2410_ble/binary_sensor.py +++ b/homeassistant/components/ld2410_ble/binary_sensor.py @@ -5,7 +5,6 @@ BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo @@ -13,8 +12,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import LD2410BLE, LD2410BLECoordinator -from .const import DOMAIN -from .models import LD2410BLEData +from .models import LD2410BLEConfigEntry ENTITY_DESCRIPTIONS = ( BinarySensorEntityDescription( @@ -30,11 +28,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LD2410BLEConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the platform for LD2410BLE.""" - data: LD2410BLEData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities( LD2410BLEBinarySensor(data.coordinator, data.device, entry.title, description) for description in ENTITY_DESCRIPTIONS diff --git a/homeassistant/components/ld2410_ble/coordinator.py b/homeassistant/components/ld2410_ble/coordinator.py index b318542e79837..f3d2f544faf57 100644 --- a/homeassistant/components/ld2410_ble/coordinator.py +++ b/homeassistant/components/ld2410_ble/coordinator.py @@ -1,18 +1,23 @@ """Data coordinator for receiving LD2410B updates.""" +from __future__ import annotations + from datetime import datetime import logging import time +from typing import TYPE_CHECKING from ld2410_ble import LD2410BLE, LD2410BLEState -from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN +if TYPE_CHECKING: + from .models import LD2410BLEConfigEntry + _LOGGER = logging.getLogger(__name__) NEVER_TIME = -86400.0 @@ -22,10 +27,13 @@ class LD2410BLECoordinator(DataUpdateCoordinator[None]): """Data coordinator for receiving LD2410B updates.""" - config_entry: ConfigEntry + config_entry: LD2410BLEConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, ld2410_ble: LD2410BLE + self, + hass: HomeAssistant, + config_entry: LD2410BLEConfigEntry, + ld2410_ble: LD2410BLE, ) -> None: """Initialise the coordinator.""" super().__init__( diff --git a/homeassistant/components/ld2410_ble/models.py b/homeassistant/components/ld2410_ble/models.py index a7f5f4f2e3e70..46dd226e30397 100644 --- a/homeassistant/components/ld2410_ble/models.py +++ b/homeassistant/components/ld2410_ble/models.py @@ -6,8 +6,12 @@ from ld2410_ble import LD2410BLE +from homeassistant.config_entries import ConfigEntry + from .coordinator import LD2410BLECoordinator +type LD2410BLEConfigEntry = ConfigEntry[LD2410BLEData] + @dataclass class LD2410BLEData: diff --git a/homeassistant/components/ld2410_ble/sensor.py b/homeassistant/components/ld2410_ble/sensor.py index db4e42580c4ba..87e173e4d1504 100644 --- a/homeassistant/components/ld2410_ble/sensor.py +++ b/homeassistant/components/ld2410_ble/sensor.py @@ -1,12 +1,13 @@ """LD2410 BLE integration sensor platform.""" +from ld2410_ble import LD2410BLE + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfLength from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr @@ -14,9 +15,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import LD2410BLE, LD2410BLECoordinator -from .const import DOMAIN -from .models import LD2410BLEData +from .coordinator import LD2410BLECoordinator +from .models import LD2410BLEConfigEntry MOVING_TARGET_DISTANCE_DESCRIPTION = SensorEntityDescription( key="moving_target_distance", @@ -121,11 +121,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LD2410BLEConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the platform for LD2410BLE.""" - data: LD2410BLEData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities( LD2410BLESensor( data.coordinator, diff --git a/homeassistant/components/leaone/__init__.py b/homeassistant/components/leaone/__init__.py index 74119cfaa4c0b..79ac349c69d4d 100644 --- a/homeassistant/components/leaone/__init__.py +++ b/homeassistant/components/leaone/__init__.py @@ -14,27 +14,26 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN - PLATFORMS: list[Platform] = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) +type LeaoneConfigEntry = ConfigEntry[PassiveBluetoothProcessorCoordinator] + -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: LeaoneConfigEntry) -> bool: """Set up Leaone BLE device from a config entry.""" address = entry.unique_id assert address is not None data = LeaoneBluetoothDeviceData() - coordinator = hass.data.setdefault(DOMAIN, {})[entry.entry_id] = ( - PassiveBluetoothProcessorCoordinator( - hass, - _LOGGER, - address=address, - mode=BluetoothScanningMode.PASSIVE, - update_method=data.update, - ) + coordinator = PassiveBluetoothProcessorCoordinator( + hass, + _LOGGER, + address=address, + mode=BluetoothScanningMode.PASSIVE, + update_method=data.update, ) + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload( coordinator.async_start() @@ -42,9 +41,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: LeaoneConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/leaone/sensor.py b/homeassistant/components/leaone/sensor.py index c815a0964e0e0..db9264b7b89fa 100644 --- a/homeassistant/components/leaone/sensor.py +++ b/homeassistant/components/leaone/sensor.py @@ -4,11 +4,9 @@ from leaone_ble import DeviceClass as LeaoneSensorDeviceClass, SensorUpdate, Units -from homeassistant import config_entries from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothDataProcessor, PassiveBluetoothDataUpdate, - PassiveBluetoothProcessorCoordinator, PassiveBluetoothProcessorEntity, ) from homeassistant.components.sensor import ( @@ -26,7 +24,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info -from .const import DOMAIN +from . import LeaoneConfigEntry from .device import device_key_to_bluetooth_entity_key SENSOR_DESCRIPTIONS = { @@ -106,13 +104,11 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: LeaoneConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Leaone BLE sensors.""" - coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ - entry.entry_id - ] + coordinator = entry.runtime_data processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update) entry.async_on_unload( processor.async_add_entities_listener( diff --git a/homeassistant/components/led_ble/__init__.py b/homeassistant/components/led_ble/__init__.py index 84d7369d70627..7f89ab202acb4 100644 --- a/homeassistant/components/led_ble/__init__.py +++ b/homeassistant/components/led_ble/__init__.py @@ -10,21 +10,20 @@ from homeassistant.components import bluetooth from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackMatcher -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DEVICE_TIMEOUT, DOMAIN, UPDATE_SECONDS -from .models import LEDBLEData +from .const import DEVICE_TIMEOUT, UPDATE_SECONDS +from .models import LEDBLEConfigEntry, LEDBLEData PLATFORMS: list[Platform] = [Platform.LIGHT] _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: LEDBLEConfigEntry) -> bool: """Set up LED BLE from a config entry.""" address: str = entry.data[CONF_ADDRESS] ble_device = bluetooth.async_ble_device_from_address(hass, address.upper(), True) @@ -89,9 +88,7 @@ async def _async_update() -> None: finally: cancel_first_update() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = LEDBLEData( - entry.title, led_ble, coordinator - ) + entry.runtime_data = LEDBLEData(entry.title, led_ble, coordinator) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) @@ -106,17 +103,15 @@ async def _async_stop(event: Event) -> None: return True -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_listener(hass: HomeAssistant, entry: LEDBLEConfigEntry) -> None: """Handle options update.""" - data: LEDBLEData = hass.data[DOMAIN][entry.entry_id] - if entry.title != data.title: + if entry.title != entry.runtime_data.title: await hass.config_entries.async_reload(entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: LEDBLEConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - data: LEDBLEData = hass.data[DOMAIN].pop(entry.entry_id) - await data.device.stop() + await entry.runtime_data.device.stop() return unload_ok diff --git a/homeassistant/components/led_ble/light.py b/homeassistant/components/led_ble/light.py index 2facda734d5cd..89263555a1ed3 100644 --- a/homeassistant/components/led_ble/light.py +++ b/homeassistant/components/led_ble/light.py @@ -15,7 +15,6 @@ LightEntity, LightEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo @@ -25,17 +24,17 @@ DataUpdateCoordinator, ) -from .const import DEFAULT_EFFECT_SPEED, DOMAIN -from .models import LEDBLEData +from .const import DEFAULT_EFFECT_SPEED +from .models import LEDBLEConfigEntry async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LEDBLEConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the light platform for LEDBLE.""" - data: LEDBLEData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities([LEDBLEEntity(data.coordinator, data.device, entry.title)]) diff --git a/homeassistant/components/led_ble/models.py b/homeassistant/components/led_ble/models.py index a8dd3443dce36..077aa9ee7ea6f 100644 --- a/homeassistant/components/led_ble/models.py +++ b/homeassistant/components/led_ble/models.py @@ -6,8 +6,11 @@ from led_ble import LEDBLE +from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +type LEDBLEConfigEntry = ConfigEntry[LEDBLEData] + @dataclass class LEDBLEData: diff --git a/homeassistant/components/lg_netcast/__init__.py b/homeassistant/components/lg_netcast/__init__.py index f6fb834ab114f..c250988976013 100644 --- a/homeassistant/components/lg_netcast/__init__.py +++ b/homeassistant/components/lg_netcast/__init__.py @@ -2,8 +2,10 @@ from typing import Final +from pylgnetcast import LgNetCastClient + from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv @@ -13,21 +15,25 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +type LgNetCastConfigEntry = ConfigEntry[LgNetCastClient] + -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: LgNetCastConfigEntry +) -> bool: """Set up a config entry.""" - hass.data.setdefault(DOMAIN, {}) + host = config_entry.data[CONF_HOST] + access_token = config_entry.data[CONF_ACCESS_TOKEN] + + client = LgNetCastClient(host, access_token) + + config_entry.runtime_data = client await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: LgNetCastConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - if unload_ok: - del hass.data[DOMAIN][entry.entry_id] - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/lg_netcast/device_trigger.py b/homeassistant/components/lg_netcast/device_trigger.py index d1808b3e536b3..c4f48fee4310c 100644 --- a/homeassistant/components/lg_netcast/device_trigger.py +++ b/homeassistant/components/lg_netcast/device_trigger.py @@ -47,14 +47,13 @@ async def async_validate_trigger_config( except ValueError as err: raise InvalidDeviceAutomationConfig(err) from err - if DOMAIN in hass.data: - for config_entry_id in device.config_entries: - if hass.data[DOMAIN].get(config_entry_id): - break - else: - raise InvalidDeviceAutomationConfig( - f"Device {device.id} is not from an existing {DOMAIN} config entry" - ) + if not any( + entry.entry_id in device.config_entries + for entry in hass.config_entries.async_loaded_entries(DOMAIN) + ): + raise InvalidDeviceAutomationConfig( + f"Device {device.id} is not from an existing {DOMAIN} config entry" + ) return config diff --git a/homeassistant/components/lg_netcast/media_player.py b/homeassistant/components/lg_netcast/media_player.py index de652eeef0833..ca533a0e3c330 100644 --- a/homeassistant/components/lg_netcast/media_player.py +++ b/homeassistant/components/lg_netcast/media_player.py @@ -5,7 +5,7 @@ from datetime import datetime from typing import TYPE_CHECKING, Any -from pylgnetcast import LG_COMMAND, LgNetCastClient, LgNetCastError +from pylgnetcast import LG_COMMAND, LgNetCastError from requests import RequestException from homeassistant.components.media_player import ( @@ -15,13 +15,13 @@ MediaPlayerState, MediaType, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_MODEL, CONF_NAME +from homeassistant.const import CONF_MODEL, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.trigger import PluggableAction +from . import LgNetCastConfigEntry from .const import ATTR_MANUFACTURER, DOMAIN from .triggers.turn_on import async_get_turn_on_trigger @@ -46,20 +46,15 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LgNetCastConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a LG Netcast Media Player from a config_entry.""" - - host = config_entry.data[CONF_HOST] - access_token = config_entry.data[CONF_ACCESS_TOKEN] unique_id = config_entry.unique_id name = config_entry.data.get(CONF_NAME, DEFAULT_NAME) model = config_entry.data[CONF_MODEL] - client = LgNetCastClient(host, access_token) - - hass.data[DOMAIN][config_entry.entry_id] = client + client = config_entry.runtime_data async_add_entities([LgTVDevice(client, name, model, unique_id=unique_id)]) diff --git a/homeassistant/components/lg_thinq/manifest.json b/homeassistant/components/lg_thinq/manifest.json index f9cff23b75cbd..0abc74d19a4f2 100644 --- a/homeassistant/components/lg_thinq/manifest.json +++ b/homeassistant/components/lg_thinq/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/lg_thinq", "iot_class": "cloud_push", "loggers": ["thinqconnect"], - "requirements": ["thinqconnect==1.0.5"] + "requirements": ["thinqconnect==1.0.7"] } diff --git a/homeassistant/components/lyric/__init__.py b/homeassistant/components/lyric/__init__.py index f99adf269999a..8ec9785cef250 100644 --- a/homeassistant/components/lyric/__init__.py +++ b/homeassistant/components/lyric/__init__.py @@ -2,25 +2,16 @@ from __future__ import annotations -import asyncio -from datetime import timedelta -from http import HTTPStatus -import logging - -from aiohttp.client_exceptions import ClientResponseError from aiolyric import Lyric -from aiolyric.exceptions import LyricAuthenticationException, LyricException from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import ( aiohttp_client, config_entry_oauth2_flow, config_validation as cv, ) -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .api import ( ConfigEntryLyricClient, @@ -28,11 +19,10 @@ OAuth2SessionLyric, ) from .const import DOMAIN +from .coordinator import LyricDataUpdateCoordinator CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -_LOGGER = logging.getLogger(__name__) - PLATFORMS = [Platform.CLIMATE, Platform.SENSOR] @@ -54,53 +44,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: client_id = implementation.client_id lyric = Lyric(client, client_id) - async def async_update_data(force_refresh_token: bool = False) -> Lyric: - """Fetch data from Lyric.""" - try: - if not force_refresh_token: - await oauth_session.async_ensure_token_valid() - else: - await oauth_session.force_refresh_token() - except ClientResponseError as exception: - if exception.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN): - raise ConfigEntryAuthFailed from exception - raise UpdateFailed(exception) from exception - - try: - async with asyncio.timeout(60): - await lyric.get_locations() - await asyncio.gather( - *( - lyric.get_thermostat_rooms( - location.location_id, device.device_id - ) - for location in lyric.locations - for device in location.devices - if device.device_class == "Thermostat" - and device.device_id.startswith("LCC") - ) - ) - - except LyricAuthenticationException as exception: - # Attempt to refresh the token before failing. - # Honeywell appear to have issues keeping tokens saved. - _LOGGER.debug("Authentication failed. Attempting to refresh token") - if not force_refresh_token: - return await async_update_data(force_refresh_token=True) - raise ConfigEntryAuthFailed from exception - except (LyricException, ClientResponseError) as exception: - raise UpdateFailed(exception) from exception - return lyric - - coordinator = DataUpdateCoordinator[Lyric]( + coordinator = LyricDataUpdateCoordinator( hass, - _LOGGER, config_entry=entry, - # Name of the data. For logging purposes. - name="lyric_coordinator", - update_method=async_update_data, - # Polling interval. Will only be polled if there are subscribers. - update_interval=timedelta(seconds=300), + oauth_session=oauth_session, + lyric=lyric, ) # Fetch initial data so we have data when entities subscribe diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py index ffcf08b927a86..4aeccf991d5fd 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -8,7 +8,6 @@ from time import localtime, strftime, time from typing import Any -from aiolyric import Lyric from aiolyric.objects.device import LyricDevice from aiolyric.objects.location import LyricLocation import voluptuous as vol @@ -37,7 +36,6 @@ from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( DOMAIN, @@ -48,6 +46,7 @@ PRESET_TEMPORARY_HOLD, PRESET_VACATION_HOLD, ) +from .coordinator import LyricDataUpdateCoordinator from .entity import LyricDeviceEntity _LOGGER = logging.getLogger(__name__) @@ -126,7 +125,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Honeywell Lyric climate platform based on a config entry.""" - coordinator: DataUpdateCoordinator[Lyric] = hass.data[DOMAIN][entry.entry_id] + coordinator: LyricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( ( @@ -164,7 +163,7 @@ class LyricThermostatType(enum.Enum): class LyricClimate(LyricDeviceEntity, ClimateEntity): """Defines a Honeywell Lyric climate entity.""" - coordinator: DataUpdateCoordinator[Lyric] + coordinator: LyricDataUpdateCoordinator entity_description: ClimateEntityDescription _attr_name = None @@ -178,7 +177,7 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): def __init__( self, - coordinator: DataUpdateCoordinator[Lyric], + coordinator: LyricDataUpdateCoordinator, description: ClimateEntityDescription, location: LyricLocation, device: LyricDevice, diff --git a/homeassistant/components/lyric/coordinator.py b/homeassistant/components/lyric/coordinator.py new file mode 100644 index 0000000000000..c177e2335165e --- /dev/null +++ b/homeassistant/components/lyric/coordinator.py @@ -0,0 +1,87 @@ +"""The Honeywell Lyric integration.""" + +from __future__ import annotations + +import asyncio +from datetime import timedelta +from http import HTTPStatus +import logging + +from aiohttp.client_exceptions import ClientResponseError +from aiolyric import Lyric +from aiolyric.exceptions import LyricAuthenticationException, LyricException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .api import OAuth2SessionLyric + +_LOGGER = logging.getLogger(__name__) + + +class LyricDataUpdateCoordinator(DataUpdateCoordinator[Lyric]): + """Data update coordinator for Honeywell Lyric.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + oauth_session: OAuth2SessionLyric, + lyric: Lyric, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name="lyric_coordinator", + update_interval=timedelta(seconds=300), + ) + self.oauth_session = oauth_session + self.lyric = lyric + + async def _async_update_data(self) -> Lyric: + """Fetch data from Lyric.""" + return await self._run_update(False) + + async def _run_update(self, force_refresh_token: bool) -> Lyric: + """Fetch data from Lyric.""" + try: + if not force_refresh_token: + await self.oauth_session.async_ensure_token_valid() + else: + await self.oauth_session.force_refresh_token() + except ClientResponseError as exception: + if exception.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN): + raise ConfigEntryAuthFailed from exception + raise UpdateFailed(exception) from exception + + try: + async with asyncio.timeout(60): + await self.lyric.get_locations() + await asyncio.gather( + *( + self.lyric.get_thermostat_rooms( + location.location_id, device.device_id + ) + for location in self.lyric.locations + for device in location.devices + if device.device_class == "Thermostat" + and device.device_id.startswith("LCC") + ) + ) + + except LyricAuthenticationException as exception: + # Attempt to refresh the token before failing. + # Honeywell appear to have issues keeping tokens saved. + _LOGGER.debug("Authentication failed. Attempting to refresh token") + if not force_refresh_token: + return await self._run_update(True) + raise ConfigEntryAuthFailed from exception + except (LyricException, ClientResponseError) as exception: + raise UpdateFailed(exception) from exception + return self.lyric diff --git a/homeassistant/components/lyric/entity.py b/homeassistant/components/lyric/entity.py index 5a5a76f144263..61ba384b861e5 100644 --- a/homeassistant/components/lyric/entity.py +++ b/homeassistant/components/lyric/entity.py @@ -2,27 +2,25 @@ from __future__ import annotations -from aiolyric import Lyric from aiolyric.objects.device import LyricDevice from aiolyric.objects.location import LyricLocation from aiolyric.objects.priority import LyricAccessory, LyricRoom from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .coordinator import LyricDataUpdateCoordinator -class LyricEntity(CoordinatorEntity[DataUpdateCoordinator[Lyric]]): + +class LyricEntity(CoordinatorEntity[LyricDataUpdateCoordinator]): """Defines a base Honeywell Lyric entity.""" _attr_has_entity_name = True def __init__( self, - coordinator: DataUpdateCoordinator[Lyric], + coordinator: LyricDataUpdateCoordinator, location: LyricLocation, device: LyricDevice, key: str, @@ -71,7 +69,7 @@ class LyricAccessoryEntity(LyricDeviceEntity): def __init__( self, - coordinator: DataUpdateCoordinator[Lyric], + coordinator: LyricDataUpdateCoordinator, location: LyricLocation, device: LyricDevice, room: LyricRoom, diff --git a/homeassistant/components/lyric/sensor.py b/homeassistant/components/lyric/sensor.py index 065ee0fba9d0f..ffebb8056cdf5 100644 --- a/homeassistant/components/lyric/sensor.py +++ b/homeassistant/components/lyric/sensor.py @@ -6,7 +6,6 @@ from dataclasses import dataclass from datetime import datetime, timedelta -from aiolyric import Lyric from aiolyric.objects.device import LyricDevice from aiolyric.objects.location import LyricLocation from aiolyric.objects.priority import LyricAccessory, LyricRoom @@ -22,7 +21,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util from .const import ( @@ -33,6 +31,7 @@ PRESET_TEMPORARY_HOLD, PRESET_VACATION_HOLD, ) +from .coordinator import LyricDataUpdateCoordinator from .entity import LyricAccessoryEntity, LyricDeviceEntity LYRIC_SETPOINT_STATUS_NAMES = { @@ -164,7 +163,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Honeywell Lyric sensor platform based on a config entry.""" - coordinator: DataUpdateCoordinator[Lyric] = hass.data[DOMAIN][entry.entry_id] + coordinator: LyricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( LyricSensor( @@ -199,7 +198,7 @@ class LyricSensor(LyricDeviceEntity, SensorEntity): def __init__( self, - coordinator: DataUpdateCoordinator[Lyric], + coordinator: LyricDataUpdateCoordinator, description: LyricSensorEntityDescription, location: LyricLocation, device: LyricDevice, @@ -231,7 +230,7 @@ class LyricAccessorySensor(LyricAccessoryEntity, SensorEntity): def __init__( self, - coordinator: DataUpdateCoordinator[Lyric], + coordinator: LyricDataUpdateCoordinator, description: LyricSensorAccessoryEntityDescription, location: LyricLocation, parentDevice: LyricDevice, diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index 2d04a936ee5b1..95efe46309c7a 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -377,4 +377,34 @@ def _update_from_device(self) -> None: ), allow_multi=True, ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="DishwasherAlarmInflowError", + translation_key="dishwasher_alarm_inflow", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + measurement_to_ha=lambda x: ( + x == clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kInflowError + ), + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.DishwasherAlarm.Attributes.State,), + allow_multi=True, + ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="DishwasherAlarmDoorError", + translation_key="dishwasher_alarm_door", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + measurement_to_ha=lambda x: ( + x == clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kDoorError + ), + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.DishwasherAlarm.Attributes.State,), + allow_multi=True, + ), ] diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index c71a5d07e2472..2d7e28888962e 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -81,9 +81,18 @@ "valve_position": { "default": "mdi:valve" }, + "battery_charge_state": { + "default": "mdi:battery-charging" + }, "battery_replacement_description": { "default": "mdi:battery-sync-outline" }, + "battery_time_remaining": { + "default": "mdi:battery-clock-outline" + }, + "battery_time_to_full_charge": { + "default": "mdi:battery-clock" + }, "evse_state": { "default": "mdi:ev-station" }, diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 9cab1a2c02fa5..0eefb536bcffc 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -38,6 +38,7 @@ UnitOfPower, UnitOfPressure, UnitOfTemperature, + UnitOfTime, UnitOfVolume, UnitOfVolumeFlowRate, ) @@ -84,6 +85,14 @@ clusters.WaterHeaterManagement.Enums.BoostStateEnum.kUnknownEnumValue: None, } +CHARGE_STATE_MAP = { + clusters.PowerSource.Enums.BatChargeStateEnum.kUnknown: None, + clusters.PowerSource.Enums.BatChargeStateEnum.kIsNotCharging: "not_charging", + clusters.PowerSource.Enums.BatChargeStateEnum.kIsCharging: "charging", + clusters.PowerSource.Enums.BatChargeStateEnum.kIsAtFullCharge: "full_charge", + clusters.PowerSource.Enums.BatChargeStateEnum.kUnknownEnumValue: None, +} + ESA_STATE_MAP = { clusters.DeviceEnergyManagement.Enums.ESAStateEnum.kOffline: "offline", clusters.DeviceEnergyManagement.Enums.ESAStateEnum.kOnline: "online", @@ -355,6 +364,47 @@ def _update_from_device(self) -> None: entity_class=MatterSensor, required_attributes=(clusters.PowerSource.Attributes.BatVoltage,), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="PowerSourceBatTimeRemaining", + translation_key="battery_time_remaining", + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(clusters.PowerSource.Attributes.BatTimeRemaining,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="PowerSourceBatChargeState", + translation_key="battery_charge_state", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=[state for state in CHARGE_STATE_MAP.values() if state is not None], + measurement_to_ha=CHARGE_STATE_MAP.get, + ), + entity_class=MatterSensor, + required_attributes=(clusters.PowerSource.Attributes.BatChargeState,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="PowerSourceBatTimeToFullCharge", + translation_key="battery_time_to_full_charge", + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(clusters.PowerSource.Attributes.BatTimeToFullCharge,), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 72e4d8c50b7b9..c713dfba615d6 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -88,6 +88,12 @@ }, "boost_state": { "name": "Boost state" + }, + "dishwasher_alarm_inflow": { + "name": "Inflow alarm" + }, + "dishwasher_alarm_door": { + "name": "Door alarm" } }, "button": { @@ -324,6 +330,20 @@ "battery_replacement_description": { "name": "Battery type" }, + "battery_charge_state": { + "name": "Battery charge state", + "state": { + "charging": "[%key:common::state::charging%]", + "full_charge": "Full charge", + "not_charging": "Not charging" + } + }, + "battery_time_remaining": { + "name": "Time remaining" + }, + "battery_time_to_full_charge": { + "name": "Time to full charge" + }, "battery_voltage": { "name": "Battery voltage" }, diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index cba559a9773c7..fa434588b3480 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -835,6 +835,15 @@ async def shutdown(self) -> None: except InvalidAuthError: self.config_entry.async_start_reauth(self.hass) return + except RpcCallError as err: + # Ignore 404 (No handler for) error + if err.code != 404: + LOGGER.debug( + "Error during shutdown for device %s: %s", + self.name, + err.message, + ) + return except DeviceConnectionError as err: # If the device is restarting or has gone offline before # the ping/pong timeout happens, the shutdown command diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index 29c71973f4228..bac3f03afb844 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -10,6 +10,7 @@ import voluptuous as vol from homeassistant.components.alarm_control_panel import ( + DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, ENTITY_ID_FORMAT, PLATFORM_SCHEMA as ALARM_CONTROL_PANEL_PLATFORM_SCHEMA, AlarmControlPanelEntity, @@ -42,6 +43,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_OBJECT_ID, DOMAIN +from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity from .template_entity import ( LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, @@ -49,6 +51,7 @@ make_template_entity_common_modern_schema, rewrite_common_legacy_to_modern_conf, ) +from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) _VALID_STATES = [ @@ -253,6 +256,13 @@ async def async_setup_platform( ) return + if "coordinator" in discovery_info: + async_add_entities( + TriggerAlarmControlPanelEntity(hass, discovery_info["coordinator"], config) + for config in discovery_info["entities"] + ) + return + _async_create_template_tracking_entities( async_add_entities, hass, @@ -276,8 +286,11 @@ def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-ini self._attr_code_format = config[CONF_CODE_FORMAT].value self._state: AlarmControlPanelState | None = None + self._attr_supported_features: AlarmControlPanelEntityFeature = ( + AlarmControlPanelEntityFeature(0) + ) - def _register_scripts( + def _iterate_scripts( self, config: dict[str, Any] ) -> Generator[ tuple[str, Sequence[dict[str, Any]], AlarmControlPanelEntityFeature | int] @@ -423,8 +436,7 @@ def __init__( if TYPE_CHECKING: assert name is not None - self._attr_supported_features = AlarmControlPanelEntityFeature(0) - for action_id, action_config, supported_feature in self._register_scripts( + for action_id, action_config, supported_feature in self._iterate_scripts( config ): self.add_script(action_id, action_config, name, DOMAIN) @@ -456,3 +468,55 @@ def _async_setup_templates(self) -> None: "_state", self._template, None, self._update_state ) super()._async_setup_templates() + + +class TriggerAlarmControlPanelEntity(TriggerEntity, AbstractTemplateAlarmControlPanel): + """Alarm Control Panel entity based on trigger data.""" + + domain = ALARM_CONTROL_PANEL_DOMAIN + + def __init__( + self, + hass: HomeAssistant, + coordinator: TriggerUpdateCoordinator, + config: ConfigType, + ) -> None: + """Initialize the entity.""" + TriggerEntity.__init__(self, hass, coordinator, config) + AbstractTemplateAlarmControlPanel.__init__(self, config) + + self._attr_name = name = self._rendered.get(CONF_NAME, DEFAULT_NAME) + + if isinstance(config.get(CONF_STATE), template.Template): + self._to_render_simple.append(CONF_STATE) + self._parse_result.add(CONF_STATE) + + for action_id, action_config, supported_feature in self._iterate_scripts( + config + ): + self.add_script(action_id, action_config, name, DOMAIN) + self._attr_supported_features |= supported_feature + + self._attr_device_info = async_device_info_to_link_from_device_id( + hass, + config.get(CONF_DEVICE_ID), + ) + + async def async_added_to_hass(self) -> None: + """Restore last state.""" + await super().async_added_to_hass() + await self._async_handle_restored_state() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle update of the data.""" + self._process_data() + + if not self.available: + self.async_write_ha_state() + return + + if (rendered := self._rendered.get(CONF_STATE)) is not None: + self._handle_state(rendered) + self.async_set_context(self.coordinator.data["context"]) + self.async_write_ha_state() diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index e87c9aee98975..1e1a27e26c6de 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -157,10 +157,8 @@ def _backward_compat_schema(value: Any | None) -> Any: }, ), ensure_domains_do_not_have_trigger_or_action( - DOMAIN_ALARM_CONTROL_PANEL, DOMAIN_BUTTON, DOMAIN_FAN, - DOMAIN_LOCK, DOMAIN_VACUUM, ), ) diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index 8ed8a004e922a..1ec8b7f753500 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant.components.lock import ( + DOMAIN as LOCK_DOMAIN, PLATFORM_SCHEMA as LOCK_PLATFORM_SCHEMA, LockEntity, LockEntityFeature, @@ -23,11 +24,12 @@ ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError, TemplateError -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_PICTURE, DOMAIN +from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity from .template_entity import ( LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, @@ -36,6 +38,7 @@ make_template_entity_common_modern_schema, rewrite_common_legacy_to_modern_conf, ) +from .trigger_entity import TriggerEntity CONF_CODE_FORMAT_TEMPLATE = "code_format_template" CONF_CODE_FORMAT = "code_format" @@ -123,6 +126,13 @@ async def async_setup_platform( ) return + if "coordinator" in discovery_info: + async_add_entities( + TriggerLockEntity(hass, discovery_info["coordinator"], config) + for config in discovery_info["entities"] + ) + return + _async_create_template_tracking_entities( async_add_entities, hass, @@ -147,7 +157,7 @@ def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-ini self._optimistic = config.get(CONF_OPTIMISTIC) self._attr_assumed_state = bool(self._optimistic) - def _register_scripts( + def _iterate_scripts( self, config: dict[str, Any] ) -> Generator[tuple[str, Sequence[dict[str, Any]], LockEntityFeature | int]]: for action_id, supported_feature in ( @@ -314,7 +324,7 @@ def __init__( if TYPE_CHECKING: assert name is not None - for action_id, action_config, supported_feature in self._register_scripts( + for action_id, action_config, supported_feature in self._iterate_scripts( config ): self.add_script(action_id, action_config, name, DOMAIN) @@ -346,3 +356,60 @@ def _async_setup_templates(self) -> None: self._update_code_format, ) super()._async_setup_templates() + + +class TriggerLockEntity(TriggerEntity, AbstractTemplateLock): + """Lock entity based on trigger data.""" + + domain = LOCK_DOMAIN + extra_template_keys = (CONF_STATE,) + + def __init__( + self, + hass: HomeAssistant, + coordinator: TriggerUpdateCoordinator, + config: ConfigType, + ) -> None: + """Initialize the entity.""" + TriggerEntity.__init__(self, hass, coordinator, config) + AbstractTemplateLock.__init__(self, config) + + self._attr_name = name = self._rendered.get(CONF_NAME, DEFAULT_NAME) + + if isinstance(config.get(CONF_CODE_FORMAT), template.Template): + self._to_render_simple.append(CONF_CODE_FORMAT) + self._parse_result.add(CONF_CODE_FORMAT) + + for action_id, action_config, supported_feature in self._iterate_scripts( + config + ): + self.add_script(action_id, action_config, name, DOMAIN) + self._attr_supported_features |= supported_feature + + @callback + def _handle_coordinator_update(self) -> None: + """Handle update of the data.""" + self._process_data() + + if not self.available: + self.async_write_ha_state() + return + + write_ha_state = False + for key, updater in ( + (CONF_STATE, self._handle_state), + (CONF_CODE_FORMAT, self._update_code_format), + ): + if (rendered := self._rendered.get(key)) is not None: + updater(rendered) + write_ha_state = True + + if not self._optimistic: + self.async_set_context(self.coordinator.data["context"]) + write_ha_state = True + elif self._optimistic and len(self._rendered) > 0: + # In case any non optimistic template + write_ha_state = True + + if write_ha_state: + self.async_write_ha_state() diff --git a/homeassistant/components/tilt_pi/__init__.py b/homeassistant/components/tilt_pi/__init__.py new file mode 100644 index 0000000000000..6b292aed3021e --- /dev/null +++ b/homeassistant/components/tilt_pi/__init__.py @@ -0,0 +1,28 @@ +"""The Tilt Pi integration.""" + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import TiltPiConfigEntry, TiltPiDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: TiltPiConfigEntry) -> bool: + """Set up Tilt Pi from a config entry.""" + coordinator = TiltPiDataUpdateCoordinator( + hass, + entry, + ) + + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: TiltPiConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/tilt_pi/config_flow.py b/homeassistant/components/tilt_pi/config_flow.py new file mode 100644 index 0000000000000..7770ce372d88c --- /dev/null +++ b/homeassistant/components/tilt_pi/config_flow.py @@ -0,0 +1,63 @@ +"""Config flow for Tilt Pi integration.""" + +from typing import Any + +import aiohttp +from tiltpi import TiltPiClient, TiltPiError +import voluptuous as vol +from yarl import URL + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_URL +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + + +class TiltPiConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Tilt Pi.""" + + async def _check_connection(self, host: str, port: int) -> str | None: + """Check if we can connect to the TiltPi instance.""" + client = TiltPiClient( + host, + port, + session=async_get_clientsession(self.hass), + ) + try: + await client.get_hydrometers() + except (TiltPiError, TimeoutError, aiohttp.ClientError): + return "cannot_connect" + return None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a configuration flow initialized by the user.""" + + errors = {} + if user_input is not None: + url = URL(user_input[CONF_URL]) + if (host := url.host) is None: + errors[CONF_URL] = "invalid_host" + else: + self._async_abort_entries_match({CONF_HOST: host}) + port = url.port + assert port + error = await self._check_connection(host=host, port=port) + if error: + errors["base"] = error + else: + return self.async_create_entry( + title="Tilt Pi", + data={ + CONF_HOST: host, + CONF_PORT: port, + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_URL): str}), + errors=errors, + ) diff --git a/homeassistant/components/tilt_pi/const.py b/homeassistant/components/tilt_pi/const.py new file mode 100644 index 0000000000000..a60b737c20f1d --- /dev/null +++ b/homeassistant/components/tilt_pi/const.py @@ -0,0 +1,8 @@ +"""Constants for the Tilt Pi integration.""" + +import logging +from typing import Final + +LOGGER = logging.getLogger(__package__) + +DOMAIN: Final = "tilt_pi" diff --git a/homeassistant/components/tilt_pi/coordinator.py b/homeassistant/components/tilt_pi/coordinator.py new file mode 100644 index 0000000000000..e2b14861a893b --- /dev/null +++ b/homeassistant/components/tilt_pi/coordinator.py @@ -0,0 +1,53 @@ +"""Data update coordinator for Tilt Pi.""" + +from datetime import timedelta +from typing import Final + +from tiltpi import TiltHydrometerData, TiltPiClient, TiltPiError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import LOGGER + +SCAN_INTERVAL: Final = timedelta(seconds=60) + +type TiltPiConfigEntry = ConfigEntry[TiltPiDataUpdateCoordinator] + + +class TiltPiDataUpdateCoordinator(DataUpdateCoordinator[dict[str, TiltHydrometerData]]): + """Class to manage fetching Tilt Pi data.""" + + config_entry: TiltPiConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: TiltPiConfigEntry, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name="Tilt Pi", + update_interval=SCAN_INTERVAL, + ) + self._api = TiltPiClient( + host=config_entry.data[CONF_HOST], + port=config_entry.data[CONF_PORT], + session=async_get_clientsession(hass), + ) + self.identifier = config_entry.entry_id + + async def _async_update_data(self) -> dict[str, TiltHydrometerData]: + """Fetch data from Tilt Pi and return as a dict keyed by mac_id.""" + try: + hydrometers = await self._api.get_hydrometers() + except TiltPiError as err: + raise UpdateFailed(f"Error communicating with Tilt Pi: {err}") from err + + return {h.mac_id: h for h in hydrometers} diff --git a/homeassistant/components/tilt_pi/entity.py b/homeassistant/components/tilt_pi/entity.py new file mode 100644 index 0000000000000..c1cf89138433c --- /dev/null +++ b/homeassistant/components/tilt_pi/entity.py @@ -0,0 +1,39 @@ +"""Base entity for Tilt Pi integration.""" + +from tiltpi import TiltHydrometerData + +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import TiltPiDataUpdateCoordinator + + +class TiltEntity(CoordinatorEntity[TiltPiDataUpdateCoordinator]): + """Base class for Tilt entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: TiltPiDataUpdateCoordinator, + hydrometer: TiltHydrometerData, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._mac_id = hydrometer.mac_id + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, hydrometer.mac_id)}, + name=f"Tilt {hydrometer.color}", + manufacturer="Tilt Hydrometer", + model=f"{hydrometer.color} Tilt Hydrometer", + ) + + @property + def current_hydrometer(self) -> TiltHydrometerData: + """Return the current hydrometer data for this entity.""" + return self.coordinator.data[self._mac_id] + + @property + def available(self) -> bool: + """Return True if the hydrometer is available (present in coordinator data).""" + return super().available and self._mac_id in self.coordinator.data diff --git a/homeassistant/components/tilt_pi/icons.json b/homeassistant/components/tilt_pi/icons.json new file mode 100644 index 0000000000000..1f23c518c3856 --- /dev/null +++ b/homeassistant/components/tilt_pi/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "sensor": { + "gravity": { + "default": "mdi:water" + } + } + } +} diff --git a/homeassistant/components/tilt_pi/manifest.json b/homeassistant/components/tilt_pi/manifest.json new file mode 100644 index 0000000000000..94c6b7ade8660 --- /dev/null +++ b/homeassistant/components/tilt_pi/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "tilt_pi", + "name": "Tilt Pi", + "codeowners": ["@michaelheyman"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/tilt_pi", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["tilt-pi==0.2.1"] +} diff --git a/homeassistant/components/tilt_pi/quality_scale.yaml b/homeassistant/components/tilt_pi/quality_scale.yaml new file mode 100644 index 0000000000000..725ff9710670e --- /dev/null +++ b/homeassistant/components/tilt_pi/quality_scale.yaml @@ -0,0 +1,80 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + No custom actions are defined. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options to configure + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + test-coverage: done + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: + status: done + comment: | + The entities are categorized well by using default category. + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: No disabled entities implemented + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + No repairs/issues. + stale-devices: todo + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/tilt_pi/sensor.py b/homeassistant/components/tilt_pi/sensor.py new file mode 100644 index 0000000000000..4ce40e70bdb9c --- /dev/null +++ b/homeassistant/components/tilt_pi/sensor.py @@ -0,0 +1,93 @@ +"""Support for Tilt Hydrometer sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Final + +from tiltpi import TiltHydrometerData + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import TiltPiConfigEntry, TiltPiDataUpdateCoordinator +from .entity import TiltEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + +ATTR_TEMPERATURE = "temperature" +ATTR_GRAVITY = "gravity" + + +@dataclass(frozen=True, kw_only=True) +class TiltEntityDescription(SensorEntityDescription): + """Describes TiltHydrometerData sensor entity.""" + + value_fn: Callable[[TiltHydrometerData], StateType] + + +SENSOR_TYPES: Final[list[TiltEntityDescription]] = [ + TiltEntityDescription( + key=ATTR_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.temperature, + ), + TiltEntityDescription( + key=ATTR_GRAVITY, + translation_key="gravity", + native_unit_of_measurement="SG", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.gravity, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: TiltPiConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Tilt Hydrometer sensors.""" + coordinator = config_entry.runtime_data + + async_add_entities( + TiltSensor( + coordinator, + description, + hydrometer, + ) + for description in SENSOR_TYPES + for hydrometer in coordinator.data.values() + ) + + +class TiltSensor(TiltEntity, SensorEntity): + """Defines a Tilt sensor.""" + + entity_description: TiltEntityDescription + + def __init__( + self, + coordinator: TiltPiDataUpdateCoordinator, + description: TiltEntityDescription, + hydrometer: TiltHydrometerData, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, hydrometer) + self.entity_description = description + self._attr_unique_id = f"{hydrometer.mac_id}_{description.key}" + + @property + def native_value(self) -> StateType: + """Return the sensor value.""" + return self.entity_description.value_fn(self.current_hydrometer) diff --git a/homeassistant/components/tilt_pi/strings.json b/homeassistant/components/tilt_pi/strings.json new file mode 100644 index 0000000000000..9af85c86641ca --- /dev/null +++ b/homeassistant/components/tilt_pi/strings.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "confirm": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + }, + "user": { + "data": { + "url": "[%key:common::config_flow::data::url%]" + }, + "data_description": { + "url": "The URL of the Tilt Pi instance." + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]" + } + }, + "entity": { + "sensor": { + "gravity": { + "name": "Gravity" + } + } + } +} diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py index 2bc5949b97006..30058bb056c84 100644 --- a/homeassistant/components/trend/binary_sensor.py +++ b/homeassistant/components/trend/binary_sensor.py @@ -27,6 +27,7 @@ CONF_ENTITY_ID, CONF_FRIENDLY_NAME, CONF_SENSORS, + CONF_UNIQUE_ID, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -89,6 +90,7 @@ def _validate_min_max(data: dict[str, Any]) -> dict[str, Any]: vol.Optional(CONF_MIN_GRADIENT, default=0.0): vol.Coerce(float), vol.Optional(CONF_SAMPLE_DURATION, default=0): cv.positive_int, vol.Optional(CONF_MIN_SAMPLES, default=2): cv.positive_int, + vol.Optional(CONF_UNIQUE_ID): cv.string, } ), _validate_min_max, @@ -121,6 +123,7 @@ async def async_setup_platform( min_samples=sensor_config[CONF_MIN_SAMPLES], max_samples=sensor_config[CONF_MAX_SAMPLES], device_class=sensor_config.get(CONF_DEVICE_CLASS), + unique_id=sensor_config.get(CONF_UNIQUE_ID), sensor_entity_id=generate_entity_id( ENTITY_ID_FORMAT, sensor_name, hass=hass ), diff --git a/homeassistant/components/wallbox/const.py b/homeassistant/components/wallbox/const.py index 5aa659a052742..34d17e5227561 100644 --- a/homeassistant/components/wallbox/const.py +++ b/homeassistant/components/wallbox/const.py @@ -3,7 +3,7 @@ from enum import StrEnum DOMAIN = "wallbox" -UPDATE_INTERVAL = 30 +UPDATE_INTERVAL = 60 BIDIRECTIONAL_MODEL_PREFIXES = ["QS"] diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index 8276ee14eafe7..598bfa7429ae4 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -90,7 +90,9 @@ def require_authentication( except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == HTTPStatus.FORBIDDEN: raise ConfigEntryAuthFailed from wallbox_connection_error - raise ConnectionError from wallbox_connection_error + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="api_failed" + ) from wallbox_connection_error return require_authentication @@ -137,56 +139,65 @@ def authenticate(self) -> None: @_require_authentication def _get_data(self) -> dict[str, Any]: """Get new sensor data for Wallbox component.""" - data: dict[str, Any] = self._wallbox.getChargerStatus(self._station) - data[CHARGER_MAX_CHARGING_CURRENT_KEY] = data[CHARGER_DATA_KEY][ - CHARGER_MAX_CHARGING_CURRENT_KEY - ] - data[CHARGER_LOCKED_UNLOCKED_KEY] = data[CHARGER_DATA_KEY][ - CHARGER_LOCKED_UNLOCKED_KEY - ] - data[CHARGER_ENERGY_PRICE_KEY] = data[CHARGER_DATA_KEY][ - CHARGER_ENERGY_PRICE_KEY - ] - # Only show max_icp_current if power_boost is available in the wallbox unit: - if ( - data[CHARGER_DATA_KEY].get(CHARGER_MAX_ICP_CURRENT_KEY, 0) > 0 - and CHARGER_POWER_BOOST_KEY - in data[CHARGER_DATA_KEY][CHARGER_PLAN_KEY][CHARGER_FEATURES_KEY] - ): - data[CHARGER_MAX_ICP_CURRENT_KEY] = data[CHARGER_DATA_KEY][ - CHARGER_MAX_ICP_CURRENT_KEY + try: + data: dict[str, Any] = self._wallbox.getChargerStatus(self._station) + data[CHARGER_MAX_CHARGING_CURRENT_KEY] = data[CHARGER_DATA_KEY][ + CHARGER_MAX_CHARGING_CURRENT_KEY ] - - data[CHARGER_CURRENCY_KEY] = ( - f"{data[CHARGER_DATA_KEY][CHARGER_CURRENCY_KEY][CODE_KEY]}/kWh" - ) - - data[CHARGER_STATUS_DESCRIPTION_KEY] = CHARGER_STATUS.get( - data[CHARGER_STATUS_ID_KEY], ChargerStatus.UNKNOWN - ) - - # Set current solar charging mode - eco_smart_enabled = ( - data[CHARGER_DATA_KEY] - .get(CHARGER_ECO_SMART_KEY, {}) - .get(CHARGER_ECO_SMART_STATUS_KEY) - ) - - eco_smart_mode = ( - data[CHARGER_DATA_KEY] - .get(CHARGER_ECO_SMART_KEY, {}) - .get(CHARGER_ECO_SMART_MODE_KEY) - ) - if eco_smart_mode is None: - data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.DISABLED - elif eco_smart_enabled is False: - data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.OFF - elif eco_smart_mode == 0: - data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.ECO_MODE - elif eco_smart_mode == 1: - data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.FULL_SOLAR - - return data + data[CHARGER_LOCKED_UNLOCKED_KEY] = data[CHARGER_DATA_KEY][ + CHARGER_LOCKED_UNLOCKED_KEY + ] + data[CHARGER_ENERGY_PRICE_KEY] = data[CHARGER_DATA_KEY][ + CHARGER_ENERGY_PRICE_KEY + ] + # Only show max_icp_current if power_boost is available in the wallbox unit: + if ( + data[CHARGER_DATA_KEY].get(CHARGER_MAX_ICP_CURRENT_KEY, 0) > 0 + and CHARGER_POWER_BOOST_KEY + in data[CHARGER_DATA_KEY][CHARGER_PLAN_KEY][CHARGER_FEATURES_KEY] + ): + data[CHARGER_MAX_ICP_CURRENT_KEY] = data[CHARGER_DATA_KEY][ + CHARGER_MAX_ICP_CURRENT_KEY + ] + + data[CHARGER_CURRENCY_KEY] = ( + f"{data[CHARGER_DATA_KEY][CHARGER_CURRENCY_KEY][CODE_KEY]}/kWh" + ) + + data[CHARGER_STATUS_DESCRIPTION_KEY] = CHARGER_STATUS.get( + data[CHARGER_STATUS_ID_KEY], ChargerStatus.UNKNOWN + ) + + # Set current solar charging mode + eco_smart_enabled = ( + data[CHARGER_DATA_KEY] + .get(CHARGER_ECO_SMART_KEY, {}) + .get(CHARGER_ECO_SMART_STATUS_KEY) + ) + + eco_smart_mode = ( + data[CHARGER_DATA_KEY] + .get(CHARGER_ECO_SMART_KEY, {}) + .get(CHARGER_ECO_SMART_MODE_KEY) + ) + if eco_smart_mode is None: + data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.DISABLED + elif eco_smart_enabled is False: + data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.OFF + elif eco_smart_mode == 0: + data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.ECO_MODE + elif eco_smart_mode == 1: + data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.FULL_SOLAR + + return data # noqa: TRY300 + except requests.exceptions.HTTPError as wallbox_connection_error: + if wallbox_connection_error.response.status_code == 429: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="too_many_requests" + ) from wallbox_connection_error + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="api_failed" + ) from wallbox_connection_error async def _async_update_data(self) -> dict[str, Any]: """Get new sensor data for Wallbox component.""" @@ -200,7 +211,13 @@ def _set_charging_current(self, charging_current: float) -> None: except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: raise InvalidAuth from wallbox_connection_error - raise + if wallbox_connection_error.response.status_code == 429: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="too_many_requests" + ) from wallbox_connection_error + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="api_failed" + ) from wallbox_connection_error async def async_set_charging_current(self, charging_current: float) -> None: """Set maximum charging current for Wallbox.""" @@ -217,7 +234,13 @@ def _set_icp_current(self, icp_current: float) -> None: except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: raise InvalidAuth from wallbox_connection_error - raise + if wallbox_connection_error.response.status_code == 429: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="too_many_requests" + ) from wallbox_connection_error + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="api_failed" + ) from wallbox_connection_error async def async_set_icp_current(self, icp_current: float) -> None: """Set maximum icp current for Wallbox.""" @@ -227,8 +250,16 @@ async def async_set_icp_current(self, icp_current: float) -> None: @_require_authentication def _set_energy_cost(self, energy_cost: float) -> None: """Set energy cost for Wallbox.""" - - self._wallbox.setEnergyCost(self._station, energy_cost) + try: + self._wallbox.setEnergyCost(self._station, energy_cost) + except requests.exceptions.HTTPError as wallbox_connection_error: + if wallbox_connection_error.response.status_code == 429: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="too_many_requests" + ) from wallbox_connection_error + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="api_failed" + ) from wallbox_connection_error async def async_set_energy_cost(self, energy_cost: float) -> None: """Set energy cost for Wallbox.""" @@ -246,7 +277,13 @@ def _set_lock_unlock(self, lock: bool) -> None: except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: raise InvalidAuth from wallbox_connection_error - raise + if wallbox_connection_error.response.status_code == 429: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="too_many_requests" + ) from wallbox_connection_error + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="api_failed" + ) from wallbox_connection_error async def async_set_lock_unlock(self, lock: bool) -> None: """Set wallbox to locked or unlocked.""" @@ -256,11 +293,19 @@ async def async_set_lock_unlock(self, lock: bool) -> None: @_require_authentication def _pause_charger(self, pause: bool) -> None: """Set wallbox to pause or resume.""" - - if pause: - self._wallbox.pauseChargingSession(self._station) - else: - self._wallbox.resumeChargingSession(self._station) + try: + if pause: + self._wallbox.pauseChargingSession(self._station) + else: + self._wallbox.resumeChargingSession(self._station) + except requests.exceptions.HTTPError as wallbox_connection_error: + if wallbox_connection_error.response.status_code == 429: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="too_many_requests" + ) from wallbox_connection_error + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="api_failed" + ) from wallbox_connection_error async def async_pause_charger(self, pause: bool) -> None: """Set wallbox to pause or resume.""" @@ -270,13 +315,21 @@ async def async_pause_charger(self, pause: bool) -> None: @_require_authentication def _set_eco_smart(self, option: str) -> None: """Set wallbox solar charging mode.""" - - if option == EcoSmartMode.ECO_MODE: - self._wallbox.enableEcoSmart(self._station, 0) - elif option == EcoSmartMode.FULL_SOLAR: - self._wallbox.enableEcoSmart(self._station, 1) - else: - self._wallbox.disableEcoSmart(self._station) + try: + if option == EcoSmartMode.ECO_MODE: + self._wallbox.enableEcoSmart(self._station, 0) + elif option == EcoSmartMode.FULL_SOLAR: + self._wallbox.enableEcoSmart(self._station, 1) + else: + self._wallbox.disableEcoSmart(self._station) + except requests.exceptions.HTTPError as wallbox_connection_error: + if wallbox_connection_error.response.status_code == 429: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="too_many_requests" + ) from wallbox_connection_error + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="api_failed" + ) from wallbox_connection_error async def async_set_eco_smart(self, option: str) -> None: """Set wallbox solar charging mode.""" diff --git a/homeassistant/components/wallbox/lock.py b/homeassistant/components/wallbox/lock.py index ef35734ed7e27..7acc56f67f211 100644 --- a/homeassistant/components/wallbox/lock.py +++ b/homeassistant/components/wallbox/lock.py @@ -7,7 +7,7 @@ from homeassistant.components.lock import LockEntity, LockEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady +from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( @@ -41,7 +41,7 @@ async def async_setup_entry( ) except InvalidAuth: return - except ConnectionError as exc: + except HomeAssistantError as exc: raise PlatformNotReady from exc async_add_entities( diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py index a5880f6e0f727..8077347858244 100644 --- a/homeassistant/components/wallbox/number.py +++ b/homeassistant/components/wallbox/number.py @@ -12,7 +12,7 @@ from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady +from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( @@ -93,7 +93,7 @@ async def async_setup_entry( ) except InvalidAuth: return - except ConnectionError as exc: + except HomeAssistantError as exc: raise PlatformNotReady from exc async_add_entities( diff --git a/homeassistant/components/wallbox/sensor.py b/homeassistant/components/wallbox/sensor.py index 4b0ec8175e35f..e19fc2b936a13 100644 --- a/homeassistant/components/wallbox/sensor.py +++ b/homeassistant/components/wallbox/sensor.py @@ -3,7 +3,6 @@ from __future__ import annotations from dataclasses import dataclass -import logging from typing import cast from homeassistant.components.sensor import ( @@ -49,11 +48,6 @@ from .coordinator import WallboxCoordinator from .entity import WallboxEntity -CHARGER_STATION = "station" -UPDATE_INTERVAL = 30 - -_LOGGER = logging.getLogger(__name__) - @dataclass(frozen=True) class WallboxSensorEntityDescription(SensorEntityDescription): diff --git a/homeassistant/components/wallbox/strings.json b/homeassistant/components/wallbox/strings.json index 68602a960c208..ee98a4855e300 100644 --- a/homeassistant/components/wallbox/strings.json +++ b/homeassistant/components/wallbox/strings.json @@ -112,6 +112,9 @@ "exceptions": { "api_failed": { "message": "Error communicating with Wallbox API" + }, + "too_many_requests": { + "message": "Error communicating with Wallbox API, too many requests" } } } diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json index 74e2259f05009..779b830637b54 100644 --- a/homeassistant/components/yolink/manifest.json +++ b/homeassistant/components/yolink/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["auth", "application_credentials"], "documentation": "https://www.home-assistant.io/integrations/yolink", "iot_class": "cloud_push", - "requirements": ["yolink-api==0.5.2"] + "requirements": ["yolink-api==0.5.5"] } diff --git a/homeassistant/components/yolink/valve.py b/homeassistant/components/yolink/valve.py index 0e8a5e6185562..06dee8af5404f 100644 --- a/homeassistant/components/yolink/valve.py +++ b/homeassistant/components/yolink/valve.py @@ -155,7 +155,10 @@ async def async_close_valve(self) -> None: @property def available(self) -> bool: """Return true is device is available.""" - if self.coordinator.dev_net_type is not None: + if ( + self.coordinator.device.is_support_mode_switching() + and self.coordinator.dev_net_type is not None + ): # When the device operates in Class A mode, it cannot be controlled. return self.coordinator.dev_net_type != ATTR_DEVICE_MODEL_A return super().available diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 119830b611150..b9dfefd3327f6 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -48,6 +48,7 @@ "airzone_cloud", "alarmdecoder", "alexa_devices", + "altruist", "amberelectric", "ambient_network", "ambient_station", @@ -647,6 +648,7 @@ "tibber", "tile", "tilt_ble", + "tilt_pi", "time_date", "todoist", "tolo", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 3795bd838ea14..b3918ac8ded62 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -204,6 +204,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "altruist": { + "name": "Altruist", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "amazon": { "name": "Amazon", "integrations": { @@ -6426,7 +6432,10 @@ "iot_class": "cloud_polling", "name": "SwitchBot Cloud" } - } + }, + "iot_standards": [ + "matter" + ] }, "switcher_kis": { "name": "Switcher", @@ -6742,11 +6751,22 @@ "config_flow": true, "iot_class": "cloud_polling" }, - "tilt_ble": { - "name": "Tilt Hydrometer BLE", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_push" + "tilt": { + "name": "Tilt", + "integrations": { + "tilt_ble": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push", + "name": "Tilt Hydrometer BLE" + }, + "tilt_pi": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling", + "name": "Tilt Pi" + } + } }, "time_date": { "integration_type": "service", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index e675a0bb23738..21abaa2a57911 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -342,6 +342,11 @@ "domain": "apple_tv", }, ], + "_altruist._tcp.local.": [ + { + "domain": "altruist", + }, + ], "_amzn-alexa._tcp.local.": [ { "domain": "roomba", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3eb77beed939a..e47c5f7d66cec 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -46,7 +46,6 @@ ifaddr==0.2.0 Jinja2==3.1.6 lru-dict==1.3.0 mutagen==1.47.0 -numpy==2.3.0 orjson==3.10.18 packaging>=23.1 paho-mqtt==2.1.0 diff --git a/mypy.ini b/mypy.ini index 1fdab75663ea2..72e52b67959a4 100644 --- a/mypy.ini +++ b/mypy.ini @@ -425,6 +425,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.altruist.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.amazon_polly.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pyproject.toml b/pyproject.toml index 4295c23740f58..f7ac7476d1b91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,41 +46,16 @@ dependencies = [ "ciso8601==2.3.2", "cronsim==2.6", "fnv-hash-fast==1.5.0", - # ha-ffmpeg is indirectly imported from onboarding via the import chain - # onboarding->cloud->assist_pipeline->tts->ffmpeg. Onboarding needs - # to be setup in stage 0, but we don't want to also promote cloud with all its - # dependencies to stage 0. - "ha-ffmpeg==3.2.2", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration "hass-nabucasa==0.103.0", - # hassil is indirectly imported from onboarding via the import chain - # onboarding->cloud->assist_pipeline->conversation->hassil. Onboarding needs - # to be setup in stage 0, but we don't want to also promote cloud with all its - # dependencies to stage 0. - "hassil==2.2.3", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", "home-assistant-bluetooth==1.13.1", - # home_assistant_intents is indirectly imported from onboarding via the import chain - # onboarding->cloud->assist_pipeline->conversation->home_assistant_intents. Onboarding needs - # to be setup in stage 0, but we don't want to also promote cloud with all its - # dependencies to stage 0. - "home-assistant-intents==2025.6.10", "ifaddr==0.2.0", "Jinja2==3.1.6", "lru-dict==1.3.0", - # mutagen is indirectly imported from onboarding via the import chain - # onboarding->cloud->assist_pipeline->tts->mutagen. Onboarding needs - # to be setup in stage 0, but we don't want to also promote cloud with all its - # dependencies to stage 0. - "mutagen==1.47.0", - # numpy is indirectly imported from onboarding via the import chain - # onboarding->cloud->alexa->camera->stream->numpy. Onboarding needs - # to be setup in stage 0, but we don't want to also promote cloud with all its - # dependencies to stage 0. - "numpy==2.3.0", "PyJWT==2.10.1", # PyJWT has loose dependency. We want the latest one. "cryptography==45.0.3", @@ -90,22 +65,7 @@ dependencies = [ "orjson==3.10.18", "packaging>=23.1", "psutil-home-assistant==0.0.1", - # pymicro_vad is indirectly imported from onboarding via the import chain - # onboarding->cloud->assist_pipeline->pymicro_vad. Onboarding needs - # to be setup in stage 0, but we don't want to also promote cloud with all its - # dependencies to stage 0. - "pymicro-vad==1.0.1", - # pyspeex-noise is indirectly imported from onboarding via the import chain - # onboarding->cloud->assist_pipeline->pyspeex_noise. Onboarding needs - # to be setup in stage 0, but we don't want to also promote cloud with all its - # dependencies to stage 0. - "pyspeex-noise==1.0.2", "python-slugify==8.0.4", - # PyTurboJPEG is indirectly imported from onboarding via the import chain - # onboarding->cloud->camera->pyturbojpeg. Onboarding needs - # to be setup in stage 0, but we don't want to also promote cloud with all its - # dependencies to stage 0. - "PyTurboJPEG==1.8.0", "PyYAML==6.0.2", "requests==2.32.4", "securetar==2025.2.1", diff --git a/requirements.txt b/requirements.txt index b47d33e7a4493..39ec6dd87ddda 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,17 +23,12 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.5.0 -ha-ffmpeg==3.2.2 hass-nabucasa==0.103.0 -hassil==2.2.3 httpx==0.28.1 home-assistant-bluetooth==1.13.1 -home-assistant-intents==2025.6.10 ifaddr==0.2.0 Jinja2==3.1.6 lru-dict==1.3.0 -mutagen==1.47.0 -numpy==2.3.0 PyJWT==2.10.1 cryptography==45.0.3 Pillow==11.2.1 @@ -42,10 +37,7 @@ pyOpenSSL==25.1.0 orjson==3.10.18 packaging>=23.1 psutil-home-assistant==0.0.1 -pymicro-vad==1.0.1 -pyspeex-noise==1.0.2 python-slugify==8.0.4 -PyTurboJPEG==1.8.0 PyYAML==6.0.2 requests==2.32.4 securetar==2025.2.1 diff --git a/requirements_all.txt b/requirements_all.txt index 0c485b79a8120..8d8817102cd03 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -461,6 +461,9 @@ airtouch5py==0.3.0 # homeassistant.components.alpha_vantage alpha-vantage==2.3.1 +# homeassistant.components.altruist +altruistclient==0.1.1 + # homeassistant.components.amberelectric amberelectric==2.0.12 @@ -776,7 +779,7 @@ defusedxml==0.7.1 deluge-client==1.10.2 # homeassistant.components.lametric -demetriek==1.2.0 +demetriek==1.3.0 # homeassistant.components.denonavr denonavr==1.1.1 @@ -1231,7 +1234,7 @@ ihcsdk==2.8.5 imeon_inverter_api==0.3.12 # homeassistant.components.imgw_pib -imgw_pib==1.0.10 +imgw_pib==1.1.0 # homeassistant.components.incomfort incomfort-client==0.6.9 @@ -2928,7 +2931,7 @@ thermopro-ble==0.13.0 thingspeak==1.0.0 # homeassistant.components.lg_thinq -thinqconnect==1.0.5 +thinqconnect==1.0.7 # homeassistant.components.tikteck tikteck==0.4 @@ -2936,6 +2939,9 @@ tikteck==0.4 # homeassistant.components.tilt_ble tilt-ble==0.2.3 +# homeassistant.components.tilt_pi +tilt-pi==0.2.1 + # homeassistant.components.tmb tmb==0.0.4 @@ -3154,7 +3160,7 @@ yeelight==0.7.16 yeelightsunflower==0.0.10 # homeassistant.components.yolink -yolink-api==0.5.2 +yolink-api==0.5.5 # homeassistant.components.youless youless-api==2.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 51b55d206af48..b53a6779b4e5c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -440,6 +440,9 @@ airtouch4pyapi==1.0.5 # homeassistant.components.airtouch5 airtouch5py==0.3.0 +# homeassistant.components.altruist +altruistclient==0.1.1 + # homeassistant.components.amberelectric amberelectric==2.0.12 @@ -676,7 +679,7 @@ defusedxml==0.7.1 deluge-client==1.10.2 # homeassistant.components.lametric -demetriek==1.2.0 +demetriek==1.3.0 # homeassistant.components.denonavr denonavr==1.1.1 @@ -1062,7 +1065,7 @@ igloohome-api==0.1.1 imeon_inverter_api==0.3.12 # homeassistant.components.imgw_pib -imgw_pib==1.0.10 +imgw_pib==1.1.0 # homeassistant.components.incomfort incomfort-client==0.6.9 @@ -2408,11 +2411,14 @@ thermobeacon-ble==0.10.0 thermopro-ble==0.13.0 # homeassistant.components.lg_thinq -thinqconnect==1.0.5 +thinqconnect==1.0.7 # homeassistant.components.tilt_ble tilt-ble==0.2.3 +# homeassistant.components.tilt_pi +tilt-pi==0.2.1 + # homeassistant.components.todoist todoist-api-python==2.1.7 @@ -2598,7 +2604,7 @@ yalexs==8.10.0 yeelight==0.7.16 # homeassistant.components.yolink -yolink-api==0.5.2 +yolink-api==0.5.5 # homeassistant.components.youless youless-api==2.2.0 diff --git a/tests/components/ai_task/test_task.py b/tests/components/ai_task/test_task.py index d4df66d83f9ca..d6e266aa02e40 100644 --- a/tests/components/ai_task/test_task.py +++ b/tests/components/ai_task/test_task.py @@ -8,6 +8,7 @@ from homeassistant.components.conversation import async_get_chat_log from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import chat_session from .conftest import TEST_ENTITY_ID, MockAITaskEntity @@ -25,7 +26,7 @@ async def test_run_task_preferred_entity( client = await hass_ws_client(hass) with pytest.raises( - ValueError, match="No entity_id provided and no preferred entity set" + HomeAssistantError, match="No entity_id provided and no preferred entity set" ): await async_generate_text( hass, @@ -42,7 +43,9 @@ async def test_run_task_preferred_entity( msg = await client.receive_json() assert msg["success"] - with pytest.raises(ValueError, match="AI Task entity ai_task.unknown not found"): + with pytest.raises( + HomeAssistantError, match="AI Task entity ai_task.unknown not found" + ): await async_generate_text( hass, task_name="Test Task", @@ -74,7 +77,7 @@ async def test_run_task_preferred_entity( mock_ai_task_entity.supported_features = AITaskEntityFeature(0) with pytest.raises( - ValueError, + HomeAssistantError, match="AI Task entity ai_task.test_task_entity does not support generating text", ): await async_generate_text( @@ -91,7 +94,7 @@ async def test_run_text_task_unknown_entity( """Test running a text task with an unknown entity.""" with pytest.raises( - ValueError, match="AI Task entity ai_task.unknown_entity not found" + HomeAssistantError, match="AI Task entity ai_task.unknown_entity not found" ): await async_generate_text( hass, diff --git a/tests/components/altruist/__init__.py b/tests/components/altruist/__init__.py new file mode 100644 index 0000000000000..bdbd8c0532a9d --- /dev/null +++ b/tests/components/altruist/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the Altruist integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/altruist/conftest.py b/tests/components/altruist/conftest.py new file mode 100644 index 0000000000000..3a7fcd1afe786 --- /dev/null +++ b/tests/components/altruist/conftest.py @@ -0,0 +1,82 @@ +"""Altruist tests configuration.""" + +from collections.abc import Generator +import json +from unittest.mock import AsyncMock, Mock, patch + +from altruistclient import AltruistDeviceModel, AltruistError +import pytest + +from homeassistant.components.altruist.const import CONF_HOST, DOMAIN + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.altruist.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.100"}, + unique_id="5366960e8b18", + title="5366960e8b18", + ) + + +@pytest.fixture +def mock_altruist_device() -> Mock: + """Return a mock AltruistDeviceModel.""" + device = Mock(spec=AltruistDeviceModel) + device.id = "5366960e8b18" + device.name = "Altruist Sensor" + device.ip_address = "192.168.1.100" + device.fw_version = "R_2025-03" + return device + + +@pytest.fixture +def mock_altruist_client(mock_altruist_device: Mock) -> Generator[AsyncMock]: + """Return a mock AltruistClient.""" + with ( + patch( + "homeassistant.components.altruist.coordinator.AltruistClient", + autospec=True, + ) as mock_client_class, + patch( + "homeassistant.components.altruist.config_flow.AltruistClient", + new=mock_client_class, + ), + ): + mock_instance = AsyncMock() + mock_instance.device = mock_altruist_device + mock_instance.device_id = mock_altruist_device.id + mock_instance.sensor_names = json.loads( + load_fixture("sensor_names.json", DOMAIN) + ) + mock_instance.fetch_data.return_value = json.loads( + load_fixture("real_data.json", DOMAIN) + ) + + mock_client_class.from_ip_address = AsyncMock(return_value=mock_instance) + + yield mock_instance + + +@pytest.fixture +def mock_altruist_client_fails_once(mock_altruist_client: AsyncMock) -> Generator[None]: + """Patch AltruistClient to fail once and then succeed.""" + with patch( + "homeassistant.components.altruist.config_flow.AltruistClient.from_ip_address", + side_effect=[AltruistError("Connection failed"), mock_altruist_client], + ): + yield diff --git a/tests/components/altruist/fixtures/real_data.json b/tests/components/altruist/fixtures/real_data.json new file mode 100644 index 0000000000000..86700f50b4f5d --- /dev/null +++ b/tests/components/altruist/fixtures/real_data.json @@ -0,0 +1,38 @@ +[ + { + "value_type": "signal", + "value": "-48" + }, + { + "value_type": "SDS_P1", + "value": "0.1" + }, + { + "value_type": "SDS_P2", + "value": "0.23" + }, + { + "value_type": "BME280_humidity", + "value": "54.94141" + }, + { + "value_type": "BME280_temperature", + "value": "22.95313" + }, + { + "value_type": "BME280_pressure", + "value": "99978.16" + }, + { + "value_type": "PCBA_noiseMax", + "value": "60" + }, + { + "value_type": "PCBA_noiseAvg", + "value": "51" + }, + { + "value_type": "GC", + "value": "15.2" + } +] diff --git a/tests/components/altruist/fixtures/sensor_names.json b/tests/components/altruist/fixtures/sensor_names.json new file mode 100644 index 0000000000000..41aa997326c3f --- /dev/null +++ b/tests/components/altruist/fixtures/sensor_names.json @@ -0,0 +1,11 @@ +[ + "signal", + "SDS_P1", + "SDS_P2", + "BME280_humidity", + "BME280_temperature", + "BME280_pressure", + "PCBA_noiseMax", + "PCBA_noiseAvg", + "GC" +] diff --git a/tests/components/altruist/snapshots/test_sensor.ambr b/tests/components/altruist/snapshots/test_sensor.ambr new file mode 100644 index 0000000000000..ca74e75542fe9 --- /dev/null +++ b/tests/components/altruist/snapshots/test_sensor.ambr @@ -0,0 +1,507 @@ +# serializer version: 1 +# name: test_all_entities[sensor.5366960e8b18_average_noise-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.5366960e8b18_average_noise', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Average noise', + 'platform': 'altruist', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'noise_avg', + 'unique_id': '5366960e8b18-PCBA_noiseAvg', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_average_noise-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'sound_pressure', + 'friendly_name': '5366960e8b18 Average noise', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.5366960e8b18_average_noise', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '51.0', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_bme280_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.5366960e8b18_bme280_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'BME280 humidity', + 'platform': 'altruist', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': '5366960e8b18-BME280_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_bme280_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': '5366960e8b18 BME280 humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.5366960e8b18_bme280_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '54.94141', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_bme280_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.5366960e8b18_bme280_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'BME280 pressure', + 'platform': 'altruist', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pressure', + 'unique_id': '5366960e8b18-BME280_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_bme280_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': '5366960e8b18 BME280 pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.5366960e8b18_bme280_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '749.897762397492', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_bme280_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.5366960e8b18_bme280_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'BME280 temperature', + 'platform': 'altruist', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '5366960e8b18-BME280_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_bme280_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': '5366960e8b18 BME280 temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.5366960e8b18_bme280_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.95313', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_maximum_noise-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.5366960e8b18_maximum_noise', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Maximum noise', + 'platform': 'altruist', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'noise_max', + 'unique_id': '5366960e8b18-PCBA_noiseMax', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_maximum_noise-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'sound_pressure', + 'friendly_name': '5366960e8b18 Maximum noise', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.5366960e8b18_maximum_noise', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60.0', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_pm10-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.5366960e8b18_pm10', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM10', + 'platform': 'altruist', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pm_10', + 'unique_id': '5366960e8b18-SDS_P1', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_pm10-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm10', + 'friendly_name': '5366960e8b18 PM10', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.5366960e8b18_pm10', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.5366960e8b18_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM2.5', + 'platform': 'altruist', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pm_25', + 'unique_id': '5366960e8b18-SDS_P2', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': '5366960e8b18 PM2.5', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.5366960e8b18_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.23', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_radiation_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.5366960e8b18_radiation_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Radiation level', + 'platform': 'altruist', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'radiation', + 'unique_id': '5366960e8b18-GC', + 'unit_of_measurement': 'μR/h', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_radiation_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '5366960e8b18 Radiation level', + 'state_class': , + 'unit_of_measurement': 'μR/h', + }), + 'context': , + 'entity_id': 'sensor.5366960e8b18_radiation_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.2', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.5366960e8b18_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'altruist', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '5366960e8b18-signal', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': '5366960e8b18 Signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.5366960e8b18_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-48.0', + }) +# --- diff --git a/tests/components/altruist/test_config_flow.py b/tests/components/altruist/test_config_flow.py new file mode 100644 index 0000000000000..3d04e893d6281 --- /dev/null +++ b/tests/components/altruist/test_config_flow.py @@ -0,0 +1,169 @@ +"""Test the Altruist config flow.""" + +from ipaddress import ip_address +from unittest.mock import AsyncMock + +from homeassistant.components.altruist.const import CONF_HOST, DOMAIN +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from tests.common import MockConfigEntry + +ZEROCONF_DISCOVERY = ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.100"), + ip_addresses=[ip_address("192.168.1.100")], + hostname="altruist-purple.local.", + name="altruist-purple._altruist._tcp.local.", + port=80, + type="_altruist._tcp.local.", + properties={ + "PATH": "/config", + }, +) + + +async def test_form_user_step_success( + hass: HomeAssistant, + mock_altruist_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test user step shows form and succeeds with valid input.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.100"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "5366960e8b18" + assert result["data"] == { + CONF_HOST: "192.168.1.100", + } + assert result["result"].unique_id == "5366960e8b18" + + +async def test_form_user_step_cannot_connect_then_recovers( + hass: HomeAssistant, + mock_altruist_client: AsyncMock, + mock_altruist_client_fails_once: None, + mock_setup_entry: AsyncMock, +) -> None: + """Test we handle connection error and allow recovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + # First attempt triggers an error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.100"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "no_device_found"} + + # Second attempt recovers with a valid client + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.100"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "5366960e8b18" + assert result["result"].unique_id == "5366960e8b18" + assert result["data"] == { + CONF_HOST: "192.168.1.100", + } + + +async def test_form_user_step_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_altruist_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test we abort if already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.100"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_zeroconf_discovery( + hass: HomeAssistant, + mock_altruist_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test zeroconf discovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "5366960e8b18" + assert result["data"] == { + CONF_HOST: "192.168.1.100", + } + assert result["result"].unique_id == "5366960e8b18" + + +async def test_zeroconf_discovery_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_altruist_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test zeroconf discovery when already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_zeroconf_discovery_cant_create_client( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_altruist_client_fails_once: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test zeroconf discovery when already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_device_found" diff --git a/tests/components/altruist/test_init.py b/tests/components/altruist/test_init.py new file mode 100644 index 0000000000000..67d5b01acb69b --- /dev/null +++ b/tests/components/altruist/test_init.py @@ -0,0 +1,53 @@ +"""Test the Altruist integration.""" + +from unittest.mock import AsyncMock + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_setup_entry_client_creation_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_altruist_client_fails_once: None, +) -> None: + """Test setup failure when client creation fails.""" + mock_config_entry.add_to_hass(hass) + + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_fetch_data_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_altruist_client: AsyncMock, +) -> None: + """Test setup failure when initial data fetch fails.""" + mock_config_entry.add_to_hass(hass) + mock_altruist_client.fetch_data.side_effect = Exception("Fetch failed") + + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_altruist_client: AsyncMock, +) -> None: + """Test unloading of config entry.""" + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Now test unloading + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/altruist/test_sensor.py b/tests/components/altruist/test_sensor.py new file mode 100644 index 0000000000000..1214adc488fb3 --- /dev/null +++ b/tests/components/altruist/test_sensor.py @@ -0,0 +1,55 @@ +"""Tests for the Altruist integration sensor platform.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from altruistclient import AltruistError +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_altruist_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.altruist.PLATFORMS", [Platform.SENSOR]): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_connection_error( + hass: HomeAssistant, + mock_altruist_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test coordinator error handling during update.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_altruist_client.fetch_data.side_effect = AltruistError() + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("sensor.5366960e8b18_bme280_temperature").state + == STATE_UNAVAILABLE + ) diff --git a/tests/components/lametric/snapshots/test_diagnostics.ambr b/tests/components/lametric/snapshots/test_diagnostics.ambr index d8f21424216ea..bc16e318a739a 100644 --- a/tests/components/lametric/snapshots/test_diagnostics.ambr +++ b/tests/components/lametric/snapshots/test_diagnostics.ambr @@ -46,6 +46,7 @@ 'name': '**REDACTED**', 'os_version': '2.2.2', 'serial_number': '**REDACTED**', + 'update': None, 'wifi': dict({ 'active': True, 'available': True, diff --git a/tests/components/lametric/test_button.py b/tests/components/lametric/test_button.py index cc8c1379fe079..cf8d76ca5f3f6 100644 --- a/tests/components/lametric/test_button.py +++ b/tests/components/lametric/test_button.py @@ -42,7 +42,7 @@ async def test_button_app_next( assert entry.device_id device_entry = device_registry.async_get(entry.device_id) assert device_entry - assert device_entry.configuration_url is None + assert device_entry.configuration_url == "https://127.0.0.1/" assert device_entry.connections == { (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff") } @@ -89,7 +89,7 @@ async def test_button_app_previous( assert entry.device_id device_entry = device_registry.async_get(entry.device_id) assert device_entry - assert device_entry.configuration_url is None + assert device_entry.configuration_url == "https://127.0.0.1/" assert device_entry.connections == { (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff") } @@ -137,7 +137,7 @@ async def test_button_dismiss_current_notification( assert entry.device_id device_entry = device_registry.async_get(entry.device_id) assert device_entry - assert device_entry.configuration_url is None + assert device_entry.configuration_url == "https://127.0.0.1/" assert device_entry.connections == { (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff") } @@ -185,7 +185,7 @@ async def test_button_dismiss_all_notifications( assert entry.device_id device_entry = device_registry.async_get(entry.device_id) assert device_entry - assert device_entry.configuration_url is None + assert device_entry.configuration_url == "https://127.0.0.1/" assert device_entry.connections == { (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff") } diff --git a/tests/components/lametric/test_number.py b/tests/components/lametric/test_number.py index 6e052603c24ad..f34cf04aed9af 100644 --- a/tests/components/lametric/test_number.py +++ b/tests/components/lametric/test_number.py @@ -55,7 +55,7 @@ async def test_brightness( device = device_registry.async_get(entry.device_id) assert device - assert device.configuration_url is None + assert device.configuration_url == "https://127.0.0.1/" assert device.connections == {(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")} assert device.entry_type is None assert device.hw_version is None @@ -104,7 +104,7 @@ async def test_volume( device = device_registry.async_get(entry.device_id) assert device - assert device.configuration_url is None + assert device.configuration_url == "https://127.0.0.1/" assert device.connections == {(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")} assert device.entry_type is None assert device.hw_version is None diff --git a/tests/components/lametric/test_select.py b/tests/components/lametric/test_select.py index e4b9870f52b70..177092f061ef0 100644 --- a/tests/components/lametric/test_select.py +++ b/tests/components/lametric/test_select.py @@ -48,7 +48,7 @@ async def test_brightness_mode( device = device_registry.async_get(entry.device_id) assert device - assert device.configuration_url is None + assert device.configuration_url == "https://127.0.0.1/" assert device.connections == {(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")} assert device.entry_type is None assert device.hw_version is None diff --git a/tests/components/lametric/test_sensor.py b/tests/components/lametric/test_sensor.py index 08b289e2425e7..a0719edfc9d9d 100644 --- a/tests/components/lametric/test_sensor.py +++ b/tests/components/lametric/test_sensor.py @@ -41,7 +41,7 @@ async def test_wifi_signal( device = device_registry.async_get(entry.device_id) assert device - assert device.configuration_url is None + assert device.configuration_url == "https://127.0.0.1/" assert device.connections == {(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")} assert device.entry_type is None assert device.hw_version is None diff --git a/tests/components/lametric/test_switch.py b/tests/components/lametric/test_switch.py index 3e73b71094237..155a315881fdd 100644 --- a/tests/components/lametric/test_switch.py +++ b/tests/components/lametric/test_switch.py @@ -50,7 +50,7 @@ async def test_bluetooth( device = device_registry.async_get(entry.device_id) assert device - assert device.configuration_url is None + assert device.configuration_url == "https://127.0.0.1/" assert device.connections == {(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")} assert device.entry_type is None assert device.hw_version is None diff --git a/tests/components/laundrify/conftest.py b/tests/components/laundrify/conftest.py index 4a78a2e9025fc..c9ad1e528a598 100644 --- a/tests/components/laundrify/conftest.py +++ b/tests/components/laundrify/conftest.py @@ -6,8 +6,7 @@ from laundrify_aio import LaundrifyAPI, LaundrifyDevice import pytest -from homeassistant.components.laundrify import DOMAIN -from homeassistant.components.laundrify.const import MANUFACTURER +from homeassistant.components.laundrify.const import DOMAIN, MANUFACTURER from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index e637d9e40cad5..5895c3472d6eb 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -76,6 +76,7 @@ async def integration_fixture( params=[ "air_purifier", "air_quality_sensor", + "battery_storage", "color_temperature_light", "cooktop", "dimmable_light", diff --git a/tests/components/matter/fixtures/nodes/battery_storage.json b/tests/components/matter/fixtures/nodes/battery_storage.json new file mode 100644 index 0000000000000..8162318b15f5b --- /dev/null +++ b/tests/components/matter/fixtures/nodes/battery_storage.json @@ -0,0 +1,271 @@ +{ + "node_id": 25, + "date_commissioned": "2025-06-19T17:13:40.727316", + "last_interview": "2025-06-19T17:13:40.727333", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 3 + }, + { + "0": 18, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63, 42], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 3, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 + } + ], + "0/31/2": 4, + "0/31/4": 4, + "0/31/3": 3, + "0/31/65532": 0, + "0/31/65533": 2, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 2, 4, 3, 65532, 65533, 65528, 65529, 65531], + "0/40/65532": 0, + "0/40/0": 19, + "0/40/6": "**REDACTED**", + "0/40/1": "TEST_VENDOR", + "0/40/2": 65521, + "0/40/3": "Mock Battery Storage", + "0/40/4": 32768, + "0/40/7": 0, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "1.0", + "0/40/18": "6C89C9D11F0BDAAD", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/21": 17104896, + "0/40/22": 1, + "0/40/65533": 5, + "0/40/5": "", + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 65532, 0, 6, 1, 2, 3, 4, 7, 8, 9, 10, 18, 19, 21, 22, 65533, 5, 65528, + 65529, 65531 + ], + "0/48/65532": 0, + "0/48/2": 0, + "0/48/3": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/4": true, + "0/48/65533": 2, + "0/48/0": 0, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [65532, 2, 3, 1, 4, 65533, 0, 65528, 65529, 65531], + "0/49/0": 1, + "0/49/1": [ + { + "0": "RnJlZWJveC03Mjg2ODE=", + "1": true + } + ], + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "RnJlZWJveC03Mjg2ODE=", + "0/49/7": null, + "0/49/2": 10, + "0/49/3": 30, + "0/49/8": [0], + "0/49/65532": 1, + "0/49/65533": 2, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 2, 4, 6, 8], + "0/49/65531": [ + 0, 1, 4, 5, 6, 7, 2, 3, 8, 65532, 65533, 65528, 65529, 65531 + ], + "0/51/0": [ + { + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "YFX59wI0", + "5": ["wKgBqA=="], + "6": ["/oAAAAAAAABiVfn//vcCNA==", "KgEOCgKzOZBiVfn//vcCNA=="], + "7": 1 + } + ], + "0/51/1": 1, + "0/51/2": 245, + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 2, + "0/51/65528": [2], + "0/51/65529": [0, 1], + "0/51/65531": [0, 1, 2, 8, 65532, 65533, 65528, 65529, 65531], + "0/60/65532": 0, + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 2], + "0/60/65531": [65532, 0, 1, 2, 65533, 65528, 65529, 65531], + "0/62/65532": 0, + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRGRgkBwEkCAEwCUEEdGR9Cz5LAJceV7SCSogqC7oif2ZaaFbkT0aMcnoFyyfBgkEg7K/IzbpMUEbatodbeOpCPFebunhR9wCXs7B8lTcKNQEoARgkAgE2AwQCBAEYMAQUTYn5+OBsvnwU4qs/Er+byaEnS/AwBRS5+zzv8ZPGnI9mC3wH9vq10JnwlhgwC0D4oAj5zm+W4u/MaHn8Xzqh3zzGdKh2OrSqols1utweoW2ODVMf+AT0WNmG9sOxeaoOPppaFVorZf5T1KtB0T9gGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE/DujEcdTsX19xbxX+KuKKWiMaA5D9u99P/pVxIOmscd2BA2PadEMNnjvtPOpf+WE2Zxar4rby1IfAClGUUuQrTcKNQEpARgkAmAwBBS5+zzv8ZPGnI9mC3wH9vq10JnwljAFFPT6p93JKGcb7g+rTWnA6evF2EdGGDALQGkPpvsbkAFEbfPN6H3Kf23R0zzmW/gpAA3kgaL6wKB2Ofm+Tmylw22qM536Kj8mOMwaV0EL1dCCGcuxF98aL6gY", + "254": 3 + } + ], + "0/62/2": 5, + "0/62/3": 3, + "0/62/1": [ + { + "1": "BBmX+KwLR5HGlVNbvlC+dO8Jv9fPthHiTfGpUzi2JJADX5az6GxBAFn02QKHwLcZHyh+lh9faf6rf38/nPYF7/M=", + "2": 4939, + "3": 2, + "4": 25, + "5": "Home", + "254": 3 + } + ], + "0/62/4": [ + "FTABAQAkAgE3AyYUyakYCSYVj6gLsxgmBPIA5y8kBQA3BiYUyakYCSYVj6gLsxgkBwEkCAEwCUEEgYwxrTB+tyiEGfrRwjlXTG34MiQtJXbg5Qqd0ohdRW7MfwYY7vZiX/0h9hI8MqUralFaVPcnghAP0MSJm1YrqTcKNQEpARgkAmAwBBS3BS9aJzt+p6i28Nj+trB2Uu+vdzAFFLcFL1onO36nqLbw2P62sHZS7693GDALQPnIJqOtiZpRoUcwAo5GzvuP5SeVloEfg6jDfAMYWb+Sm6X4b9FLaO9IVlUmABOKG5Ay+6ayHN5KRUFmoo4TrxIY", + "FTABAQAkAgE3AycUQhmZbaIbYjokFQIYJgRWZLcqJAUANwYnFEIZmW2iG2I6JBUCGCQHASQIATAJQQT2AlKGW/kOMjqayzeO0md523/fuhrhGEUU91uQpTiKo0I7wcPpKnmrwfQNPX6g0kEQl+VGaXa3e22lzfu5Tzp0Nwo1ASkBGCQCYDAEFOOMk13ScMKuT2hlaydi1yEJnhTqMAUU44yTXdJwwq5PaGVrJ2LXIQmeFOoYMAtAv2jJd1qd5miXbYesH1XrJ+vgyY0hzGuZ78N6Jw4Cb1oN1sLSpA+PNM0u7+hsEqcSvvn2eSV8EaRR+hg5YQjHDxg=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEGZf4rAtHkcaVU1u+UL507wm/18+2EeJN8alTOLYkkANflrPobEEAWfTZAofAtxkfKH6WH19p/qt/fz+c9gXv8zcKNQEpARgkAmAwBBT0+qfdyShnG+4Pq01pwOnrxdhHRjAFFPT6p93JKGcb7g+rTWnA6evF2EdGGDALQPVrsFnfFplsQGV5m5EUua+rmo9hAr+OP1bvaifdLqiEIn3uXLTLoKmVUkPImRL2Fb+xcMEAqR2p7RM6ZlFCR20Y" + ], + "0/62/5": 3, + "0/62/65533": 2, + "0/62/65528": [1, 3, 5, 8, 14], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11, 12, 13], + "0/62/65531": [65532, 0, 2, 3, 1, 4, 5, 65533, 65528, 65529, 65531], + "0/63/65532": 0, + "0/63/65533": 2, + "0/63/0": [], + "0/63/1": [], + "0/63/2": 0, + "0/63/3": 3, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [65532, 65533, 0, 1, 2, 3, 65528, 65529, 65531], + "0/42/65532": 0, + "0/42/0": [], + "0/42/65533": 1, + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [65532, 0, 65533, 1, 2, 3, 65528, 65529, 65531], + "1/29/0": [ + { + "0": 24, + "1": 1 + }, + { + "0": 17, + "1": 1 + }, + { + "0": 1296, + "1": 1 + }, + { + "0": 1293, + "1": 2 + } + ], + "1/29/1": [29, 47, 156, 144, 145, 152, 159], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 3, + "1/29/4": [], + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65532, 65533, 4, 65528, 65529, 65531], + "1/47/65532": 7, + "1/47/65533": 3, + "1/47/0": 0, + "1/47/1": 0, + "1/47/2": "Main", + "1/47/31": [], + "1/47/5": 0, + "1/47/11": 48000, + "1/47/12": 180, + "1/47/13": 7200, + "1/47/18": [], + "1/47/24": 100000, + "1/47/27": 1800, + "1/47/29": null, + "1/47/30": null, + "1/47/65528": [], + "1/47/65529": [], + "1/47/65531": [ + 65532, 65533, 0, 1, 2, 31, 5, 11, 12, 13, 18, 24, 27, 29, 30, 65528, + 65529, 65531 + ], + "1/156/65532": 0, + "1/156/65533": 1, + "1/156/65528": [], + "1/156/65529": [], + "1/156/65531": [65532, 65533, 65528, 65529, 65531], + "1/144/65532": 0, + "1/144/0": 0, + "1/144/1": 0, + "1/144/2": null, + "1/144/8": 0, + "1/144/65533": 1, + "1/144/4": 0, + "1/144/5": 0, + "1/144/65528": [], + "1/144/65529": [], + "1/144/65531": [65532, 0, 1, 2, 8, 65533, 4, 5, 65528, 65529, 65531], + "1/145/65532": 0, + "1/145/0": null, + "1/145/65533": 1, + "1/145/65528": [], + "1/145/65529": [], + "1/145/65531": [65532, 0, 65533, 65528, 65529, 65531], + "1/152/65532": 1, + "1/152/0": 5, + "1/152/1": false, + "1/152/2": 1, + "1/152/3": 0, + "1/152/4": 0, + "1/152/65533": 4, + "1/152/5": null, + "1/152/7": 0, + "1/152/65528": [], + "1/152/65529": [0, 1], + "1/152/65531": [65532, 0, 1, 2, 3, 4, 65533, 5, 7, 65528, 65529, 65531], + "1/159/65532": 0, + "1/159/0": null, + "1/159/65533": 2, + "1/159/1": 0, + "1/159/65528": [1], + "1/159/65529": [0], + "1/159/65531": [65532, 0, 65533, 1, 65528, 65529, 65531] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/fixtures/nodes/silabs_dishwasher.json b/tests/components/matter/fixtures/nodes/silabs_dishwasher.json index c5015bc1c34b0..d0efcc7e00463 100644 --- a/tests/components/matter/fixtures/nodes/silabs_dishwasher.json +++ b/tests/components/matter/fixtures/nodes/silabs_dishwasher.json @@ -417,6 +417,14 @@ "1/89/65528": [1], "1/89/65529": [0], "1/89/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/93/0": 63, + "1/93/2": 0, + "1/93/3": 63, + "1/93/65533": 1, + "1/93/65532": 0, + "1/93/65531": [0, 2, 3, 65533, 65532, 65531, 65529, 65528], + "1/93/65529": [], + "1/93/65528": [], "1/96/0": null, "1/96/1": null, "1/96/3": [ diff --git a/tests/components/matter/snapshots/test_binary_sensor.ambr b/tests/components/matter/snapshots/test_binary_sensor.ambr index f13d86c4557ee..f6c7780c517d4 100644 --- a/tests/components/matter/snapshots/test_binary_sensor.ambr +++ b/tests/components/matter/snapshots/test_binary_sensor.ambr @@ -489,6 +489,104 @@ 'state': 'on', }) # --- +# name: test_binary_sensors[silabs_dishwasher][binary_sensor.dishwasher_door_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.dishwasher_door_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door alarm', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dishwasher_alarm_door', + 'unique_id': '00000000000004D2-0000000000000036-MatterNodeDevice-1-DishwasherAlarmDoorError-93-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[silabs_dishwasher][binary_sensor.dishwasher_door_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Dishwasher Door alarm', + }), + 'context': , + 'entity_id': 'binary_sensor.dishwasher_door_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[silabs_dishwasher][binary_sensor.dishwasher_inflow_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.dishwasher_inflow_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Inflow alarm', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dishwasher_alarm_inflow', + 'unique_id': '00000000000004D2-0000000000000036-MatterNodeDevice-1-DishwasherAlarmInflowError-93-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[silabs_dishwasher][binary_sensor.dishwasher_inflow_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Dishwasher Inflow alarm', + }), + 'context': , + 'entity_id': 'binary_sensor.dishwasher_inflow_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_charging_status-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 14169c84e15cf..7de836b7092a2 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -1251,6 +1251,477 @@ 'state': '189.0', }) # --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_appliance_energy_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'offline', + 'online', + 'fault', + 'power_adjust_active', + 'paused', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_battery_storage_appliance_energy_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Appliance energy state', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'esa_state', + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-1-ESAState-152-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_appliance_energy_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Mock Battery Storage Appliance energy state', + 'options': list([ + 'offline', + 'online', + 'fault', + 'power_adjust_active', + 'paused', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_battery_storage_appliance_energy_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'online', + }) +# --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_battery_storage_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-1-PowerSource-47-12', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Mock Battery Storage Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.mock_battery_storage_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90', + }) +# --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_battery_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_battery_storage_battery_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery voltage', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_voltage', + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-1-PowerSourceBatVoltage-47-11', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_battery_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Mock Battery Storage Battery voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_battery_storage_battery_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '48.0', + }) +# --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_battery_storage_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-1-ElectricalPowerMeasurementActiveCurrent-144-5', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Mock Battery Storage Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_battery_storage_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_battery_storage_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-1-ElectricalPowerMeasurementWatt-144-8', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Mock Battery Storage Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_battery_storage_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_time_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_battery_storage_time_remaining', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time remaining', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_time_remaining', + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-1-PowerSourceBatTimeRemaining-47-13', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_time_remaining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Mock Battery Storage Time remaining', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_battery_storage_time_remaining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '120.0', + }) +# --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_time_to_full_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_battery_storage_time_to_full_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to full charge', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_time_to_full_charge', + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-1-PowerSourceBatTimeToFullCharge-47-27', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_time_to_full_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Mock Battery Storage Time to full charge', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_battery_storage_time_to_full_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.0', + }) +# --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_battery_storage_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-1-ElectricalPowerMeasurementVoltage-144-4', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Mock Battery Storage Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_battery_storage_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_sensors[cooktop][sensor.mock_cooktop_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/test_binary_sensor.py b/tests/components/matter/test_binary_sensor.py index e221140b85b99..873d6f175280d 100644 --- a/tests/components/matter/test_binary_sensor.py +++ b/tests/components/matter/test_binary_sensor.py @@ -257,3 +257,21 @@ async def test_pump( state = hass.states.get("binary_sensor.mock_pump_problem") assert state assert state.state == "on" + + +@pytest.mark.parametrize("node_fixture", ["silabs_dishwasher"]) +async def test_dishwasher_alarm( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test dishwasher alarm sensors.""" + state = hass.states.get("binary_sensor.dishwasher_door_alarm") + assert state + + set_node_attribute(matter_node, 1, 93, 2, 4) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("binary_sensor.dishwasher_door_alarm") + assert state + assert state.state == "on" diff --git a/tests/components/template/test_alarm_control_panel.py b/tests/components/template/test_alarm_control_panel.py index f982024360082..1984b4ea2afda 100644 --- a/tests/components/template/test_alarm_control_panel.py +++ b/tests/components/template/test_alarm_control_panel.py @@ -30,6 +30,7 @@ TEST_OBJECT_ID = "test_template_panel" TEST_ENTITY_ID = f"alarm_control_panel.{TEST_OBJECT_ID}" TEST_STATE_ENTITY_ID = "alarm_control_panel.test" +TEST_SWITCH = "switch.test_state" @pytest.fixture @@ -110,6 +111,14 @@ def capture_events(event: Event) -> None: **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, } +TEST_STATE_TRIGGER = { + "triggers": {"trigger": "state", "entity_id": [TEST_STATE_ENTITY_ID, TEST_SWITCH]}, + "variables": {"triggering_entity": "{{ trigger.entity_id }}"}, + "actions": [ + {"event": "action_event", "event_data": {"what": "{{ triggering_entity }}"}} + ], +} + async def async_setup_legacy_format( hass: HomeAssistant, count: int, panel_config: dict[str, Any] @@ -146,6 +155,24 @@ async def async_setup_modern_format( await hass.async_block_till_done() +async def async_setup_trigger_format( + hass: HomeAssistant, count: int, panel_config: dict[str, Any] +) -> None: + """Do setup of alarm control panel integration via trigger format.""" + config = {"template": {"alarm_control_panel": panel_config, **TEST_STATE_TRIGGER}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + @pytest.fixture async def setup_panel( hass: HomeAssistant, @@ -158,6 +185,8 @@ async def setup_panel( await async_setup_legacy_format(hass, count, panel_config) elif style == ConfigurationStyle.MODERN: await async_setup_modern_format(hass, count, panel_config) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format(hass, count, panel_config) async def async_setup_state_panel( @@ -188,6 +217,16 @@ async def async_setup_state_panel( **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, }, ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + "state": state_template, + **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, + }, + ) @pytest.fixture @@ -228,6 +267,17 @@ async def setup_base_panel( **panel_config, }, ) + elif style == ConfigurationStyle.TRIGGER: + extra = {"state": state_template} if state_template else {} + await async_setup_trigger_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + **extra, + **panel_config, + }, + ) @pytest.fixture @@ -264,13 +314,25 @@ async def setup_single_attribute_state_panel( **extra, }, ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, + "state": state_template, + **extra, + }, + ) @pytest.mark.parametrize( ("count", "state_template"), [(1, "{{ states('alarm_control_panel.test') }}")] ) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_state_panel") async def test_template_state_text(hass: HomeAssistant) -> None: @@ -301,56 +363,72 @@ async def test_template_state_text(hass: HomeAssistant) -> None: @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("state_template", "expected"), + ("state_template", "expected", "trigger_expected"), [ - ("{{ 'disarmed' }}", AlarmControlPanelState.DISARMED), - ("{{ 'armed_home' }}", AlarmControlPanelState.ARMED_HOME), - ("{{ 'armed_away' }}", AlarmControlPanelState.ARMED_AWAY), - ("{{ 'armed_night' }}", AlarmControlPanelState.ARMED_NIGHT), - ("{{ 'armed_vacation' }}", AlarmControlPanelState.ARMED_VACATION), - ("{{ 'armed_custom_bypass' }}", AlarmControlPanelState.ARMED_CUSTOM_BYPASS), - ("{{ 'pending' }}", AlarmControlPanelState.PENDING), - ("{{ 'arming' }}", AlarmControlPanelState.ARMING), - ("{{ 'disarming' }}", AlarmControlPanelState.DISARMING), - ("{{ 'triggered' }}", AlarmControlPanelState.TRIGGERED), - ("{{ x - 1 }}", STATE_UNKNOWN), + ("{{ 'disarmed' }}", AlarmControlPanelState.DISARMED, None), + ("{{ 'armed_home' }}", AlarmControlPanelState.ARMED_HOME, None), + ("{{ 'armed_away' }}", AlarmControlPanelState.ARMED_AWAY, None), + ("{{ 'armed_night' }}", AlarmControlPanelState.ARMED_NIGHT, None), + ("{{ 'armed_vacation' }}", AlarmControlPanelState.ARMED_VACATION, None), + ( + "{{ 'armed_custom_bypass' }}", + AlarmControlPanelState.ARMED_CUSTOM_BYPASS, + None, + ), + ("{{ 'pending' }}", AlarmControlPanelState.PENDING, None), + ("{{ 'arming' }}", AlarmControlPanelState.ARMING, None), + ("{{ 'disarming' }}", AlarmControlPanelState.DISARMING, None), + ("{{ 'triggered' }}", AlarmControlPanelState.TRIGGERED, None), + ("{{ x - 1 }}", STATE_UNKNOWN, STATE_UNAVAILABLE), ], ) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_state_panel") -async def test_state_template_states(hass: HomeAssistant, expected: str) -> None: +async def test_state_template_states( + hass: HomeAssistant, expected: str, trigger_expected: str, style: ConfigurationStyle +) -> None: """Test the state template.""" + + # Force a trigger + hass.states.async_set(TEST_STATE_ENTITY_ID, None) + await hass.async_block_till_done() + state = hass.states.get(TEST_ENTITY_ID) + + if trigger_expected and style == ConfigurationStyle.TRIGGER: + expected = trigger_expected + assert state.state == expected @pytest.mark.parametrize( - ("count", "state_template", "attribute_template"), + ("count", "state_template", "attribute_template", "attribute"), [ ( 1, "{{ 'disarmed' }}", "{% if states.switch.test_state.state %}mdi:check{% endif %}", + "icon", ) ], ) @pytest.mark.parametrize( - ("style", "attribute"), + ("style", "initial_state"), [ - (ConfigurationStyle.MODERN, "icon"), + (ConfigurationStyle.MODERN, ""), + (ConfigurationStyle.TRIGGER, None), ], ) @pytest.mark.usefixtures("setup_single_attribute_state_panel") -async def test_icon_template( - hass: HomeAssistant, -) -> None: +async def test_icon_template(hass: HomeAssistant, initial_state: str) -> None: """Test icon template.""" state = hass.states.get(TEST_ENTITY_ID) - assert state.attributes.get("icon") in ("", None) + assert state.attributes.get("icon") == initial_state - hass.states.async_set("switch.test_state", STATE_ON) + hass.states.async_set(TEST_SWITCH, STATE_ON) await hass.async_block_till_done() state = hass.states.get(TEST_ENTITY_ID) @@ -358,30 +436,30 @@ async def test_icon_template( @pytest.mark.parametrize( - ("count", "state_template", "attribute_template"), + ("count", "state_template", "attribute_template", "attribute"), [ ( 1, "{{ 'disarmed' }}", "{% if states.switch.test_state.state %}local/panel.png{% endif %}", + "picture", ) ], ) @pytest.mark.parametrize( - ("style", "attribute"), + ("style", "initial_state"), [ - (ConfigurationStyle.MODERN, "picture"), + (ConfigurationStyle.MODERN, ""), + (ConfigurationStyle.TRIGGER, None), ], ) @pytest.mark.usefixtures("setup_single_attribute_state_panel") -async def test_picture_template( - hass: HomeAssistant, -) -> None: +async def test_picture_template(hass: HomeAssistant, initial_state: str) -> None: """Test icon template.""" state = hass.states.get(TEST_ENTITY_ID) - assert state.attributes.get("entity_picture") in ("", None) + assert state.attributes.get("entity_picture") == initial_state - hass.states.async_set("switch.test_state", STATE_ON) + hass.states.async_set(TEST_SWITCH, STATE_ON) await hass.async_block_till_done() state = hass.states.get(TEST_ENTITY_ID) @@ -425,7 +503,8 @@ async def test_setup_config_entry( @pytest.mark.parametrize(("count", "state_template"), [(1, None)]) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( "panel_config", [OPTIMISTIC_TEMPLATE_ALARM_CONFIG, EMPTY_ACTIONS] @@ -459,7 +538,8 @@ async def test_optimistic_states(hass: HomeAssistant) -> None: @pytest.mark.parametrize("count", [0]) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( ("panel_config", "state_template", "msg"), @@ -538,11 +618,15 @@ async def test_legacy_template_syntax_error( [ (ConfigurationStyle.LEGACY, TEST_ENTITY_ID), (ConfigurationStyle.MODERN, "alarm_control_panel.template_alarm_panel"), + (ConfigurationStyle.TRIGGER, "alarm_control_panel.unnamed_device"), ], ) @pytest.mark.usefixtures("setup_single_attribute_state_panel") async def test_name(hass: HomeAssistant, test_entity_id: str) -> None: """Test the accessibility of the name attribute.""" + hass.states.async_set(TEST_STATE_ENTITY_ID, "disarmed") + await hass.async_block_till_done() + state = hass.states.get(test_entity_id) assert state is not None assert state.attributes.get("friendly_name") == "Template Alarm Panel" @@ -552,7 +636,8 @@ async def test_name(hass: HomeAssistant, test_entity_id: str) -> None: ("count", "state_template"), [(1, "{{ states('alarm_control_panel.test') }}")] ) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( "service", @@ -615,6 +700,21 @@ async def test_actions( ], ConfigurationStyle.MODERN, ), + ( + [ + { + "name": "test_template_alarm_control_panel_01", + "state": "{{ true }}", + **UNIQUE_ID_CONFIG, + }, + { + "name": "test_template_alarm_control_panel_02", + "state": "{{ false }}", + **UNIQUE_ID_CONFIG, + }, + ], + ConfigurationStyle.TRIGGER, + ), ], ) @pytest.mark.usefixtures("setup_panel") @@ -669,7 +769,8 @@ async def test_nested_unique_id( @pytest.mark.parametrize(("count", "state_template"), [(1, "disarmed")]) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( ("panel_config", "code_format", "code_arm_required"), @@ -714,7 +815,8 @@ async def test_code_config(hass: HomeAssistant, code_format, code_arm_required) ("count", "state_template"), [(1, "{{ states('alarm_control_panel.test') }}")] ) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( ("restored_state", "initial_state"), diff --git a/tests/components/template/test_lock.py b/tests/components/template/test_lock.py index 4435e4a2404dc..94b0669acd173 100644 --- a/tests/components/template/test_lock.py +++ b/tests/components/template/test_lock.py @@ -25,7 +25,19 @@ TEST_OBJECT_ID = "test_template_lock" TEST_ENTITY_ID = f"lock.{TEST_OBJECT_ID}" -TEST_STATE_ENTITY_ID = "switch.test_state" +TEST_STATE_ENTITY_ID = "sensor.test_state" +TEST_AVAILABILITY_ENTITY_ID = "availability_state.state" + +TEST_STATE_TRIGGER = { + "trigger": { + "trigger": "state", + "entity_id": [TEST_STATE_ENTITY_ID, TEST_AVAILABILITY_ENTITY_ID], + }, + "variables": {"triggering_entity": "{{ trigger.entity_id }}"}, + "action": [ + {"event": "action_event", "event_data": {"what": "{{ triggering_entity }}"}} + ], +} LOCK_ACTION = { "lock": { @@ -113,6 +125,29 @@ async def async_setup_modern_format( await hass.async_block_till_done() +async def async_setup_trigger_format( + hass: HomeAssistant, count: int, lock_config: dict[str, Any] +) -> None: + """Do setup of lock integration via trigger format.""" + config = { + "template": { + "lock": {"name": TEST_OBJECT_ID, **lock_config}, + **TEST_STATE_TRIGGER, + } + } + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + @pytest.fixture async def setup_lock( hass: HomeAssistant, @@ -125,6 +160,8 @@ async def setup_lock( await async_setup_legacy_format(hass, count, lock_config) elif style == ConfigurationStyle.MODERN: await async_setup_modern_format(hass, count, lock_config) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format(hass, count, lock_config) @pytest.fixture @@ -148,6 +185,12 @@ async def setup_base_lock( count, {"state": state_template, **extra_config}, ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + {"state": state_template, **extra_config}, + ) @pytest.fixture @@ -176,6 +219,15 @@ async def setup_state_lock( "state": state_template, }, ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + { + **OPTIMISTIC_LOCK, + "state": state_template, + }, + ) @pytest.fixture @@ -199,6 +251,12 @@ async def setup_state_lock_with_extra_config( count, {**OPTIMISTIC_LOCK, "state": state_template, **extra_config}, ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + {**OPTIMISTIC_LOCK, "state": state_template, **extra_config}, + ) @pytest.fixture @@ -228,13 +286,20 @@ async def setup_state_lock_with_attribute( count, {**OPTIMISTIC_LOCK, "state": state_template, **extra}, ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + {**OPTIMISTIC_LOCK, "state": state_template, **extra}, + ) @pytest.mark.parametrize( - ("count", "state_template"), [(1, "{{ states.switch.test_state.state }}")] + ("count", "state_template"), [(1, "{{ states.sensor.test_state.state }}")] ) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_state_lock") async def test_template_state(hass: HomeAssistant) -> None: @@ -260,10 +325,11 @@ async def test_template_state(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("count", "state_template", "extra_config"), - [(1, "{{ states.switch.test_state.state }}", {"optimistic": True, **OPEN_ACTION})], + [(1, "{{ states.sensor.test_state.state }}", {"optimistic": True, **OPEN_ACTION})], ) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_state_lock_with_extra_config") async def test_open_lock_optimistic( @@ -293,18 +359,24 @@ async def test_open_lock_optimistic( @pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 1 == 1 }}")]) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_state_lock") async def test_template_state_boolean_on(hass: HomeAssistant) -> None: """Test the setting of the state with boolean on.""" + # Ensure the trigger executes for trigger configurations + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + state = hass.states.get(TEST_ENTITY_ID) assert state.state == LockState.LOCKED @pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 1 == 2 }}")]) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_state_lock") async def test_template_state_boolean_off(hass: HomeAssistant) -> None: @@ -315,7 +387,8 @@ async def test_template_state_boolean_off(hass: HomeAssistant) -> None: @pytest.mark.parametrize("count", [0]) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( ("state_template", "extra_config"), @@ -326,7 +399,7 @@ async def test_template_state_boolean_off(hass: HomeAssistant) -> None: ( "{{ 1==1 }}", { - "not_value_template": "{{ states.switch.test_state.state }}", + "not_value_template": "{{ states.sensor.test_state.state }}", **OPTIMISTIC_LOCK, }, ), @@ -345,6 +418,7 @@ async def test_template_syntax_error(hass: HomeAssistant) -> None: [ (ConfigurationStyle.LEGACY, "code_format_template"), (ConfigurationStyle.MODERN, "code_format"), + (ConfigurationStyle.TRIGGER, "code_format"), ], ) @pytest.mark.usefixtures("setup_state_lock_with_attribute") @@ -355,7 +429,8 @@ async def test_template_code_template_syntax_error(hass: HomeAssistant) -> None: @pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 1 + 1 }}")]) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_state_lock") async def test_template_static(hass: HomeAssistant) -> None: @@ -371,7 +446,8 @@ async def test_template_static(hass: HomeAssistant) -> None: @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( ("state_template", "expected"), @@ -384,26 +460,33 @@ async def test_template_static(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("setup_state_lock") async def test_state_template(hass: HomeAssistant, expected: str) -> None: """Test state and value_template template.""" + # Ensure the trigger executes for trigger configurations + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + state = hass.states.get(TEST_ENTITY_ID) assert state.state == expected -@pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 1==1 }}")]) +@pytest.mark.parametrize( + ("count", "state_template", "attribute"), [(1, "{{ 1==1 }}", "picture")] +) @pytest.mark.parametrize( "attribute_template", - ["{% if states.switch.test_state.state %}/local/switch.png{% endif %}"], + ["{% if states.sensor.test_state.state %}/local/switch.png{% endif %}"], ) @pytest.mark.parametrize( - ("style", "attribute"), + ("style", "initial_state"), [ - (ConfigurationStyle.MODERN, "picture"), + (ConfigurationStyle.MODERN, ""), + (ConfigurationStyle.TRIGGER, None), ], ) @pytest.mark.usefixtures("setup_state_lock_with_attribute") -async def test_picture_template(hass: HomeAssistant) -> None: +async def test_picture_template(hass: HomeAssistant, initial_state: str) -> None: """Test entity_picture template.""" state = hass.states.get(TEST_ENTITY_ID) - assert state.attributes.get("entity_picture") in ("", None) + assert state.attributes.get("entity_picture") == initial_state hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() @@ -412,22 +495,25 @@ async def test_picture_template(hass: HomeAssistant) -> None: assert state.attributes["entity_picture"] == "/local/switch.png" -@pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 1==1 }}")]) +@pytest.mark.parametrize( + ("count", "state_template", "attribute"), [(1, "{{ 1==1 }}", "icon")] +) @pytest.mark.parametrize( "attribute_template", - ["{% if states.switch.test_state.state %}mdi:eye{% endif %}"], + ["{% if states.sensor.test_state.state %}mdi:eye{% endif %}"], ) @pytest.mark.parametrize( - ("style", "attribute"), + ("style", "initial_state"), [ - (ConfigurationStyle.MODERN, "icon"), + (ConfigurationStyle.MODERN, ""), + (ConfigurationStyle.TRIGGER, None), ], ) @pytest.mark.usefixtures("setup_state_lock_with_attribute") -async def test_icon_template(hass: HomeAssistant) -> None: +async def test_icon_template(hass: HomeAssistant, initial_state: str) -> None: """Test entity_picture template.""" state = hass.states.get(TEST_ENTITY_ID) - assert state.attributes.get("icon") in ("", None) + assert state.attributes.get("icon") == initial_state hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() @@ -437,10 +523,11 @@ async def test_icon_template(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - ("count", "state_template"), [(1, "{{ states.switch.test_state.state }}")] + ("count", "state_template"), [(1, "{{ states.sensor.test_state.state }}")] ) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_state_lock") async def test_lock_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None: @@ -464,10 +551,11 @@ async def test_lock_action(hass: HomeAssistant, calls: list[ServiceCall]) -> Non @pytest.mark.parametrize( - ("count", "state_template"), [(1, "{{ states.switch.test_state.state }}")] + ("count", "state_template"), [(1, "{{ states.sensor.test_state.state }}")] ) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_state_lock") async def test_unlock_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None: @@ -492,10 +580,11 @@ async def test_unlock_action(hass: HomeAssistant, calls: list[ServiceCall]) -> N @pytest.mark.parametrize( ("count", "state_template", "extra_config"), - [(1, "{{ states.switch.test_state.state }}", OPEN_ACTION)], + [(1, "{{ states.sensor.test_state.state }}", OPEN_ACTION)], ) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_state_lock_with_extra_config") async def test_open_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None: @@ -523,7 +612,7 @@ async def test_open_action(hass: HomeAssistant, calls: list[ServiceCall]) -> Non [ ( 1, - "{{ states.switch.test_state.state }}", + "{{ states.sensor.test_state.state }}", "{{ '.+' }}", ) ], @@ -533,6 +622,7 @@ async def test_open_action(hass: HomeAssistant, calls: list[ServiceCall]) -> Non [ (ConfigurationStyle.LEGACY, "code_format_template"), (ConfigurationStyle.MODERN, "code_format"), + (ConfigurationStyle.TRIGGER, "code_format"), ], ) @pytest.mark.usefixtures("setup_state_lock_with_attribute") @@ -564,7 +654,7 @@ async def test_lock_action_with_code( [ ( 1, - "{{ states.switch.test_state.state }}", + "{{ states.sensor.test_state.state }}", "{{ '.+' }}", ) ], @@ -574,6 +664,7 @@ async def test_lock_action_with_code( [ (ConfigurationStyle.LEGACY, "code_format_template"), (ConfigurationStyle.MODERN, "code_format"), + (ConfigurationStyle.TRIGGER, "code_format"), ], ) @pytest.mark.usefixtures("setup_state_lock_with_attribute") @@ -616,6 +707,7 @@ async def test_unlock_action_with_code( [ (ConfigurationStyle.LEGACY, "code_format_template"), (ConfigurationStyle.MODERN, "code_format"), + (ConfigurationStyle.TRIGGER, "code_format"), ], ) @pytest.mark.parametrize( @@ -630,6 +722,10 @@ async def test_lock_actions_fail_with_invalid_code( hass: HomeAssistant, calls: list[ServiceCall], test_action ) -> None: """Test invalid lock codes.""" + # Ensure trigger entities updated + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + await hass.services.async_call( lock.DOMAIN, test_action, @@ -656,17 +752,23 @@ async def test_lock_actions_fail_with_invalid_code( ], ) @pytest.mark.parametrize( - ("style", "attribute"), + ("style", "attribute", "expected"), [ - (ConfigurationStyle.LEGACY, "code_format_template"), - (ConfigurationStyle.MODERN, "code_format"), + (ConfigurationStyle.LEGACY, "code_format_template", 0), + (ConfigurationStyle.MODERN, "code_format", 0), + (ConfigurationStyle.TRIGGER, "code_format", 2), ], ) @pytest.mark.usefixtures("setup_state_lock_with_attribute") async def test_lock_actions_dont_execute_with_code_template_rendering_error( - hass: HomeAssistant, calls: list[ServiceCall] + hass: HomeAssistant, calls: list[ServiceCall], expected: int ) -> None: """Test lock code format rendering fails block lock/unlock actions.""" + + # Ensure trigger entities updated + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + await hass.services.async_call( lock.DOMAIN, lock.SERVICE_LOCK, @@ -679,7 +781,10 @@ async def test_lock_actions_dont_execute_with_code_template_rendering_error( ) await hass.async_block_till_done() - assert len(calls) == 0 + # Trigger expects calls here because trigger based entities don't + # allow template exception resolutions into code_format property so + # the actions will fire using the previous code_format. + assert len(calls) == expected @pytest.mark.parametrize( @@ -687,7 +792,7 @@ async def test_lock_actions_dont_execute_with_code_template_rendering_error( [ ( 1, - "{{ states.switch.test_state.state }}", + "{{ states.sensor.test_state.state }}", "{{ None }}", ) ], @@ -697,6 +802,7 @@ async def test_lock_actions_dont_execute_with_code_template_rendering_error( [ (ConfigurationStyle.LEGACY, "code_format_template"), (ConfigurationStyle.MODERN, "code_format"), + (ConfigurationStyle.TRIGGER, "code_format"), ], ) @pytest.mark.parametrize("action", [lock.SERVICE_LOCK, lock.SERVICE_UNLOCK]) @@ -729,7 +835,7 @@ async def test_actions_with_none_as_codeformat_ignores_code( [ ( 1, - "{{ states.switch.test_state.state }}", + "{{ states.sensor.test_state.state }}", "[12]{1", ) ], @@ -739,6 +845,7 @@ async def test_actions_with_none_as_codeformat_ignores_code( [ (ConfigurationStyle.LEGACY, "code_format_template"), (ConfigurationStyle.MODERN, "code_format"), + (ConfigurationStyle.TRIGGER, "code_format"), ], ) @pytest.mark.parametrize("action", [lock.SERVICE_LOCK, lock.SERVICE_UNLOCK]) @@ -774,10 +881,11 @@ async def test_actions_with_invalid_regexp_as_codeformat_never_execute( @pytest.mark.parametrize( - ("count", "state_template"), [(1, "{{ states.input_select.test_state.state }}")] + ("count", "state_template"), [(1, "{{ states.sensor.test_state.state }}")] ) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( "test_state", [LockState.UNLOCKING, LockState.LOCKING, LockState.JAMMED] @@ -785,7 +893,7 @@ async def test_actions_with_invalid_regexp_as_codeformat_never_execute( @pytest.mark.usefixtures("setup_state_lock") async def test_lock_state(hass: HomeAssistant, test_state) -> None: """Test value template.""" - hass.states.async_set("input_select.test_state", test_state) + hass.states.async_set(TEST_STATE_ENTITY_ID, test_state) await hass.async_block_till_done() state = hass.states.get(TEST_ENTITY_ID) @@ -797,7 +905,7 @@ async def test_lock_state(hass: HomeAssistant, test_state) -> None: [ ( 1, - "{{ states('switch.test_state') }}", + "{{ states('sensor.test_state') }}", "{{ is_state('availability_state.state', 'on') }}", ) ], @@ -807,20 +915,21 @@ async def test_lock_state(hass: HomeAssistant, test_state) -> None: [ (ConfigurationStyle.LEGACY, "availability_template"), (ConfigurationStyle.MODERN, "availability"), + (ConfigurationStyle.TRIGGER, "availability"), ], ) @pytest.mark.usefixtures("setup_state_lock_with_attribute") async def test_available_template_with_entities(hass: HomeAssistant) -> None: """Test availability templates with values from other entities.""" # When template returns true.. - hass.states.async_set("availability_state.state", STATE_ON) + hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, STATE_ON) await hass.async_block_till_done() # Device State should not be unavailable assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE # When Availability template returns false - hass.states.async_set("availability_state.state", STATE_OFF) + hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, STATE_OFF) await hass.async_block_till_done() # device state should be unavailable @@ -842,15 +951,20 @@ async def test_available_template_with_entities(hass: HomeAssistant) -> None: [ (ConfigurationStyle.LEGACY, "availability_template"), (ConfigurationStyle.MODERN, "availability"), + (ConfigurationStyle.TRIGGER, "availability"), ], ) @pytest.mark.usefixtures("setup_state_lock_with_attribute") async def test_invalid_availability_template_keeps_component_available( - hass: HomeAssistant, caplog_setup_text + hass: HomeAssistant, caplog_setup_text, caplog: pytest.LogCaptureFixture ) -> None: """Test that an invalid availability keeps the device available.""" + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE - assert ("UndefinedError: 'x' is undefined") in caplog_setup_text + err = "'x' is undefined" + assert err in caplog_setup_text or err in caplog.text @pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) diff --git a/tests/components/tilt_pi/__init__.py b/tests/components/tilt_pi/__init__.py new file mode 100644 index 0000000000000..a6109c66ca5f6 --- /dev/null +++ b/tests/components/tilt_pi/__init__.py @@ -0,0 +1,12 @@ +"""Tests for the Tilt Pi integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the integration.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/tilt_pi/conftest.py b/tests/components/tilt_pi/conftest.py new file mode 100644 index 0000000000000..dada9596be502 --- /dev/null +++ b/tests/components/tilt_pi/conftest.py @@ -0,0 +1,70 @@ +"""Common fixtures for the Tilt Pi tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +from tiltpi import TiltColor, TiltHydrometerData + +from homeassistant.components.tilt_pi.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT + +from tests.common import MockConfigEntry + +TEST_NAME = "Test Tilt Pi" +TEST_HOST = "192.168.1.123" +TEST_PORT = 1880 +TEST_URL = f"http://{TEST_HOST}:{TEST_PORT}" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.tilt_pi.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: TEST_HOST, + CONF_PORT: TEST_PORT, + }, + ) + + +@pytest.fixture +def mock_tiltpi_client() -> Generator[AsyncMock]: + """Mock a TiltPi client.""" + with ( + patch( + "homeassistant.components.tilt_pi.coordinator.TiltPiClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.tilt_pi.config_flow.TiltPiClient", + new=mock_client, + ), + ): + client = mock_client.return_value + client.get_hydrometers.return_value = [ + TiltHydrometerData( + mac_id="00:1A:2B:3C:4D:5E", + color=TiltColor.BLACK, + temperature=55.0, + gravity=1.010, + ), + TiltHydrometerData( + mac_id="00:1s:99:f1:d2:4f", + color=TiltColor.YELLOW, + temperature=68.0, + gravity=1.015, + ), + ] + yield client diff --git a/tests/components/tilt_pi/snapshots/test_sensor.ambr b/tests/components/tilt_pi/snapshots/test_sensor.ambr new file mode 100644 index 0000000000000..bcee6881d755b --- /dev/null +++ b/tests/components/tilt_pi/snapshots/test_sensor.ambr @@ -0,0 +1,217 @@ +# serializer version: 1 +# name: test_all_sensors[sensor.tilt_black_gravity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.tilt_black_gravity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Gravity', + 'platform': 'tilt_pi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'gravity', + 'unique_id': '00:1A:2B:3C:4D:5E_gravity', + 'unit_of_measurement': 'SG', + }) +# --- +# name: test_all_sensors[sensor.tilt_black_gravity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Tilt Black Gravity', + 'state_class': , + 'unit_of_measurement': 'SG', + }), + 'context': , + 'entity_id': 'sensor.tilt_black_gravity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.01', + }) +# --- +# name: test_all_sensors[sensor.tilt_black_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.tilt_black_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tilt_pi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:1A:2B:3C:4D:5E_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.tilt_black_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Tilt Black Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.tilt_black_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.7777777777778', + }) +# --- +# name: test_all_sensors[sensor.tilt_yellow_gravity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.tilt_yellow_gravity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Gravity', + 'platform': 'tilt_pi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'gravity', + 'unique_id': '00:1s:99:f1:d2:4f_gravity', + 'unit_of_measurement': 'SG', + }) +# --- +# name: test_all_sensors[sensor.tilt_yellow_gravity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Tilt Yellow Gravity', + 'state_class': , + 'unit_of_measurement': 'SG', + }), + 'context': , + 'entity_id': 'sensor.tilt_yellow_gravity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.015', + }) +# --- +# name: test_all_sensors[sensor.tilt_yellow_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.tilt_yellow_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tilt_pi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:1s:99:f1:d2:4f_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.tilt_yellow_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Tilt Yellow Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.tilt_yellow_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.0', + }) +# --- diff --git a/tests/components/tilt_pi/test_config_flow.py b/tests/components/tilt_pi/test_config_flow.py new file mode 100644 index 0000000000000..f9b9693b9f890 --- /dev/null +++ b/tests/components/tilt_pi/test_config_flow.py @@ -0,0 +1,125 @@ +"""Test the Tilt config flow.""" + +from unittest.mock import AsyncMock + +from homeassistant.components.tilt_pi.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_async_step_user_gets_form_and_creates_entry( + hass: HomeAssistant, + mock_tiltpi_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test that the we can view the form and that the config flow creates an entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_URL: "http://192.168.1.123:1880"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: "192.168.1.123", + CONF_PORT: 1880, + } + + +async def test_abort_if_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test that we abort if we attempt to submit the same entry twice.""" + mock_config_entry.add_to_hass(hass) + + 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_URL: "http://192.168.1.123:1880"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_successful_recovery_after_invalid_host( + hass: HomeAssistant, + mock_tiltpi_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test error shown when user submits invalid host.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + # Simulate a invalid host error by providing an invalid URL + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_URL: "not-a-valid-url"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"url": "invalid_host"} + + # Demonstrate successful connection on retry + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_URL: "http://192.168.1.123:1880"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: "192.168.1.123", + CONF_PORT: 1880, + } + + +async def test_successful_recovery_after_connection_error( + hass: HomeAssistant, + mock_tiltpi_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test error shown when connection fails.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + # Simulate a connection error by raising a TimeoutError + mock_tiltpi_client.get_hydrometers.side_effect = TimeoutError() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_URL: "http://192.168.1.123:1880"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + # Simulate successful connection on retry + mock_tiltpi_client.get_hydrometers.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_URL: "http://192.168.1.123:1880"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: "192.168.1.123", + CONF_PORT: 1880, + } diff --git a/tests/components/tilt_pi/test_sensor.py b/tests/components/tilt_pi/test_sensor.py new file mode 100644 index 0000000000000..cb4e02818c750 --- /dev/null +++ b/tests/components/tilt_pi/test_sensor.py @@ -0,0 +1,84 @@ +"""Test the Tilt Hydrometer sensors.""" + +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from syrupy.assertion import SnapshotAssertion +from tiltpi import TiltColor, TiltPiConnectionError + +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_all_sensors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tiltpi_client: AsyncMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Tilt Pi sensors. + + When making changes to this test, ensure that the snapshot reflects the + new data by generating it via: + + $ pytest tests/components/tilt_pi/test_sensor.py -v --snapshot-update + """ + with patch("homeassistant.components.tilt_pi.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_availability( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tiltpi_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that entities become unavailable when the coordinator fails.""" + with patch("homeassistant.components.tilt_pi.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + # Simulate a coordinator update failure + mock_tiltpi_client.get_hydrometers.side_effect = TiltPiConnectionError() + freezer.tick(60) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Check that entities are unavailable + for color in (TiltColor.BLACK, TiltColor.YELLOW): + temperature_entity_id = f"sensor.tilt_{color}_temperature" + gravity_entity_id = f"sensor.tilt_{color}_gravity" + + temperature_state = hass.states.get(temperature_entity_id) + assert temperature_state is not None + assert temperature_state.state == STATE_UNAVAILABLE + + gravity_state = hass.states.get(gravity_entity_id) + assert gravity_state is not None + assert gravity_state.state == STATE_UNAVAILABLE + + # Simulate a coordinator update success + mock_tiltpi_client.get_hydrometers.side_effect = None + freezer.tick(60) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Check that entities are now available + for color in (TiltColor.BLACK, TiltColor.YELLOW): + temperature_entity_id = f"sensor.tilt_{color}_temperature" + gravity_entity_id = f"sensor.tilt_{color}_gravity" + + temperature_state = hass.states.get(temperature_entity_id) + assert temperature_state is not None + assert temperature_state.state != STATE_UNAVAILABLE + + gravity_state = hass.states.get(gravity_entity_id) + assert gravity_state is not None + assert gravity_state.state != STATE_UNAVAILABLE diff --git a/tests/components/trend/test_binary_sensor.py b/tests/components/trend/test_binary_sensor.py index 4f19c7e342725..289d7510fbece 100644 --- a/tests/components/trend/test_binary_sensor.py +++ b/tests/components/trend/test_binary_sensor.py @@ -48,6 +48,7 @@ async def _setup_legacy_component(hass: HomeAssistant, params: dict[str, Any]) - ) async def test_basic_trend_setup_from_yaml( hass: HomeAssistant, + entity_registry: er.EntityRegistry, states: list[str], inverted: bool, expected_state: str, @@ -72,6 +73,43 @@ async def test_basic_trend_setup_from_yaml( assert (sensor_state := hass.states.get("binary_sensor.test_trend_sensor")) assert sensor_state.state == expected_state + # Verify that entity without unique_id in YAML is not in the registry + entity_entry = entity_registry.async_get("binary_sensor.test_trend_sensor") + assert entity_entry is None + + +async def test_trend_setup_from_yaml_with_unique_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test trend setup from YAML with unique_id.""" + await _setup_legacy_component( + hass, + { + "friendly_name": "Test state with ID", + "entity_id": "sensor.cpu_temp", + "unique_id": "my_unique_trend_sensor", + "max_samples": 2.0, + "min_gradient": 0.0, + "sample_duration": 0.0, + }, + ) + + # Set some states to ensure the sensor works + hass.states.async_set("sensor.cpu_temp", "1") + await hass.async_block_till_done() + hass.states.async_set("sensor.cpu_temp", "2") + await hass.async_block_till_done() + + # Check that the sensor exists and has the correct state + assert (sensor_state := hass.states.get("binary_sensor.test_trend_sensor")) + assert sensor_state.state == STATE_ON + + # Check that the entity is registered with the correct unique_id + entity_entry = entity_registry.async_get("binary_sensor.test_trend_sensor") + assert entity_entry is not None + assert entity_entry.unique_id == "my_unique_trend_sensor" + @pytest.mark.parametrize( ("states", "inverted", "expected_state"), diff --git a/tests/components/wallbox/__init__.py b/tests/components/wallbox/__init__.py index 83e39d2f60221..37e7d5059f0f6 100644 --- a/tests/components/wallbox/__init__.py +++ b/tests/components/wallbox/__init__.py @@ -162,6 +162,9 @@ http_404_error = requests.exceptions.HTTPError() http_404_error.response = requests.Response() http_404_error.response.status_code = HTTPStatus.NOT_FOUND +http_429_error = requests.exceptions.HTTPError() +http_429_error.response = requests.Response() +http_429_error.response.status_code = HTTPStatus.TOO_MANY_REQUESTS authorisation_response = { "data": { @@ -192,6 +195,24 @@ } } +invalid_reauth_response = { + "jwt": "fakekeyhere", + "refresh_token": "refresh_fakekeyhere", + "user_id": 12345, + "ttl": 145656758, + "refresh_token_ttl": 145756758, + "error": False, + "status": 200, +} + +http_403_error = requests.exceptions.HTTPError() +http_403_error.response = requests.Response() +http_403_error.response.status_code = HTTPStatus.FORBIDDEN + +http_404_error = requests.exceptions.HTTPError() +http_404_error.response = requests.Response() +http_404_error.response.status_code = HTTPStatus.NOT_FOUND + async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test wallbox sensor class setup.""" diff --git a/tests/components/wallbox/test_config_flow.py b/tests/components/wallbox/test_config_flow.py index 467e20c51c1fc..bdfb4cad18dbd 100644 --- a/tests/components/wallbox/test_config_flow.py +++ b/tests/components/wallbox/test_config_flow.py @@ -1,9 +1,6 @@ """Test the Wallbox config flow.""" -from http import HTTPStatus -import json - -import requests_mock +from unittest.mock import Mock, patch from homeassistant import config_entries from homeassistant.components.wallbox import config_flow @@ -24,23 +21,21 @@ from . import ( authorisation_response, authorisation_response_unauthorised, + http_403_error, + http_404_error, setup_integration, ) from tests.common import MockConfigEntry -test_response = json.loads( - json.dumps( - { - CHARGER_CHARGING_POWER_KEY: 0, - CHARGER_MAX_AVAILABLE_POWER_KEY: "xx", - CHARGER_CHARGING_SPEED_KEY: 0, - CHARGER_ADDED_RANGE_KEY: "xx", - CHARGER_ADDED_ENERGY_KEY: "44.697", - CHARGER_DATA_KEY: {CHARGER_MAX_CHARGING_CURRENT_KEY: 24}, - } - ) -) +test_response = { + CHARGER_CHARGING_POWER_KEY: 0, + CHARGER_MAX_AVAILABLE_POWER_KEY: "xx", + CHARGER_CHARGING_SPEED_KEY: 0, + CHARGER_ADDED_RANGE_KEY: "xx", + CHARGER_ADDED_ENERGY_KEY: "44.697", + CHARGER_DATA_KEY: {CHARGER_MAX_CHARGING_CURRENT_KEY: 24}, +} async def test_show_set_form(hass: HomeAssistant) -> None: @@ -59,17 +54,16 @@ async def test_form_cannot_authenticate(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=HTTPStatus.FORBIDDEN, - ) - mock_request.get( - "https://api.wall-box.com/chargers/status/12345", - json=test_response, - status_code=HTTPStatus.FORBIDDEN, - ) + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(side_effect=http_403_error), + ), + patch( + "homeassistant.components.wallbox.Wallbox.pauseChargingSession", + new=Mock(side_effect=http_403_error), + ), + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -89,17 +83,16 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response_unauthorised, - status_code=HTTPStatus.NOT_FOUND, - ) - mock_request.get( - "https://api.wall-box.com/chargers/status/12345", - json=test_response, - status_code=HTTPStatus.NOT_FOUND, - ) + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(side_effect=http_404_error), + ), + patch( + "homeassistant.components.wallbox.Wallbox.pauseChargingSession", + new=Mock(side_effect=http_404_error), + ), + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -119,17 +112,16 @@ async def test_form_validate_input(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=HTTPStatus.OK, - ) - mock_request.get( - "https://api.wall-box.com/chargers/status/12345", - json=test_response, - status_code=HTTPStatus.OK, - ) + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.getChargerStatus", + new=Mock(return_value=test_response), + ), + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -148,18 +140,16 @@ async def test_form_reauth(hass: HomeAssistant, entry: MockConfigEntry) -> None: await setup_integration(hass, entry) assert entry.state is ConfigEntryState.LOADED - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, - ) - mock_request.get( - "https://api.wall-box.com/chargers/status/12345", - json=test_response, - status_code=200, - ) - + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response_unauthorised), + ), + patch( + "homeassistant.components.wallbox.Wallbox.getChargerStatus", + new=Mock(return_value=test_response), + ), + ): result = await entry.start_reauth_flow(hass) result2 = await hass.config_entries.flow.async_configure( @@ -183,26 +173,16 @@ async def test_form_reauth_invalid(hass: HomeAssistant, entry: MockConfigEntry) await setup_integration(hass, entry) assert entry.state is ConfigEntryState.LOADED - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json={ - "jwt": "fakekeyhere", - "refresh_token": "refresh_fakekeyhere", - "user_id": 12345, - "ttl": 145656758, - "refresh_token_ttl": 145756758, - "error": False, - "status": 200, - }, - status_code=200, - ) - mock_request.get( - "https://api.wall-box.com/chargers/status/12345", - json=test_response, - status_code=200, - ) - + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response_unauthorised), + ), + patch( + "homeassistant.components.wallbox.Wallbox.getChargerStatus", + new=Mock(return_value=test_response), + ), + ): result = await entry.start_reauth_flow(hass) result2 = await hass.config_entries.flow.async_configure( diff --git a/tests/components/wallbox/test_init.py b/tests/components/wallbox/test_init.py index 6d6a5cd1417bf..5048385aaf638 100644 --- a/tests/components/wallbox/test_init.py +++ b/tests/components/wallbox/test_init.py @@ -1,16 +1,15 @@ """Test Wallbox Init Component.""" -import requests_mock +from unittest.mock import Mock, patch -from homeassistant.components.wallbox.const import ( - CHARGER_MAX_CHARGING_CURRENT_KEY, - DOMAIN, -) +from homeassistant.components.wallbox.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from . import ( authorisation_response, + http_403_error, + http_429_error, setup_integration, setup_integration_connection_error, setup_integration_no_eco_mode, @@ -53,18 +52,16 @@ async def test_wallbox_refresh_failed_connection_error_auth( await setup_integration(hass, entry) assert entry.state is ConfigEntryState.LOADED - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=404, - ) - mock_request.get( - "https://api.wall-box.com/chargers/status/12345", - json=test_response, - status_code=200, - ) - + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(side_effect=http_429_error), + ), + patch( + "homeassistant.components.wallbox.Wallbox.pauseChargingSession", + new=Mock(return_value=test_response), + ), + ): wallbox = hass.data[DOMAIN][entry.entry_id] await wallbox.async_refresh() @@ -81,18 +78,68 @@ async def test_wallbox_refresh_failed_invalid_auth( await setup_integration(hass, entry) assert entry.state is ConfigEntryState.LOADED - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=403, - ) - mock_request.put( - "https://api.wall-box.com/v2/charger/12345", - json={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}, - status_code=403, - ) + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(side_effect=http_403_error), + ), + patch( + "homeassistant.components.wallbox.Wallbox.pauseChargingSession", + new=Mock(side_effect=http_403_error), + ), + ): + wallbox = hass.data[DOMAIN][entry.entry_id] + + await wallbox.async_refresh() + + assert await hass.config_entries.async_unload(entry.entry_id) + assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_wallbox_refresh_failed_http_error( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test Wallbox setup with authentication error.""" + + await setup_integration(hass, entry) + assert entry.state is ConfigEntryState.LOADED + + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.getChargerStatus", + new=Mock(side_effect=http_403_error), + ), + ): + wallbox = hass.data[DOMAIN][entry.entry_id] + + await wallbox.async_refresh() + + assert await hass.config_entries.async_unload(entry.entry_id) + assert entry.state is ConfigEntryState.NOT_LOADED + +async def test_wallbox_refresh_failed_too_many_requests( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test Wallbox setup with authentication error.""" + + await setup_integration(hass, entry) + assert entry.state is ConfigEntryState.LOADED + + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.getChargerStatus", + new=Mock(side_effect=http_429_error), + ), + ): wallbox = hass.data[DOMAIN][entry.entry_id] await wallbox.async_refresh() @@ -109,18 +156,16 @@ async def test_wallbox_refresh_failed_connection_error( await setup_integration(hass, entry) assert entry.state is ConfigEntryState.LOADED - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, - ) - mock_request.get( - "https://api.wall-box.com/chargers/status/12345", - json=test_response, - status_code=403, - ) - + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.pauseChargingSession", + new=Mock(side_effect=http_403_error), + ), + ): wallbox = hass.data[DOMAIN][entry.entry_id] await wallbox.async_refresh() diff --git a/tests/components/wallbox/test_lock.py b/tests/components/wallbox/test_lock.py index 1d48e53b51584..5842d708f116b 100644 --- a/tests/components/wallbox/test_lock.py +++ b/tests/components/wallbox/test_lock.py @@ -1,15 +1,18 @@ """Test Wallbox Lock component.""" +from unittest.mock import Mock, patch + import pytest -import requests_mock from homeassistant.components.lock import SERVICE_LOCK, SERVICE_UNLOCK from homeassistant.components.wallbox.const import CHARGER_LOCKED_UNLOCKED_KEY from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from . import ( authorisation_response, + http_429_error, setup_integration, setup_integration_platform_not_ready, setup_integration_read_only, @@ -28,18 +31,20 @@ async def test_wallbox_lock_class(hass: HomeAssistant, entry: MockConfigEntry) - assert state assert state.state == "unlocked" - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, - ) - mock_request.put( - "https://api.wall-box.com/v2/charger/12345", - json={CHARGER_LOCKED_UNLOCKED_KEY: False}, - status_code=200, - ) - + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.lockCharger", + new=Mock(return_value={CHARGER_LOCKED_UNLOCKED_KEY: False}), + ), + patch( + "homeassistant.components.wallbox.Wallbox.unlockCharger", + new=Mock(return_value={CHARGER_LOCKED_UNLOCKED_KEY: False}), + ), + ): await hass.services.async_call( "lock", SERVICE_LOCK, @@ -66,36 +71,73 @@ async def test_wallbox_lock_class_connection_error( await setup_integration(hass, entry) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.lockCharger", + new=Mock(side_effect=ConnectionError), + ), + pytest.raises(ConnectionError), + ): + await hass.services.async_call( + "lock", + SERVICE_LOCK, + { + ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID, + }, + blocking=True, ) - mock_request.put( - "https://api.wall-box.com/v2/charger/12345", - json={CHARGER_LOCKED_UNLOCKED_KEY: False}, - status_code=404, + + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.lockCharger", + new=Mock(side_effect=ConnectionError), + ), + patch( + "homeassistant.components.wallbox.Wallbox.unlockCharger", + new=Mock(side_effect=ConnectionError), + ), + pytest.raises(ConnectionError), + ): + await hass.services.async_call( + "lock", + SERVICE_UNLOCK, + { + ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID, + }, + blocking=True, ) - with pytest.raises(ConnectionError): - await hass.services.async_call( - "lock", - SERVICE_LOCK, - { - ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID, - }, - blocking=True, - ) - with pytest.raises(ConnectionError): - await hass.services.async_call( - "lock", - SERVICE_UNLOCK, - { - ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID, - }, - blocking=True, - ) + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.lockCharger", + new=Mock(side_effect=http_429_error), + ), + patch( + "homeassistant.components.wallbox.Wallbox.unlockCharger", + new=Mock(side_effect=http_429_error), + ), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + "lock", + SERVICE_UNLOCK, + { + ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID, + }, + blocking=True, + ) async def test_wallbox_lock_class_authentication_error( diff --git a/tests/components/wallbox/test_number.py b/tests/components/wallbox/test_number.py index c319668c1616c..c603ae2410629 100644 --- a/tests/components/wallbox/test_number.py +++ b/tests/components/wallbox/test_number.py @@ -1,22 +1,26 @@ """Test Wallbox Switch component.""" +from unittest.mock import Mock, patch + import pytest -import requests_mock from homeassistant.components.input_number import ATTR_VALUE, SERVICE_SET_VALUE from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN -from homeassistant.components.wallbox import InvalidAuth from homeassistant.components.wallbox.const import ( CHARGER_ENERGY_PRICE_KEY, CHARGER_MAX_CHARGING_CURRENT_KEY, CHARGER_MAX_ICP_CURRENT_KEY, ) +from homeassistant.components.wallbox.coordinator import InvalidAuth from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import HomeAssistantError from . import ( authorisation_response, + http_403_error, + http_404_error, + http_429_error, setup_integration, setup_integration_bidir, setup_integration_platform_not_ready, @@ -29,6 +33,14 @@ from tests.common import MockConfigEntry +mock_wallbox = Mock() +mock_wallbox.authenticate = Mock(return_value=authorisation_response) +mock_wallbox.setEnergyCost = Mock(return_value={CHARGER_ENERGY_PRICE_KEY: 1.1}) +mock_wallbox.setMaxChargingCurrent = Mock( + return_value={CHARGER_MAX_CHARGING_CURRENT_KEY: 20} +) +mock_wallbox.setIcpMaxCurrent = Mock(return_value={CHARGER_MAX_ICP_CURRENT_KEY: 10}) + async def test_wallbox_number_class( hass: HomeAssistant, entry: MockConfigEntry @@ -37,17 +49,16 @@ async def test_wallbox_number_class( await setup_integration(hass, entry) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, - ) - mock_request.put( - "https://api.wall-box.com/v2/charger/12345", - json={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}, - status_code=200, - ) + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.setMaxChargingCurrent", + new=Mock(return_value={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}), + ), + ): state = hass.states.get(MOCK_NUMBER_ENTITY_ID) assert state.attributes["min"] == 6 assert state.attributes["max"] == 25 @@ -82,19 +93,16 @@ async def test_wallbox_number_energy_class( await setup_integration(hass, entry) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, - ) - - mock_request.post( - "https://api.wall-box.com/chargers/config/12345", - json={CHARGER_ENERGY_PRICE_KEY: 1.1}, - status_code=200, - ) - + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.setEnergyCost", + new=Mock(return_value={CHARGER_ENERGY_PRICE_KEY: 1.1}), + ), + ): await hass.services.async_call( "number", SERVICE_SET_VALUE, @@ -113,59 +121,113 @@ async def test_wallbox_number_class_connection_error( await setup_integration(hass, entry) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, - ) - mock_request.put( - "https://api.wall-box.com/v2/charger/12345", - json={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}, - status_code=404, + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.setMaxChargingCurrent", + new=Mock(side_effect=http_404_error), + ), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ID, + ATTR_VALUE: 20, + }, + blocking=True, ) - with pytest.raises(ConnectionError): - await hass.services.async_call( - "number", - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ID, - ATTR_VALUE: 20, - }, - blocking=True, - ) - -async def test_wallbox_number_class_energy_price_connection_error( +async def test_wallbox_number_class_too_many_requests( hass: HomeAssistant, entry: MockConfigEntry ) -> None: """Test wallbox sensor class.""" await setup_integration(hass, entry) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.setMaxChargingCurrent", + new=Mock(side_effect=http_429_error), + ), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ID, + ATTR_VALUE: 20, + }, + blocking=True, ) - mock_request.post( - "https://api.wall-box.com/chargers/config/12345", - json={CHARGER_ENERGY_PRICE_KEY: 1.1}, - status_code=404, + + +async def test_wallbox_number_class_energy_price_update_failed( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test wallbox sensor class.""" + + await setup_integration(hass, entry) + + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.setEnergyCost", + new=Mock(side_effect=http_429_error), + ), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, + ATTR_VALUE: 1.1, + }, + blocking=True, ) - with pytest.raises(ConnectionError): - await hass.services.async_call( - "number", - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, - ATTR_VALUE: 1.1, - }, - blocking=True, - ) + +async def test_wallbox_number_class_energy_price_update_connection_error( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test wallbox sensor class.""" + + await setup_integration(hass, entry) + + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.setEnergyCost", + new=Mock(side_effect=http_404_error), + ), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, + ATTR_VALUE: 1.1, + }, + blocking=True, + ) async def test_wallbox_number_class_energy_price_auth_error( @@ -175,29 +237,27 @@ async def test_wallbox_number_class_energy_price_auth_error( await setup_integration(hass, entry) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, - ) - mock_request.post( - "https://api.wall-box.com/chargers/config/12345", - json={CHARGER_ENERGY_PRICE_KEY: 1.1}, - status_code=403, + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.setEnergyCost", + new=Mock(side_effect=http_429_error), + ), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, + ATTR_VALUE: 1.1, + }, + blocking=True, ) - with pytest.raises(ConfigEntryAuthFailed): - await hass.services.async_call( - "number", - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, - ATTR_VALUE: 1.1, - }, - blocking=True, - ) - async def test_wallbox_number_class_platform_not_ready( hass: HomeAssistant, entry: MockConfigEntry @@ -218,19 +278,16 @@ async def test_wallbox_number_class_icp_energy( await setup_integration(hass, entry) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, - ) - - mock_request.post( - "https://api.wall-box.com/chargers/config/12345", - json={CHARGER_MAX_ICP_CURRENT_KEY: 10}, - status_code=200, - ) - + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.setIcpMaxCurrent", + new=Mock(return_value={CHARGER_MAX_ICP_CURRENT_KEY: 10}), + ), + ): await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, @@ -249,29 +306,27 @@ async def test_wallbox_number_class_icp_energy_auth_error( await setup_integration(hass, entry) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, - ) - mock_request.post( - "https://api.wall-box.com/chargers/config/12345", - json={CHARGER_MAX_ICP_CURRENT_KEY: 10}, - status_code=403, + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.setIcpMaxCurrent", + new=Mock(side_effect=http_403_error), + ), + pytest.raises(InvalidAuth), + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ICP_CURRENT_ID, + ATTR_VALUE: 10, + }, + blocking=True, ) - with pytest.raises(InvalidAuth): - await hass.services.async_call( - NUMBER_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ICP_CURRENT_ID, - ATTR_VALUE: 10, - }, - blocking=True, - ) - async def test_wallbox_number_class_icp_energy_connection_error( hass: HomeAssistant, entry: MockConfigEntry @@ -280,25 +335,52 @@ async def test_wallbox_number_class_icp_energy_connection_error( await setup_integration(hass, entry) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, - ) - mock_request.post( - "https://api.wall-box.com/chargers/config/12345", - json={CHARGER_MAX_ICP_CURRENT_KEY: 10}, - status_code=404, + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.setIcpMaxCurrent", + new=Mock(side_effect=http_404_error), + ), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ICP_CURRENT_ID, + ATTR_VALUE: 10, + }, + blocking=True, ) - with pytest.raises(ConnectionError): - await hass.services.async_call( - NUMBER_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ICP_CURRENT_ID, - ATTR_VALUE: 10, - }, - blocking=True, - ) + +async def test_wallbox_number_class_icp_energy_too_many_request( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test wallbox sensor class.""" + + await setup_integration(hass, entry) + + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.setIcpMaxCurrent", + new=Mock(side_effect=http_429_error), + ), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ICP_CURRENT_ID, + ATTR_VALUE: 10, + }, + blocking=True, + ) diff --git a/tests/components/wallbox/test_select.py b/tests/components/wallbox/test_select.py index 516b1e87c2732..f59a8367b4105 100644 --- a/tests/components/wallbox/test_select.py +++ b/tests/components/wallbox/test_select.py @@ -16,6 +16,7 @@ from . import ( authorisation_response, http_404_error, + http_429_error, setup_integration_select, test_response, test_response_eco_mode, @@ -109,7 +110,41 @@ async def test_wallbox_select_class_error( "homeassistant.components.wallbox.Wallbox.enableEcoSmart", new=Mock(side_effect=error), ), - pytest.raises(HomeAssistantError, match="Error communicating with Wallbox API"), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: MOCK_SELECT_ENTITY_ID, + ATTR_OPTION: mode, + }, + blocking=True, + ) + + +@pytest.mark.parametrize(("mode", "response"), TEST_OPTIONS) +async def test_wallbox_select_too_many_requests_error( + hass: HomeAssistant, + entry: MockConfigEntry, + mode, + response, + mock_authenticate, +) -> None: + """Test wallbox select class connection error.""" + + await setup_integration_select(hass, entry, response) + + with ( + patch( + "homeassistant.components.wallbox.Wallbox.disableEcoSmart", + new=Mock(side_effect=http_429_error), + ), + patch( + "homeassistant.components.wallbox.Wallbox.enableEcoSmart", + new=Mock(side_effect=http_429_error), + ), + pytest.raises(HomeAssistantError), ): await hass.services.async_call( SELECT_DOMAIN, diff --git a/tests/components/wallbox/test_switch.py b/tests/components/wallbox/test_switch.py index b7c3a81dc732f..eb983ca44ce34 100644 --- a/tests/components/wallbox/test_switch.py +++ b/tests/components/wallbox/test_switch.py @@ -1,15 +1,16 @@ """Test Wallbox Lock component.""" +from unittest.mock import Mock, patch + import pytest -import requests_mock from homeassistant.components.switch import SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.components.wallbox.const import CHARGER_STATUS_ID_KEY from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import HomeAssistantError -from . import authorisation_response, setup_integration +from . import authorisation_response, http_404_error, http_429_error, setup_integration from .const import MOCK_SWITCH_ENTITY_ID from tests.common import MockConfigEntry @@ -26,18 +27,20 @@ async def test_wallbox_switch_class( assert state assert state.state == "on" - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, - ) - mock_request.post( - "https://api.wall-box.com/v3/chargers/12345/remote-action", - json={CHARGER_STATUS_ID_KEY: 193}, - status_code=200, - ) - + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.pauseChargingSession", + new=Mock(return_value={CHARGER_STATUS_ID_KEY: 193}), + ), + patch( + "homeassistant.components.wallbox.Wallbox.resumeChargingSession", + new=Mock(return_value={CHARGER_STATUS_ID_KEY: 193}), + ), + ): await hass.services.async_call( "switch", SERVICE_TURN_ON, @@ -64,72 +67,52 @@ async def test_wallbox_switch_class_connection_error( await setup_integration(hass, entry) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, - ) - mock_request.post( - "https://api.wall-box.com/v3/chargers/12345/remote-action", - json={CHARGER_STATUS_ID_KEY: 193}, - status_code=404, + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.resumeChargingSession", + new=Mock(side_effect=http_404_error), + ), + pytest.raises(HomeAssistantError), + ): + # Test behavior when a connection error occurs + await hass.services.async_call( + "switch", + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: MOCK_SWITCH_ENTITY_ID, + }, + blocking=True, ) - with pytest.raises(ConnectionError): - await hass.services.async_call( - "switch", - SERVICE_TURN_ON, - { - ATTR_ENTITY_ID: MOCK_SWITCH_ENTITY_ID, - }, - blocking=True, - ) - with pytest.raises(ConnectionError): - await hass.services.async_call( - "switch", - SERVICE_TURN_OFF, - { - ATTR_ENTITY_ID: MOCK_SWITCH_ENTITY_ID, - }, - blocking=True, - ) - - -async def test_wallbox_switch_class_authentication_error( + +async def test_wallbox_switch_class_too_many_requests( hass: HomeAssistant, entry: MockConfigEntry ) -> None: """Test wallbox switch class connection error.""" await setup_integration(hass, entry) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, - ) - mock_request.post( - "https://api.wall-box.com/v3/chargers/12345/remote-action", - json={CHARGER_STATUS_ID_KEY: 193}, - status_code=403, + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.resumeChargingSession", + new=Mock(side_effect=http_429_error), + ), + pytest.raises(HomeAssistantError), + ): + # Test behavior when a connection error occurs + await hass.services.async_call( + "switch", + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: MOCK_SWITCH_ENTITY_ID, + }, + blocking=True, ) - - with pytest.raises(ConfigEntryAuthFailed): - await hass.services.async_call( - "switch", - SERVICE_TURN_ON, - { - ATTR_ENTITY_ID: MOCK_SWITCH_ENTITY_ID, - }, - blocking=True, - ) - with pytest.raises(ConfigEntryAuthFailed): - await hass.services.async_call( - "switch", - SERVICE_TURN_OFF, - { - ATTR_ENTITY_ID: MOCK_SWITCH_ENTITY_ID, - }, - blocking=True, - ) diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 2c9cc19c84b3e..6e4fa34ed26c1 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -17,6 +17,9 @@ TYPE_AUTH_OK, TYPE_AUTH_REQUIRED, ) +from homeassistant.components.websocket_api.commands import ( + ALL_SERVICE_DESCRIPTIONS_JSON_CACHE, +) from homeassistant.components.websocket_api.const import FEATURE_COALESCE_MESSAGES, URL from homeassistant.config_entries import ConfigEntryState from homeassistant.const import SIGNAL_BOOTSTRAP_INTEGRATIONS @@ -667,14 +670,41 @@ async def test_get_services( hass: HomeAssistant, websocket_client: MockHAClientWebSocket ) -> None: """Test get_services command.""" - for id_ in (5, 6): - await websocket_client.send_json({"id": id_, "type": "get_services"}) + assert ALL_SERVICE_DESCRIPTIONS_JSON_CACHE not in hass.data + await websocket_client.send_json_auto_id({"type": "get_services"}) + msg = await websocket_client.receive_json() + assert msg == {"id": 1, "result": {}, "success": True, "type": "result"} - msg = await websocket_client.receive_json() - assert msg["id"] == id_ - assert msg["type"] == const.TYPE_RESULT - assert msg["success"] - assert msg["result"].keys() == hass.services.async_services().keys() + # Check cache is reused + old_cache = hass.data[ALL_SERVICE_DESCRIPTIONS_JSON_CACHE] + await websocket_client.send_json_auto_id({"type": "get_services"}) + msg = await websocket_client.receive_json() + assert msg == {"id": 2, "result": {}, "success": True, "type": "result"} + assert hass.data[ALL_SERVICE_DESCRIPTIONS_JSON_CACHE] is old_cache + + # Load a service and check cache is updated + assert await async_setup_component(hass, "logger", {}) + await websocket_client.send_json_auto_id({"type": "get_services"}) + msg = await websocket_client.receive_json() + assert msg == { + "id": 3, + "result": {"logger": {"set_default_level": ANY, "set_level": ANY}}, + "success": True, + "type": "result", + } + assert hass.data[ALL_SERVICE_DESCRIPTIONS_JSON_CACHE] is not old_cache + + # Check cache is reused + old_cache = hass.data[ALL_SERVICE_DESCRIPTIONS_JSON_CACHE] + await websocket_client.send_json_auto_id({"type": "get_services"}) + msg = await websocket_client.receive_json() + assert msg == { + "id": 4, + "result": {"logger": {"set_default_level": ANY, "set_level": ANY}}, + "success": True, + "type": "result", + } + assert hass.data[ALL_SERVICE_DESCRIPTIONS_JSON_CACHE] is old_cache async def test_get_config(