diff --git a/.strict-typing b/.strict-typing index b34cbfa5fca..a76ba3885bc 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.* @@ -502,6 +503,7 @@ homeassistant.components.tautulli.* homeassistant.components.tcp.* homeassistant.components.technove.* homeassistant.components.tedee.* +homeassistant.components.telegram_bot.* homeassistant.components.text.* homeassistant.components.thethingsnetwork.* homeassistant.components.threshold.* diff --git a/CODEOWNERS b/CODEOWNERS index da247c06cb8..2406606bb28 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 @@ -786,8 +788,6 @@ build.json @home-assistant/supervisor /tests/components/jellyfin/ @RunC0deRun @ctalkington /homeassistant/components/jewish_calendar/ @tsvi /tests/components/jewish_calendar/ @tsvi -/homeassistant/components/juicenet/ @jesserockz -/tests/components/juicenet/ @jesserockz /homeassistant/components/justnimbus/ @kvanzuijlen /tests/components/justnimbus/ @kvanzuijlen /homeassistant/components/jvc_projector/ @SteveEasley @msavazzi @@ -1169,6 +1169,8 @@ build.json @home-assistant/supervisor /tests/components/ping/ @jpbede /homeassistant/components/plaato/ @JohNan /tests/components/plaato/ @JohNan +/homeassistant/components/playstation_network/ @jackjpowell +/tests/components/playstation_network/ @jackjpowell /homeassistant/components/plex/ @jjlawren /tests/components/plex/ @jjlawren /homeassistant/components/plugwise/ @CoMPaTech @bouwew @@ -1670,6 +1672,8 @@ build.json @home-assistant/supervisor /tests/components/vallox/ @andre-richter @slovdahl @viiru- @yozik04 /homeassistant/components/valve/ @home-assistant/core /tests/components/valve/ @home-assistant/core +/homeassistant/components/vegehub/ @ghowevege +/tests/components/vegehub/ @ghowevege /homeassistant/components/velbus/ @Cereal2nd @brefra /tests/components/velbus/ @Cereal2nd @brefra /homeassistant/components/velux/ @Julius2342 @DeerMaximum @pawlizio diff --git a/homeassistant/brands/sony.json b/homeassistant/brands/sony.json index e35d5f4723c..27bc26a33dc 100644 --- a/homeassistant/brands/sony.json +++ b/homeassistant/brands/sony.json @@ -1,5 +1,11 @@ { "domain": "sony", "name": "Sony", - "integrations": ["braviatv", "ps4", "sony_projector", "songpal"] + "integrations": [ + "braviatv", + "ps4", + "sony_projector", + "songpal", + "playstation_network" + ] } diff --git a/homeassistant/components/ai_task/__init__.py b/homeassistant/components/ai_task/__init__.py index 8b3d6e04966..7fec89f384e 100644 --- a/homeassistant/components/ai_task/__init__.py +++ b/homeassistant/components/ai_task/__init__.py @@ -5,6 +5,7 @@ import logging 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 import config_validation as cv, storage 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 69786178583..b6058c11b45 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 @@ DOMAIN = "ai_task" 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 32715bf77d7..12e3975fca6 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 1cdbf20ba4f..f994aaebe8e 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 d0c59fdd09a..1ba5838d18b 100644 --- a/homeassistant/components/ai_task/task.py +++ b/homeassistant/components/ai_task/task.py @@ -5,6 +5,7 @@ from __future__ import annotations 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 00000000000..6040b347bb5 --- /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 00000000000..ec3c8f9d8f9 --- /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 00000000000..93cbbd2c535 --- /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 00000000000..0a537e62af6 --- /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 00000000000..9c012b87b6d --- /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 00000000000..534830a9b70 --- /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 00000000000..4566ac5f6df --- /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 00000000000..f02c442e5cd --- /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 00000000000..a466e1e3c9d --- /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 160d6141959..0003d80cc4f 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 474dc348fd8..f26050b4883 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/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 5221e89deee..ad0a4c96102 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.6.10"] + "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.6.23"] } diff --git a/homeassistant/components/device_automation/condition.py b/homeassistant/components/device_automation/condition.py index 13454d416a0..92901f8e857 100644 --- a/homeassistant/components/device_automation/condition.py +++ b/homeassistant/components/device_automation/condition.py @@ -9,7 +9,10 @@ import voluptuous as vol from homeassistant.const import CONF_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.condition import ConditionProtocol, trace_condition_function +from homeassistant.helpers.condition import ( + ConditionCheckerType, + trace_condition_function, +) from homeassistant.helpers.typing import ConfigType from . import DeviceAutomationType, async_get_device_automation_platform @@ -19,13 +22,24 @@ if TYPE_CHECKING: from homeassistant.helpers import condition -class DeviceAutomationConditionProtocol(ConditionProtocol, Protocol): +class DeviceAutomationConditionProtocol(Protocol): """Define the format of device_condition modules. - Each module must define either CONDITION_SCHEMA or async_validate_condition_config - from ConditionProtocol. + Each module must define either CONDITION_SCHEMA or async_validate_condition_config. """ + CONDITION_SCHEMA: vol.Schema + + async def async_validate_condition_config( + self, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config.""" + + def async_condition_from_config( + self, hass: HomeAssistant, config: ConfigType + ) -> ConditionCheckerType: + """Evaluate state based on configuration.""" + async def async_get_condition_capabilities( self, hass: HomeAssistant, config: ConfigType ) -> dict[str, vol.Schema]: diff --git a/homeassistant/components/enphase_envoy/diagnostics.py b/homeassistant/components/enphase_envoy/diagnostics.py index e59a9fa09c5..a1a9d4ed6b4 100644 --- a/homeassistant/components/enphase_envoy/diagnostics.py +++ b/homeassistant/components/enphase_envoy/diagnostics.py @@ -65,6 +65,7 @@ async def _get_fixture_collection(envoy: Envoy, serial: str) -> dict[str, Any]: "/ivp/ensemble/generator", "/ivp/meters", "/ivp/meters/readings", + "/ivp/pdm/device_data", "/home", ] diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 6f1e0a943ef..5f74da954a0 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["pyenphase"], "quality_scale": "platinum", - "requirements": ["pyenphase==2.0.1"], + "requirements": ["pyenphase==2.1.0"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index 594f5f34088..c1088252618 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -45,6 +45,7 @@ from homeassistant.const import ( UnitOfFrequency, UnitOfPower, UnitOfTemperature, + UnitOfTime, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo @@ -80,6 +81,114 @@ INVERTER_SENSORS = ( device_class=SensorDeviceClass.POWER, value_fn=attrgetter("last_report_watts"), ), + EnvoyInverterSensorEntityDescription( + key="dc_voltage", + translation_key="dc_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + suggested_display_precision=3, + entity_registry_enabled_default=False, + value_fn=attrgetter("dc_voltage"), + ), + EnvoyInverterSensorEntityDescription( + key="dc_current", + translation_key="dc_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + suggested_display_precision=3, + entity_registry_enabled_default=False, + value_fn=attrgetter("dc_current"), + ), + EnvoyInverterSensorEntityDescription( + key="ac_voltage", + translation_key="ac_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + suggested_display_precision=3, + entity_registry_enabled_default=False, + value_fn=attrgetter("ac_voltage"), + ), + EnvoyInverterSensorEntityDescription( + key="ac_current", + translation_key="ac_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + suggested_display_precision=3, + entity_registry_enabled_default=False, + value_fn=attrgetter("ac_current"), + ), + EnvoyInverterSensorEntityDescription( + key="ac_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.FREQUENCY, + suggested_display_precision=3, + entity_registry_enabled_default=False, + value_fn=attrgetter("ac_frequency"), + ), + EnvoyInverterSensorEntityDescription( + key="temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + suggested_display_precision=3, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=attrgetter("temperature"), + ), + EnvoyInverterSensorEntityDescription( + key="lifetime_energy", + translation_key="lifetime_energy", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + entity_registry_enabled_default=False, + value_fn=attrgetter("lifetime_energy"), + ), + EnvoyInverterSensorEntityDescription( + key="energy_today", + translation_key="energy_today", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + entity_registry_enabled_default=False, + value_fn=attrgetter("energy_today"), + ), + EnvoyInverterSensorEntityDescription( + key="last_report_duration", + translation_key="last_report_duration", + native_unit_of_measurement=UnitOfTime.SECONDS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DURATION, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=attrgetter("last_report_duration"), + ), + EnvoyInverterSensorEntityDescription( + key="energy_produced", + translation_key="energy_produced", + native_unit_of_measurement=UnitOfEnergy.MILLIWATT_HOUR, + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.ENERGY, + suggested_display_precision=3, + entity_registry_enabled_default=False, + value_fn=attrgetter("energy_produced"), + ), + EnvoyInverterSensorEntityDescription( + key="max_reported", + translation_key="max_reported", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=attrgetter("max_report_watts"), + ), EnvoyInverterSensorEntityDescription( key=LAST_REPORTED_KEY, translation_key=LAST_REPORTED_KEY, diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index e45c746869d..36319c71bc6 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -379,7 +379,34 @@ "name": "Aggregated Battery capacity" }, "aggregated_soc": { - "name": "Aggregated battery soc" + "name": "Aggregated battery SOC" + }, + "dc_voltage": { + "name": "DC voltage" + }, + "dc_current": { + "name": "DC current" + }, + "ac_voltage": { + "name": "AC voltage" + }, + "ac_current": { + "name": "AC current" + }, + "lifetime_energy": { + "name": "[%key:component::enphase_envoy::entity::sensor::lifetime_production::name%]" + }, + "energy_today": { + "name": "[%key:component::enphase_envoy::entity::sensor::daily_production::name%]" + }, + "energy_produced": { + "name": "Energy production since previous report" + }, + "max_reported": { + "name": "Lifetime maximum power" + }, + "last_report_duration": { + "name": "Last report duration" } }, "switch": { diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 0577ed10c19..68bc8fe040e 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==33.0.0", + "aioesphomeapi==33.1.1", "esphome-dashboard-api==1.3.0", "bleak-esphome==2.16.0" ], diff --git a/homeassistant/components/foscam/__init__.py b/homeassistant/components/foscam/__init__.py index 9643f333bb5..222a7e44a45 100644 --- a/homeassistant/components/foscam/__init__.py +++ b/homeassistant/components/foscam/__init__.py @@ -1,6 +1,6 @@ """The foscam component.""" -from libpyfoscam import FoscamCamera +from libpyfoscamcgi import FoscamCamera from homeassistant.const import ( CONF_HOST, diff --git a/homeassistant/components/foscam/config_flow.py b/homeassistant/components/foscam/config_flow.py index 19c19a1a5f5..562c3f42f8b 100644 --- a/homeassistant/components/foscam/config_flow.py +++ b/homeassistant/components/foscam/config_flow.py @@ -2,8 +2,8 @@ from typing import Any -from libpyfoscam import FoscamCamera -from libpyfoscam.foscam import ( +from libpyfoscamcgi import FoscamCamera +from libpyfoscamcgi.foscamcgi import ( ERROR_FOSCAM_AUTH, ERROR_FOSCAM_UNAVAILABLE, FOSCAM_SUCCESS, diff --git a/homeassistant/components/foscam/coordinator.py b/homeassistant/components/foscam/coordinator.py index 92eb7615e2a..72bf60cffe0 100644 --- a/homeassistant/components/foscam/coordinator.py +++ b/homeassistant/components/foscam/coordinator.py @@ -4,7 +4,7 @@ import asyncio from datetime import timedelta from typing import Any -from libpyfoscam import FoscamCamera +from libpyfoscamcgi import FoscamCamera from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/foscam/manifest.json b/homeassistant/components/foscam/manifest.json index 9ddb7c4b4fc..9e6864cf1c6 100644 --- a/homeassistant/components/foscam/manifest.json +++ b/homeassistant/components/foscam/manifest.json @@ -5,6 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/foscam", "iot_class": "local_polling", - "loggers": ["libpyfoscam"], - "requirements": ["libpyfoscam==1.2.2"] + "loggers": ["libpyfoscamcgi"], + "requirements": ["libpyfoscamcgi==0.0.6"] } diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 7e9ca550275..3a7d160399d 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -12,7 +12,7 @@ from google.genai.types import File, FileState from requests.exceptions import Timeout import voluptuous as vol -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import ( HomeAssistant, @@ -26,7 +26,11 @@ from homeassistant.exceptions import ( ConfigEntryNotReady, HomeAssistantError, ) -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType @@ -56,6 +60,8 @@ type GoogleGenerativeAIConfigEntry = ConfigEntry[Client] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Google Generative AI Conversation.""" + await async_migrate_integration(hass) + async def generate_content(call: ServiceCall) -> ServiceResponse: """Generate content from text and optionally images.""" @@ -209,3 +215,68 @@ async def async_unload_entry( return False return True + + +async def async_migrate_integration(hass: HomeAssistant) -> None: + """Migrate integration entry structure.""" + + entries = hass.config_entries.async_entries(DOMAIN) + if not any(entry.version == 1 for entry in entries): + return + + api_keys_entries: dict[str, ConfigEntry] = {} + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + for entry in entries: + use_existing = False + subentry = ConfigSubentry( + data=entry.options, + subentry_type="conversation", + title=entry.title, + unique_id=None, + ) + if entry.data[CONF_API_KEY] not in api_keys_entries: + use_existing = True + api_keys_entries[entry.data[CONF_API_KEY]] = entry + + parent_entry = api_keys_entries[entry.data[CONF_API_KEY]] + + hass.config_entries.async_add_subentry(parent_entry, subentry) + conversation_entity = entity_registry.async_get_entity_id( + "conversation", + DOMAIN, + entry.entry_id, + ) + if conversation_entity is not None: + entity_registry.async_update_entity( + conversation_entity, + config_entry_id=parent_entry.entry_id, + config_subentry_id=subentry.subentry_id, + new_unique_id=subentry.subentry_id, + ) + + device = device_registry.async_get_device( + identifiers={(DOMAIN, entry.entry_id)} + ) + if device is not None: + device_registry.async_update_device( + device.id, + new_identifiers={(DOMAIN, subentry.subentry_id)}, + add_config_subentry_id=subentry.subentry_id, + add_config_entry_id=parent_entry.entry_id, + ) + if parent_entry.entry_id != entry.entry_id: + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + ) + + if not use_existing: + await hass.config_entries.async_remove(entry.entry_id) + else: + hass.config_entries.async_update_entry( + entry, + options={}, + version=2, + ) diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index ae0f09b1037..4b7c7a0dd47 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -4,8 +4,7 @@ from __future__ import annotations from collections.abc import Mapping import logging -from types import MappingProxyType -from typing import Any +from typing import Any, cast from google import genai from google.genai.errors import APIError, ClientError @@ -15,12 +14,14 @@ import voluptuous as vol from homeassistant.config_entries import ( SOURCE_REAUTH, ConfigEntry, + ConfigEntryState, ConfigFlow, ConfigFlowResult, - OptionsFlow, + ConfigSubentryFlow, + SubentryFlowResult, ) from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import llm from homeassistant.helpers.selector import ( NumberSelector, @@ -45,6 +46,7 @@ from .const import ( CONF_TOP_K, CONF_TOP_P, CONF_USE_GOOGLE_SEARCH_TOOL, + DEFAULT_CONVERSATION_NAME, DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_HARM_BLOCK_THRESHOLD, @@ -66,7 +68,7 @@ STEP_API_DATA_SCHEMA = vol.Schema( RECOMMENDED_OPTIONS = { CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: llm.LLM_API_ASSIST, + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, } @@ -90,7 +92,7 @@ async def validate_input(data: dict[str, Any]) -> None: class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Google Generative AI Conversation.""" - VERSION = 1 + VERSION = 2 async def async_step_api( self, user_input: dict[str, Any] | None = None @@ -98,6 +100,7 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): """Handle the initial step.""" errors: dict[str, str] = {} if user_input is not None: + self._async_abort_entries_match(user_input) try: await validate_input(user_input) except (APIError, Timeout) as err: @@ -117,7 +120,14 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_create_entry( title="Google Generative AI", data=user_input, - options=RECOMMENDED_OPTIONS, + subentries=[ + { + "subentry_type": "conversation", + "data": RECOMMENDED_OPTIONS, + "title": DEFAULT_CONVERSATION_NAME, + "unique_id": None, + } + ], ) return self.async_show_form( step_id="api", @@ -156,41 +166,72 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): }, ) - @staticmethod - def async_get_options_flow( - config_entry: ConfigEntry, - ) -> OptionsFlow: - """Create the options flow.""" - return GoogleGenerativeAIOptionsFlow(config_entry) + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this integration.""" + return {"conversation": ConversationSubentryFlowHandler} -class GoogleGenerativeAIOptionsFlow(OptionsFlow): - """Google Generative AI config flow options handler.""" +class ConversationSubentryFlowHandler(ConfigSubentryFlow): + """Flow for managing conversation subentries.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.last_rendered_recommended = config_entry.options.get( - CONF_RECOMMENDED, False - ) - self._genai_client = config_entry.runtime_data + last_rendered_recommended = False - async def async_step_init( + @property + def _genai_client(self) -> genai.Client: + """Return the Google Generative AI client.""" + return self._get_entry().runtime_data + + @property + def _is_new(self) -> bool: + """Return if this is a new subentry.""" + return self.source == "user" + + async def async_step_set_options( self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Manage the options.""" - options: dict[str, Any] | MappingProxyType[str, Any] = self.config_entry.options + ) -> SubentryFlowResult: + """Set conversation options.""" + # abort if entry is not loaded + if self._get_entry().state != ConfigEntryState.LOADED: + return self.async_abort(reason="entry_not_loaded") + errors: dict[str, str] = {} - if user_input is not None: + if user_input is None: + if self._is_new: + options = RECOMMENDED_OPTIONS.copy() + else: + # If this is a reconfiguration, we need to copy the existing options + # so that we can show the current values in the form. + options = self._get_reconfigure_subentry().data.copy() + + self.last_rendered_recommended = cast( + bool, options.get(CONF_RECOMMENDED, False) + ) + + else: if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: if not user_input.get(CONF_LLM_HASS_API): user_input.pop(CONF_LLM_HASS_API, None) + # Don't allow to save options that enable the Google Seearch tool with an Assist API if not ( user_input.get(CONF_LLM_HASS_API) and user_input.get(CONF_USE_GOOGLE_SEARCH_TOOL, False) is True ): - # Don't allow to save options that enable the Google Seearch tool with an Assist API - return self.async_create_entry(title="", data=user_input) + if self._is_new: + return self.async_create_entry( + title=user_input.pop(CONF_NAME), + data=user_input, + ) + + return self.async_update_and_abort( + self._get_entry(), + self._get_reconfigure_subentry(), + data=user_input, + ) errors[CONF_USE_GOOGLE_SEARCH_TOOL] = "invalid_google_search_option" # Re-render the options again, now with the recommended options shown/hidden @@ -199,15 +240,19 @@ class GoogleGenerativeAIOptionsFlow(OptionsFlow): options = user_input schema = await google_generative_ai_config_option_schema( - self.hass, options, self._genai_client + self.hass, self._is_new, options, self._genai_client ) return self.async_show_form( - step_id="init", data_schema=vol.Schema(schema), errors=errors + step_id="set_options", data_schema=vol.Schema(schema), errors=errors ) + async_step_reconfigure = async_step_set_options + async_step_user = async_step_set_options + async def google_generative_ai_config_option_schema( hass: HomeAssistant, + is_new: bool, options: Mapping[str, Any], genai_client: genai.Client, ) -> dict: @@ -224,23 +269,32 @@ async def google_generative_ai_config_option_schema( ): suggested_llm_apis = [suggested_llm_apis] - schema = { - vol.Optional( - CONF_PROMPT, - description={ - "suggested_value": options.get( - CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT - ) - }, - ): TemplateSelector(), - vol.Optional( - CONF_LLM_HASS_API, - description={"suggested_value": suggested_llm_apis}, - ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)), - vol.Required( - CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) - ): bool, - } + if is_new: + schema: dict[vol.Required | vol.Optional, Any] = { + vol.Required(CONF_NAME, default=DEFAULT_CONVERSATION_NAME): str, + } + else: + schema = {} + + schema.update( + { + vol.Optional( + CONF_PROMPT, + description={ + "suggested_value": options.get( + CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT + ) + }, + ): TemplateSelector(), + vol.Optional( + CONF_LLM_HASS_API, + description={"suggested_value": suggested_llm_apis}, + ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)), + vol.Required( + CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) + ): bool, + } + ) if options.get(CONF_RECOMMENDED): return schema diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py index 831e7d8f508..0735e9015c2 100644 --- a/homeassistant/components/google_generative_ai_conversation/const.py +++ b/homeassistant/components/google_generative_ai_conversation/const.py @@ -6,10 +6,12 @@ DOMAIN = "google_generative_ai_conversation" LOGGER = logging.getLogger(__package__) CONF_PROMPT = "prompt" +DEFAULT_CONVERSATION_NAME = "Google AI Conversation" + ATTR_MODEL = "model" CONF_RECOMMENDED = "recommended" CONF_CHAT_MODEL = "chat_model" -RECOMMENDED_CHAT_MODEL = "models/gemini-2.0-flash" +RECOMMENDED_CHAT_MODEL = "models/gemini-2.5-flash" RECOMMENDED_TTS_MODEL = "gemini-2.5-flash-preview-tts" CONF_TEMPERATURE = "temperature" RECOMMENDED_TEMPERATURE = 1.0 diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 00199f5fe1f..d8eae3f6d0d 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -5,7 +5,7 @@ from __future__ import annotations from typing import Literal from homeassistant.components import assist_pipeline, conversation -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -22,8 +22,14 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up conversation entities.""" - agent = GoogleGenerativeAIConversationEntity(config_entry) - async_add_entities([agent]) + for subentry in config_entry.subentries.values(): + if subentry.subentry_type != "conversation": + continue + + async_add_entities( + [GoogleGenerativeAIConversationEntity(config_entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) class GoogleGenerativeAIConversationEntity( @@ -35,10 +41,10 @@ class GoogleGenerativeAIConversationEntity( _attr_supports_streaming = True - def __init__(self, entry: ConfigEntry) -> None: + def __init__(self, entry: ConfigEntry, subentry: ConfigSubentry) -> None: """Initialize the agent.""" - super().__init__(entry) - if self.entry.options.get(CONF_LLM_HASS_API): + super().__init__(entry, subentry) + if self.subentry.data.get(CONF_LLM_HASS_API): self._attr_supported_features = ( conversation.ConversationEntityFeature.CONTROL ) @@ -70,7 +76,7 @@ class GoogleGenerativeAIConversationEntity( chat_log: conversation.ChatLog, ) -> conversation.ConversationResult: """Call the API.""" - options = self.entry.options + options = self.subentry.data try: await chat_log.async_provide_llm_data( diff --git a/homeassistant/components/google_generative_ai_conversation/entity.py b/homeassistant/components/google_generative_ai_conversation/entity.py index 7eef3dbacff..d4b0ec2bbd0 100644 --- a/homeassistant/components/google_generative_ai_conversation/entity.py +++ b/homeassistant/components/google_generative_ai_conversation/entity.py @@ -24,7 +24,7 @@ from google.genai.types import ( from voluptuous_openapi import convert from homeassistant.components import conversation -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, llm from homeassistant.helpers.entity import Entity @@ -301,17 +301,16 @@ async def _transform_stream( class GoogleGenerativeAILLMBaseEntity(Entity): """Google Generative AI base entity.""" - _attr_has_entity_name = True - _attr_name = None - - def __init__(self, entry: ConfigEntry) -> None: + def __init__(self, entry: ConfigEntry, subentry: ConfigSubentry) -> None: """Initialize the agent.""" self.entry = entry + self.subentry = subentry + self._attr_name = subentry.title self._genai_client = entry.runtime_data - self._attr_unique_id = entry.entry_id + self._attr_unique_id = subentry.subentry_id self._attr_device_info = dr.DeviceInfo( - identifiers={(DOMAIN, entry.entry_id)}, - name=entry.title, + identifiers={(DOMAIN, subentry.subentry_id)}, + name=subentry.title, manufacturer="Google", model="Generative AI", entry_type=dr.DeviceEntryType.SERVICE, @@ -322,7 +321,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity): chat_log: conversation.ChatLog, ) -> None: """Generate an answer for the chat log.""" - options = self.entry.options + options = self.subentry.data tools: list[Tool | Callable[..., Any]] | None = None if chat_log.llm_api: diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index a57e2f78f53..e523aecbaec 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -18,35 +18,49 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } }, - "options": { - "step": { - "init": { - "data": { - "recommended": "Recommended model settings", - "prompt": "Instructions", - "chat_model": "[%key:common::generic::model%]", - "temperature": "Temperature", - "top_p": "Top P", - "top_k": "Top K", - "max_tokens": "Maximum tokens to return in response", - "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", - "harassment_block_threshold": "Negative or harmful comments targeting identity and/or protected attributes", - "hate_block_threshold": "Content that is rude, disrespectful, or profane", - "sexual_block_threshold": "Contains references to sexual acts or other lewd content", - "dangerous_block_threshold": "Promotes, facilitates, or encourages harmful acts", - "enable_google_search_tool": "Enable Google Search tool" - }, - "data_description": { - "prompt": "Instruct how the LLM should respond. This can be a template.", - "enable_google_search_tool": "Only works if there is nothing selected in the \"Control Home Assistant\" setting. See docs for a workaround using it with \"Assist\"." + "config_subentries": { + "conversation": { + "initiate_flow": { + "user": "Add conversation agent", + "reconfigure": "Reconfigure conversation agent" + }, + "entry_type": "Conversation agent", + + "step": { + "set_options": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "recommended": "Recommended model settings", + "prompt": "Instructions", + "chat_model": "[%key:common::generic::model%]", + "temperature": "Temperature", + "top_p": "Top P", + "top_k": "Top K", + "max_tokens": "Maximum tokens to return in response", + "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", + "harassment_block_threshold": "Negative or harmful comments targeting identity and/or protected attributes", + "hate_block_threshold": "Content that is rude, disrespectful, or profane", + "sexual_block_threshold": "Contains references to sexual acts or other lewd content", + "dangerous_block_threshold": "Promotes, facilitates, or encourages harmful acts", + "enable_google_search_tool": "Enable Google Search tool" + }, + "data_description": { + "prompt": "Instruct how the LLM should respond. This can be a template.", + "enable_google_search_tool": "Only works if there is nothing selected in the \"Control Home Assistant\" setting. See docs for a workaround using it with \"Assist\"." + } } + }, + "abort": { + "entry_not_loaded": "Cannot add things while the configuration is disabled.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, + "error": { + "invalid_google_search_option": "Google Search can only be enabled if nothing is selected in the \"Control Home Assistant\" setting." } - }, - "error": { - "invalid_google_search_option": "Google Search can only be enabled if nothing is selected in the \"Control Home Assistant\" setting." } }, "services": { diff --git a/homeassistant/components/google_generative_ai_conversation/tts.py b/homeassistant/components/google_generative_ai_conversation/tts.py index 160048e4897..50baec67db2 100644 --- a/homeassistant/components/google_generative_ai_conversation/tts.py +++ b/homeassistant/components/google_generative_ai_conversation/tts.py @@ -113,7 +113,6 @@ class GoogleGenerativeAITextToSpeechEntity(TextToSpeechEntity): self._attr_unique_id = f"{entry.entry_id}_tts" self._attr_device_info = dr.DeviceInfo( identifiers={(DOMAIN, entry.entry_id)}, - name=entry.title, manufacturer="Google", model="Generative AI", entry_type=dr.DeviceEntryType.SERVICE, diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 6772034e53f..0c15a687421 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -12,7 +12,6 @@ import re import struct from typing import Any, NamedTuple -import aiofiles from aiohasupervisor import SupervisorError import voluptuous as vol @@ -239,12 +238,6 @@ def _is_32_bit() -> bool: return size * 8 == 32 -async def _get_arch() -> str: - async with aiofiles.open("/etc/apk/arch") as arch_file: - raw_arch = await arch_file.read() - return {"x86": "i386"}.get(raw_arch, raw_arch) - - class APIEndpointSettings(NamedTuple): """Settings for API endpoint.""" @@ -566,8 +559,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() hass.data[ADDONS_COORDINATOR] = coordinator - arch = await _get_arch() - def deprecated_setup_issue() -> None: os_info = get_os_info(hass) info = get_info(hass) @@ -575,6 +566,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return is_haos = info.get("hassos") is not None board = os_info.get("board") + arch = info.get("arch", "unknown") unsupported_board = board in {"tinker", "odroid-xu4", "rpi2"} unsupported_os_on_board = board in {"rpi3", "rpi4"} if is_haos and (unsupported_board or unsupported_os_on_board): diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 4360fa9c16e..d5dabfa2e08 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -7,7 +7,6 @@ import logging import struct from typing import Any -import aiofiles import voluptuous as vol from homeassistant import config as conf_util, core_config @@ -18,6 +17,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_LATITUDE, ATTR_LONGITUDE, + EVENT_HOMEASSISTANT_STARTED, RESTART_EXIT_CODE, SERVICE_RELOAD, SERVICE_SAVE_PERSISTENT_STATES, @@ -26,6 +26,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, ) from homeassistant.core import ( + Event, HomeAssistant, ServiceCall, ServiceResponse, @@ -101,12 +102,6 @@ def _is_32_bit() -> bool: return size * 8 == 32 -async def _get_arch() -> str: - async with aiofiles.open("/etc/apk/arch") as arch_file: - raw_arch = (await arch_file.read()).strip() - return {"x86": "i386", "x86_64": "amd64"}.get(raw_arch, raw_arch) - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: C901 """Set up general services related to Home Assistant.""" @@ -411,45 +406,50 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: hass.data[DATA_EXPOSED_ENTITIES] = exposed_entities async_set_stop_handler(hass, _async_stop) - info = await async_get_system_info(hass) + async def _async_check_deprecation(event: Event) -> None: + """Check and create deprecation issues after startup.""" + info = await async_get_system_info(hass) - installation_type = info["installation_type"][15:] - if installation_type in {"Core", "Container"}: - deprecated_method = installation_type == "Core" - bit32 = _is_32_bit() - arch = info["arch"] - if bit32 and installation_type == "Container": - arch = await _get_arch() - ir.async_create_issue( - hass, - DOMAIN, - "deprecated_container", - learn_more_url=DEPRECATION_URL, - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_container", - translation_placeholders={"arch": arch}, - ) - deprecated_architecture = bit32 and installation_type != "Container" - if deprecated_method or deprecated_architecture: - issue_id = "deprecated" - if deprecated_method: - issue_id += "_method" - if deprecated_architecture: - issue_id += "_architecture" - ir.async_create_issue( - hass, - DOMAIN, - issue_id, - learn_more_url=DEPRECATION_URL, - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key=issue_id, - translation_placeholders={ - "installation_type": installation_type, - "arch": arch, - }, - ) + installation_type = info["installation_type"][15:] + if installation_type in {"Core", "Container"}: + deprecated_method = installation_type == "Core" + bit32 = _is_32_bit() + arch = info["arch"] + if bit32 and installation_type == "Container": + arch = info.get("container_arch", arch) + ir.async_create_issue( + hass, + DOMAIN, + "deprecated_container", + learn_more_url=DEPRECATION_URL, + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_container", + translation_placeholders={"arch": arch}, + ) + deprecated_architecture = bit32 and installation_type != "Container" + if deprecated_method or deprecated_architecture: + issue_id = "deprecated" + if deprecated_method: + issue_id += "_method" + if deprecated_architecture: + issue_id += "_architecture" + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + learn_more_url=DEPRECATION_URL, + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key=issue_id, + translation_placeholders={ + "installation_type": installation_type, + "arch": arch, + }, + ) + + # Delay deprecation check to make sure installation method is determined correctly + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _async_check_deprecation) return True diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 940af999c4d..7c95680076c 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -124,6 +124,7 @@ "info": { "arch": "CPU architecture", "config_dir": "Configuration directory", + "container_arch": "Container architecture", "dev": "Development", "docker": "Docker", "hassio": "Supervisor", diff --git a/homeassistant/components/homeassistant/system_health.py b/homeassistant/components/homeassistant/system_health.py index 8a51b9cd418..3f98c5ae6e0 100644 --- a/homeassistant/components/homeassistant/system_health.py +++ b/homeassistant/components/homeassistant/system_health.py @@ -27,6 +27,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: "dev": info.get("dev"), "hassio": info.get("hassio"), "docker": info.get("docker"), + "container_arch": info.get("container_arch"), "user": info.get("user"), "virtualenv": info.get("virtualenv"), "python_version": info.get("python_version"), diff --git a/homeassistant/components/homeassistant_hardware/manifest.json b/homeassistant/components/homeassistant_hardware/manifest.json index f3a02185b83..cf9acf14a5d 100644 --- a/homeassistant/components/homeassistant_hardware/manifest.json +++ b/homeassistant/components/homeassistant_hardware/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware", "integration_type": "system", "requirements": [ - "universal-silabs-flasher==0.0.30", + "universal-silabs-flasher==0.0.31", "ha-silabs-firmware-client==0.2.0" ] } diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index 9fd74fa80e4..f9924a68db4 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -12,6 +12,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==9.1.1"], + "requirements": ["python-homewizard-energy==9.2.0"], "zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."] } diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json index 4216ece64cb..84594a440f9 100644 --- a/homeassistant/components/homewizard/strings.json +++ b/homeassistant/components/homewizard/strings.json @@ -24,7 +24,7 @@ }, "authorize": { "title": "Authorize", - "description": "Press the button on the HomeWizard Energy device, then select the button below." + "description": "Press the button on the HomeWizard Energy device for two seconds, then select the button below." }, "reconfigure": { "description": "Update configuration for {title}.", diff --git a/homeassistant/components/juicenet/__init__.py b/homeassistant/components/juicenet/__init__.py index 6cfdd85c6b7..5d2c10bcd1c 100644 --- a/homeassistant/components/juicenet/__init__.py +++ b/homeassistant/components/juicenet/__init__.py @@ -1,95 +1,36 @@ """The JuiceNet integration.""" -import logging - -import aiohttp -from pyjuicenet import Api, TokenError -import voluptuous as vol - -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_ACCESS_TOKEN, Platform +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers import issue_registry as ir -from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR -from .coordinator import JuiceNetCoordinator -from .device import JuiceNetApi - -_LOGGER = logging.getLogger(__name__) - -PLATFORMS = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] - -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - {DOMAIN: vol.Schema({vol.Required(CONF_ACCESS_TOKEN): cv.string})}, - ), - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the JuiceNet component.""" - conf = config.get(DOMAIN) - hass.data.setdefault(DOMAIN, {}) - - if not conf: - return True - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=conf - ) - ) - return True +from .const import DOMAIN async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up JuiceNet from a config entry.""" - config = entry.data - - session = async_get_clientsession(hass) - - access_token = config[CONF_ACCESS_TOKEN] - api = Api(access_token, session) - - juicenet = JuiceNetApi(api) - - try: - await juicenet.setup() - except TokenError as error: - _LOGGER.error("JuiceNet Error %s", error) - return False - except aiohttp.ClientError as error: - _LOGGER.error("Could not reach the JuiceNet API %s", error) - raise ConfigEntryNotReady from error - - if not juicenet.devices: - _LOGGER.error("No JuiceNet devices found for this account") - return False - _LOGGER.debug("%d JuiceNet device(s) found", len(juicenet.devices)) - - coordinator = JuiceNetCoordinator(hass, entry, juicenet) - - await coordinator.async_config_entry_first_refresh() - - hass.data[DOMAIN][entry.entry_id] = { - JUICENET_API: juicenet, - JUICENET_COORDINATOR: coordinator, - } - - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - + ir.async_create_issue( + hass, + DOMAIN, + DOMAIN, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="integration_removed", + translation_placeholders={ + "entries": "/config/integrations/integration/juicenet", + }, + ) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + if all( + config_entry.state is ConfigEntryState.NOT_LOADED + for config_entry in hass.config_entries.async_entries(DOMAIN) + if config_entry.entry_id != entry.entry_id + ): + ir.async_delete_issue(hass, DOMAIN, DOMAIN) + + return True diff --git a/homeassistant/components/juicenet/config_flow.py b/homeassistant/components/juicenet/config_flow.py index 8bcee5677e6..a5da1c50486 100644 --- a/homeassistant/components/juicenet/config_flow.py +++ b/homeassistant/components/juicenet/config_flow.py @@ -1,82 +1,11 @@ """Config flow for JuiceNet integration.""" -import logging -from typing import Any - -import aiohttp -from pyjuicenet import Api, TokenError -import voluptuous as vol - -from homeassistant import core, exceptions -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_ACCESS_TOKEN -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.config_entries import ConfigFlow from .const import DOMAIN -_LOGGER = logging.getLogger(__name__) - -DATA_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str}) - - -async def validate_input(hass: core.HomeAssistant, data): - """Validate the user input allows us to connect. - - Data has the keys from DATA_SCHEMA with values provided by the user. - """ - session = async_get_clientsession(hass) - juicenet = Api(data[CONF_ACCESS_TOKEN], session) - - try: - await juicenet.get_devices() - except TokenError as error: - _LOGGER.error("Token Error %s", error) - raise InvalidAuth from error - except aiohttp.ClientError as error: - _LOGGER.error("Error connecting %s", error) - raise CannotConnect from error - - # Return info that you want to store in the config entry. - return {"title": "JuiceNet"} - class JuiceNetConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for JuiceNet.""" VERSION = 1 - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle the initial step.""" - errors = {} - if user_input is not None: - await self.async_set_unique_id(user_input[CONF_ACCESS_TOKEN]) - self._abort_if_unique_id_configured() - - try: - info = await validate_input(self.hass, user_input) - return self.async_create_entry(title=info["title"], data=user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - except InvalidAuth: - errors["base"] = "invalid_auth" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - - return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors - ) - - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Handle import.""" - return await self.async_step_user(import_data) - - -class CannotConnect(exceptions.HomeAssistantError): - """Error to indicate we cannot connect.""" - - -class InvalidAuth(exceptions.HomeAssistantError): - """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/juicenet/const.py b/homeassistant/components/juicenet/const.py index 5dc3e5c3e27..a1072dffb87 100644 --- a/homeassistant/components/juicenet/const.py +++ b/homeassistant/components/juicenet/const.py @@ -1,6 +1,3 @@ """Constants used by the JuiceNet component.""" DOMAIN = "juicenet" - -JUICENET_API = "juicenet_api" -JUICENET_COORDINATOR = "juicenet_coordinator" diff --git a/homeassistant/components/juicenet/coordinator.py b/homeassistant/components/juicenet/coordinator.py deleted file mode 100644 index 7a89416e400..00000000000 --- a/homeassistant/components/juicenet/coordinator.py +++ /dev/null @@ -1,33 +0,0 @@ -"""The JuiceNet integration.""" - -from datetime import timedelta -import logging - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - -from .device import JuiceNetApi - -_LOGGER = logging.getLogger(__name__) - - -class JuiceNetCoordinator(DataUpdateCoordinator[None]): - """Coordinator for JuiceNet.""" - - def __init__( - self, hass: HomeAssistant, entry: ConfigEntry, juicenet_api: JuiceNetApi - ) -> None: - """Initialize the JuiceNet coordinator.""" - super().__init__( - hass, - _LOGGER, - config_entry=entry, - name="JuiceNet", - update_interval=timedelta(seconds=30), - ) - self.juicenet_api = juicenet_api - - async def _async_update_data(self) -> None: - for device in self.juicenet_api.devices: - await device.update_state(True) diff --git a/homeassistant/components/juicenet/device.py b/homeassistant/components/juicenet/device.py deleted file mode 100644 index b38b0efd68a..00000000000 --- a/homeassistant/components/juicenet/device.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Adapter to wrap the pyjuicenet api for home assistant.""" - -from pyjuicenet import Api, Charger - - -class JuiceNetApi: - """Represent a connection to JuiceNet.""" - - def __init__(self, api: Api) -> None: - """Create an object from the provided API instance.""" - self.api = api - self._devices: list[Charger] = [] - - async def setup(self) -> None: - """JuiceNet device setup.""" - self._devices = await self.api.get_devices() - - @property - def devices(self) -> list[Charger]: - """Get a list of devices managed by this account.""" - return self._devices diff --git a/homeassistant/components/juicenet/entity.py b/homeassistant/components/juicenet/entity.py deleted file mode 100644 index d54ccb5accb..00000000000 --- a/homeassistant/components/juicenet/entity.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Adapter to wrap the pyjuicenet api for home assistant.""" - -from pyjuicenet import Charger - -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from .const import DOMAIN -from .coordinator import JuiceNetCoordinator - - -class JuiceNetEntity(CoordinatorEntity[JuiceNetCoordinator]): - """Represent a base JuiceNet device.""" - - _attr_has_entity_name = True - - def __init__( - self, device: Charger, key: str, coordinator: JuiceNetCoordinator - ) -> None: - """Initialise the sensor.""" - super().__init__(coordinator) - self.device = device - self.key = key - self._attr_unique_id = f"{device.id}-{key}" - self._attr_device_info = DeviceInfo( - configuration_url=( - f"https://home.juice.net/Portal/Details?unitID={device.id}" - ), - identifiers={(DOMAIN, device.id)}, - manufacturer="JuiceNet", - name=device.name, - ) diff --git a/homeassistant/components/juicenet/manifest.json b/homeassistant/components/juicenet/manifest.json index 979e540af01..5bdad83ac1e 100644 --- a/homeassistant/components/juicenet/manifest.json +++ b/homeassistant/components/juicenet/manifest.json @@ -1,10 +1,9 @@ { "domain": "juicenet", "name": "JuiceNet", - "codeowners": ["@jesserockz"], - "config_flow": true, + "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/juicenet", + "integration_type": "system", "iot_class": "cloud_polling", - "loggers": ["pyjuicenet"], - "requirements": ["python-juicenet==1.1.0"] + "requirements": [] } diff --git a/homeassistant/components/juicenet/number.py b/homeassistant/components/juicenet/number.py deleted file mode 100644 index ff8c357a115..00000000000 --- a/homeassistant/components/juicenet/number.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Support for controlling juicenet/juicepoint/juicebox based EVSE numbers.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from pyjuicenet import Charger - -from homeassistant.components.number import ( - DEFAULT_MAX_VALUE, - NumberEntity, - NumberEntityDescription, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR -from .coordinator import JuiceNetCoordinator -from .device import JuiceNetApi -from .entity import JuiceNetEntity - - -@dataclass(frozen=True, kw_only=True) -class JuiceNetNumberEntityDescription(NumberEntityDescription): - """An entity description for a JuiceNetNumber.""" - - setter_key: str - native_max_value_key: str | None = None - - -NUMBER_TYPES: tuple[JuiceNetNumberEntityDescription, ...] = ( - JuiceNetNumberEntityDescription( - translation_key="amperage_limit", - key="current_charging_amperage_limit", - native_min_value=6, - native_max_value_key="max_charging_amperage", - native_step=1, - setter_key="set_charging_amperage_limit", - ), -) - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up the JuiceNet Numbers.""" - juicenet_data = hass.data[DOMAIN][config_entry.entry_id] - api: JuiceNetApi = juicenet_data[JUICENET_API] - coordinator: JuiceNetCoordinator = juicenet_data[JUICENET_COORDINATOR] - - entities = [ - JuiceNetNumber(device, description, coordinator) - for device in api.devices - for description in NUMBER_TYPES - ] - async_add_entities(entities) - - -class JuiceNetNumber(JuiceNetEntity, NumberEntity): - """Implementation of a JuiceNet number.""" - - entity_description: JuiceNetNumberEntityDescription - - def __init__( - self, - device: Charger, - description: JuiceNetNumberEntityDescription, - coordinator: JuiceNetCoordinator, - ) -> None: - """Initialise the number.""" - super().__init__(device, description.key, coordinator) - self.entity_description = description - - @property - def native_value(self) -> float | None: - """Return the value of the entity.""" - return getattr(self.device, self.entity_description.key, None) - - @property - def native_max_value(self) -> float: - """Return the maximum value.""" - if self.entity_description.native_max_value_key is not None: - return getattr(self.device, self.entity_description.native_max_value_key) - if self.entity_description.native_max_value is not None: - return self.entity_description.native_max_value - return DEFAULT_MAX_VALUE - - async def async_set_native_value(self, value: float) -> None: - """Update the current value.""" - await getattr(self.device, self.entity_description.setter_key)(value) diff --git a/homeassistant/components/juicenet/sensor.py b/homeassistant/components/juicenet/sensor.py deleted file mode 100644 index e3ae35da2ce..00000000000 --- a/homeassistant/components/juicenet/sensor.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Support for monitoring juicenet/juicepoint/juicebox based EVSE sensors.""" - -from __future__ import annotations - -from pyjuicenet import Charger - -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntity, - SensorEntityDescription, - SensorStateClass, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - UnitOfElectricCurrent, - UnitOfElectricPotential, - UnitOfEnergy, - UnitOfPower, - UnitOfTemperature, - UnitOfTime, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR -from .coordinator import JuiceNetCoordinator -from .device import JuiceNetApi -from .entity import JuiceNetEntity - -SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( - key="status", - name="Charging Status", - ), - SensorEntityDescription( - key="temperature", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), - SensorEntityDescription( - key="voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - ), - SensorEntityDescription( - key="amps", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - ), - SensorEntityDescription( - key="watts", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - ), - SensorEntityDescription( - key="charge_time", - translation_key="charge_time", - native_unit_of_measurement=UnitOfTime.SECONDS, - icon="mdi:timer-outline", - ), - SensorEntityDescription( - key="energy_added", - translation_key="energy_added", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - ), -) - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up the JuiceNet Sensors.""" - juicenet_data = hass.data[DOMAIN][config_entry.entry_id] - api: JuiceNetApi = juicenet_data[JUICENET_API] - coordinator: JuiceNetCoordinator = juicenet_data[JUICENET_COORDINATOR] - - entities = [ - JuiceNetSensorDevice(device, coordinator, description) - for device in api.devices - for description in SENSOR_TYPES - ] - async_add_entities(entities) - - -class JuiceNetSensorDevice(JuiceNetEntity, SensorEntity): - """Implementation of a JuiceNet sensor.""" - - def __init__( - self, - device: Charger, - coordinator: JuiceNetCoordinator, - description: SensorEntityDescription, - ) -> None: - """Initialise the sensor.""" - super().__init__(device, description.key, coordinator) - self.entity_description = description - - @property - def icon(self): - """Return the icon of the sensor.""" - icon = None - if self.entity_description.key == "status": - status = self.device.status - if status == "standby": - icon = "mdi:power-plug-off" - elif status == "plugged": - icon = "mdi:power-plug" - elif status == "charging": - icon = "mdi:battery-positive" - else: - icon = self.entity_description.icon - return icon - - @property - def native_value(self): - """Return the state.""" - return getattr(self.device, self.entity_description.key, None) diff --git a/homeassistant/components/juicenet/strings.json b/homeassistant/components/juicenet/strings.json index 0e3732c66d2..6e25130955b 100644 --- a/homeassistant/components/juicenet/strings.json +++ b/homeassistant/components/juicenet/strings.json @@ -1,41 +1,8 @@ { - "config": { - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" - }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "step": { - "user": { - "data": { - "api_token": "[%key:common::config_flow::data::api_token%]" - }, - "description": "You will need the API Token from https://home.juice.net/Manage.", - "title": "Connect to JuiceNet" - } - } - }, - "entity": { - "number": { - "amperage_limit": { - "name": "Amperage limit" - } - }, - "sensor": { - "charge_time": { - "name": "Charge time" - }, - "energy_added": { - "name": "Energy added" - } - }, - "switch": { - "charge_now": { - "name": "Charge now" - } + "issues": { + "integration_removed": { + "title": "The JuiceNet integration has been removed", + "description": "Enel X has dropped support for JuiceNet in favor of JuicePass, and the JuiceNet integration has been removed from Home Assistant as it was no longer working.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing JuiceNet integration entries]({entries})." } } } diff --git a/homeassistant/components/juicenet/switch.py b/homeassistant/components/juicenet/switch.py deleted file mode 100644 index e8a16e9da8f..00000000000 --- a/homeassistant/components/juicenet/switch.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Support for monitoring juicenet/juicepoint/juicebox based EVSE switches.""" - -from typing import Any - -from pyjuicenet import Charger - -from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR -from .coordinator import JuiceNetCoordinator -from .device import JuiceNetApi -from .entity import JuiceNetEntity - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up the JuiceNet switches.""" - juicenet_data = hass.data[DOMAIN][config_entry.entry_id] - api: JuiceNetApi = juicenet_data[JUICENET_API] - coordinator: JuiceNetCoordinator = juicenet_data[JUICENET_COORDINATOR] - - async_add_entities( - JuiceNetChargeNowSwitch(device, coordinator) for device in api.devices - ) - - -class JuiceNetChargeNowSwitch(JuiceNetEntity, SwitchEntity): - """Implementation of a JuiceNet switch.""" - - _attr_translation_key = "charge_now" - - def __init__(self, device: Charger, coordinator: JuiceNetCoordinator) -> None: - """Initialise the switch.""" - super().__init__(device, "charge_now", coordinator) - - @property - def is_on(self): - """Return true if switch is on.""" - return self.device.override_time != 0 - - async def async_turn_on(self, **kwargs: Any) -> None: - """Charge now.""" - await self.device.set_override(True) - - async def async_turn_off(self, **kwargs: Any) -> None: - """Don't charge now.""" - await self.device.set_override(False) diff --git a/homeassistant/components/keenetic_ndms2/config_flow.py b/homeassistant/components/keenetic_ndms2/config_flow.py index 7219819b911..3862d34398f 100644 --- a/homeassistant/components/keenetic_ndms2/config_flow.py +++ b/homeassistant/components/keenetic_ndms2/config_flow.py @@ -146,17 +146,27 @@ class KeeneticOptionsFlowHandler(OptionsFlow): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage the options.""" + if ( + not hasattr(self.config_entry, "runtime_data") + or not self.config_entry.runtime_data + ): + return self.async_abort(reason="not_initialized") + router = self.config_entry.runtime_data - interfaces: list[InterfaceInfo] = await self.hass.async_add_executor_job( - router.client.get_interfaces - ) + try: + interfaces: list[InterfaceInfo] = await self.hass.async_add_executor_job( + router.client.get_interfaces + ) + except ConnectionException: + return self.async_abort(reason="cannot_connect") self._interface_options = { interface.name: (interface.description or interface.name) for interface in interfaces if interface.type.lower() == "bridge" } + return await self.async_step_user() async def async_step_user( @@ -182,9 +192,13 @@ class KeeneticOptionsFlowHandler(OptionsFlow): ): int, vol.Required( CONF_INTERFACES, - default=self.config_entry.options.get( - CONF_INTERFACES, [DEFAULT_INTERFACE] - ), + default=[ + item + for item in self.config_entry.options.get( + CONF_INTERFACES, [DEFAULT_INTERFACE] + ) + if item in self._interface_options + ], ): cv.multi_select(self._interface_options), vol.Optional( CONF_TRY_HOTSPOT, diff --git a/homeassistant/components/keenetic_ndms2/strings.json b/homeassistant/components/keenetic_ndms2/strings.json index 739846de0a8..93b59be122d 100644 --- a/homeassistant/components/keenetic_ndms2/strings.json +++ b/homeassistant/components/keenetic_ndms2/strings.json @@ -36,6 +36,10 @@ "include_associated": "Use Wi-Fi AP associations data (ignored if hotspot data used)" } } + }, + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "not_initialized": "The integration is not initialized yet. Can't display available options." } } } diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 36c4bc71273..baa830bfaa4 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -9,6 +9,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["xknx", "xknxproject"], + "quality_scale": "silver", "requirements": [ "xknx==3.8.0", "xknxproject==3.8.2", diff --git a/homeassistant/components/knx/quality_scale.yaml b/homeassistant/components/knx/quality_scale.yaml index 63aa4578159..b4b36213c43 100644 --- a/homeassistant/components/knx/quality_scale.yaml +++ b/homeassistant/components/knx/quality_scale.yaml @@ -13,7 +13,7 @@ rules: docs-actions: done docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: todo + docs-removal-instructions: done entity-event-setup: done entity-unique-id: done has-entity-name: @@ -41,8 +41,8 @@ rules: # Silver action-exceptions: done config-entry-unloading: done - docs-configuration-parameters: todo - docs-installation-parameters: todo + docs-configuration-parameters: done + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: @@ -64,21 +64,24 @@ rules: comment: | YAML entities don't support devices. UI entities support user-defined devices. diagnostics: done - discovery-update-info: todo + discovery-update-info: + status: exempt + comment: | + KNX doesn't support any provided discovery method. discovery: status: exempt comment: | KNX doesn't support any provided discovery method. - docs-data-update: todo + docs-data-update: done docs-examples: done - docs-known-limitations: todo + docs-known-limitations: done docs-supported-devices: status: exempt comment: | Devices aren't supported directly since communication is on group address level. docs-supported-functions: done docs-troubleshooting: done - docs-use-cases: todo + docs-use-cases: done dynamic-devices: status: exempt comment: | diff --git a/homeassistant/components/lametric/const.py b/homeassistant/components/lametric/const.py index 4f9472b24f4..8c05b15ad1f 100644 --- a/homeassistant/components/lametric/const.py +++ b/homeassistant/components/lametric/const.py @@ -13,6 +13,7 @@ PLATFORMS = [ Platform.SELECT, Platform.SENSOR, Platform.SWITCH, + Platform.UPDATE, ] LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/lametric/entity.py b/homeassistant/components/lametric/entity.py index 4764974b5e0..f0c0d14e0e4 100644 --- a/homeassistant/components/lametric/entity.py +++ b/homeassistant/components/lametric/entity.py @@ -3,6 +3,7 @@ from __future__ import annotations from homeassistant.helpers.device_registry import ( + CONNECTION_BLUETOOTH, CONNECTION_NETWORK_MAC, DeviceInfo, format_mac, @@ -21,10 +22,13 @@ class LaMetricEntity(CoordinatorEntity[LaMetricDataUpdateCoordinator]): def __init__(self, coordinator: LaMetricDataUpdateCoordinator) -> None: """Initialize the LaMetric entity.""" super().__init__(coordinator=coordinator) + connections = {(CONNECTION_NETWORK_MAC, format_mac(coordinator.data.wifi.mac))} + if coordinator.data.bluetooth is not None: + connections.add( + (CONNECTION_BLUETOOTH, format_mac(coordinator.data.bluetooth.address)) + ) self._attr_device_info = DeviceInfo( - connections={ - (CONNECTION_NETWORK_MAC, format_mac(coordinator.data.wifi.mac)) - }, + connections=connections, identifiers={(DOMAIN, coordinator.data.serial_number)}, manufacturer="LaMetric Inc.", model_id=coordinator.data.model, diff --git a/homeassistant/components/lametric/update.py b/homeassistant/components/lametric/update.py new file mode 100644 index 00000000000..d486d9d27ba --- /dev/null +++ b/homeassistant/components/lametric/update.py @@ -0,0 +1,46 @@ +"""LaMetric Update platform.""" + +from awesomeversion import AwesomeVersion + +from homeassistant.components.update import UpdateDeviceClass, UpdateEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import LaMetricConfigEntry, LaMetricDataUpdateCoordinator +from .entity import LaMetricEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: LaMetricConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up LaMetric update platform.""" + + coordinator = config_entry.runtime_data + + if coordinator.data.os_version >= AwesomeVersion("2.3.0"): + async_add_entities([LaMetricUpdate(coordinator)]) + + +class LaMetricUpdate(LaMetricEntity, UpdateEntity): + """Representation of LaMetric Update.""" + + _attr_device_class = UpdateDeviceClass.FIRMWARE + + def __init__(self, coordinator: LaMetricDataUpdateCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.data.serial_number}-update" + + @property + def installed_version(self) -> str: + """Return the installed version of the entity.""" + return self.coordinator.data.os_version + + @property + def latest_version(self) -> str | None: + """Return the latest version of the entity.""" + if not self.coordinator.data.update: + return None + return self.coordinator.data.update.version diff --git a/homeassistant/components/lcn/binary_sensor.py b/homeassistant/components/lcn/binary_sensor.py index d8418c6d838..b124b3f6188 100644 --- a/homeassistant/components/lcn/binary_sensor.py +++ b/homeassistant/components/lcn/binary_sensor.py @@ -25,6 +25,8 @@ from .const import BINSENSOR_PORTS, CONF_DOMAIN_DATA, DOMAIN, SETPOINTS from .entity import LcnEntity from .helpers import InputType, LcnConfigEntry +PARALLEL_UPDATES = 0 + def add_lcn_entities( config_entry: LcnConfigEntry, diff --git a/homeassistant/components/lcn/sensor.py b/homeassistant/components/lcn/sensor.py index fd90c024383..da475e50005 100644 --- a/homeassistant/components/lcn/sensor.py +++ b/homeassistant/components/lcn/sensor.py @@ -38,6 +38,8 @@ from .const import ( from .entity import LcnEntity from .helpers import InputType, LcnConfigEntry +PARALLEL_UPDATES = 0 + DEVICE_CLASS_MAPPING = { pypck.lcn_defs.VarUnit.CELSIUS: SensorDeviceClass.TEMPERATURE, pypck.lcn_defs.VarUnit.KELVIN: SensorDeviceClass.TEMPERATURE, diff --git a/homeassistant/components/lifx/__init__.py b/homeassistant/components/lifx/__init__.py index 7a6d95549ff..99a8adb0182 100644 --- a/homeassistant/components/lifx/__init__.py +++ b/homeassistant/components/lifx/__init__.py @@ -13,7 +13,6 @@ from aiolifx.connection import LIFXConnection import voluptuous as vol from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_PORT, @@ -27,7 +26,7 @@ from homeassistant.helpers.event import async_call_later, async_track_time_inter from homeassistant.helpers.typing import ConfigType from .const import _LOGGER, DATA_LIFX_MANAGER, DOMAIN, TARGET_ANY -from .coordinator import LIFXUpdateCoordinator +from .coordinator import LIFXConfigEntry, LIFXUpdateCoordinator from .discovery import async_discover_devices, async_trigger_discovery from .manager import LIFXManager from .migration import async_migrate_entities_devices, async_migrate_legacy_entries @@ -73,7 +72,7 @@ DISCOVERY_COOLDOWN = 5 async def async_legacy_migration( hass: HomeAssistant, - legacy_entry: ConfigEntry, + legacy_entry: LIFXConfigEntry, discovered_devices: Iterable[Light], ) -> bool: """Migrate config entries.""" @@ -157,7 +156,6 @@ class LIFXDiscoveryManager: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the LIFX component.""" - hass.data[DOMAIN] = {} migrating = bool(async_get_legacy_entry(hass)) discovery_manager = LIFXDiscoveryManager(hass, migrating) @@ -187,7 +185,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: LIFXConfigEntry) -> bool: """Set up LIFX from a config entry.""" if async_entry_is_legacy(entry): return True @@ -198,10 +196,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async_migrate_entities_devices(hass, legacy_entry.entry_id, entry) assert entry.unique_id is not None - domain_data = hass.data[DOMAIN] - if DATA_LIFX_MANAGER not in domain_data: + if DATA_LIFX_MANAGER not in hass.data: manager = LIFXManager(hass) - domain_data[DATA_LIFX_MANAGER] = manager + hass.data[DATA_LIFX_MANAGER] = manager manager.async_setup() host = entry.data[CONF_HOST] @@ -229,21 +226,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady( f"Unexpected device found at {host}; expected {entry.unique_id}, found {serial}" ) - domain_data[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: LIFXConfigEntry) -> bool: """Unload a config entry.""" if async_entry_is_legacy(entry): return True - domain_data = hass.data[DOMAIN] if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - coordinator: LIFXUpdateCoordinator = domain_data.pop(entry.entry_id) - coordinator.connection.async_stop() + entry.runtime_data.connection.async_stop() # Only the DATA_LIFX_MANAGER left, remove it. - if len(domain_data) == 1: - manager: LIFXManager = domain_data.pop(DATA_LIFX_MANAGER) + if len(hass.config_entries.async_loaded_entries(DOMAIN)) == 0: + manager = hass.data.pop(DATA_LIFX_MANAGER) manager.async_unload() return unload_ok diff --git a/homeassistant/components/lifx/binary_sensor.py b/homeassistant/components/lifx/binary_sensor.py index f5a974b4626..478a4d306e2 100644 --- a/homeassistant/components/lifx/binary_sensor.py +++ b/homeassistant/components/lifx/binary_sensor.py @@ -7,13 +7,12 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, HEV_CYCLE_STATE -from .coordinator import LIFXUpdateCoordinator +from .const import HEV_CYCLE_STATE +from .coordinator import LIFXConfigEntry, LIFXUpdateCoordinator from .entity import LIFXEntity from .util import lifx_features @@ -27,11 +26,11 @@ HEV_CYCLE_STATE_SENSOR = BinarySensorEntityDescription( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LIFXConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LIFX from a config entry.""" - coordinator: LIFXUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data if lifx_features(coordinator.device)["hev"]: async_add_entities( diff --git a/homeassistant/components/lifx/button.py b/homeassistant/components/lifx/button.py index 25ab61aebae..758d7ab6435 100644 --- a/homeassistant/components/lifx/button.py +++ b/homeassistant/components/lifx/button.py @@ -7,13 +7,12 @@ 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, IDENTIFY, RESTART -from .coordinator import LIFXUpdateCoordinator +from .const import IDENTIFY, RESTART +from .coordinator import LIFXConfigEntry, LIFXUpdateCoordinator from .entity import LIFXEntity RESTART_BUTTON_DESCRIPTION = ButtonEntityDescription( @@ -31,12 +30,11 @@ IDENTIFY_BUTTON_DESCRIPTION = ButtonEntityDescription( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LIFXConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LIFX from a config entry.""" - domain_data = hass.data[DOMAIN] - coordinator: LIFXUpdateCoordinator = domain_data[entry.entry_id] + coordinator = entry.runtime_data async_add_entities( [LIFXRestartButton(coordinator), LIFXIdentifyButton(coordinator)] ) diff --git a/homeassistant/components/lifx/const.py b/homeassistant/components/lifx/const.py index 58c3550b812..ecc572aa006 100644 --- a/homeassistant/components/lifx/const.py +++ b/homeassistant/components/lifx/const.py @@ -1,8 +1,17 @@ """Const for LIFX.""" +from __future__ import annotations + import logging +from typing import TYPE_CHECKING + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from .manager import LIFXManager DOMAIN = "lifx" +DATA_LIFX_MANAGER: HassKey[LIFXManager] = HassKey(DOMAIN) TARGET_ANY = "00:00:00:00:00:00" @@ -59,7 +68,6 @@ INFRARED_BRIGHTNESS_VALUES_MAP = { 32767: "50%", 65535: "100%", } -DATA_LIFX_MANAGER = "lifx_manager" LIFX_CEILING_PRODUCT_IDS = {176, 177, 201, 202} diff --git a/homeassistant/components/lifx/coordinator.py b/homeassistant/components/lifx/coordinator.py index b77dbdc015a..79ce843b339 100644 --- a/homeassistant/components/lifx/coordinator.py +++ b/homeassistant/components/lifx/coordinator.py @@ -65,6 +65,8 @@ ZONES_PER_COLOR_UPDATE_REQUEST = 8 RSSI_DBM_FW = AwesomeVersion("2.77") +type LIFXConfigEntry = ConfigEntry[LIFXUpdateCoordinator] + class FirmwareEffect(IntEnum): """Enumeration of LIFX firmware effects.""" @@ -87,12 +89,12 @@ class SkyType(IntEnum): class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): """DataUpdateCoordinator to gather data for a specific lifx device.""" - config_entry: ConfigEntry + config_entry: LIFXConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LIFXConfigEntry, connection: LIFXConnection, ) -> None: """Initialize DataUpdateCoordinator.""" diff --git a/homeassistant/components/lifx/diagnostics.py b/homeassistant/components/lifx/diagnostics.py index b9ef1af4dc6..64e7390b210 100644 --- a/homeassistant/components/lifx/diagnostics.py +++ b/homeassistant/components/lifx/diagnostics.py @@ -5,21 +5,20 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_IP_ADDRESS, CONF_MAC from homeassistant.core import HomeAssistant -from .const import CONF_LABEL, DOMAIN -from .coordinator import LIFXUpdateCoordinator +from .const import CONF_LABEL +from .coordinator import LIFXConfigEntry TO_REDACT = [CONF_LABEL, CONF_HOST, CONF_IP_ADDRESS, CONF_MAC] async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: LIFXConfigEntry ) -> dict[str, Any]: """Return diagnostics for a LIFX config entry.""" - coordinator: LIFXUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data return { "entry": { "title": entry.title, diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index 5641786eb61..3d30fcd369e 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -17,7 +17,6 @@ from homeassistant.components.light import ( LightEntity, LightEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -37,7 +36,7 @@ from .const import ( INFRARED_BRIGHTNESS, LIFX_CEILING_PRODUCT_IDS, ) -from .coordinator import FirmwareEffect, LIFXUpdateCoordinator +from .coordinator import FirmwareEffect, LIFXConfigEntry, LIFXUpdateCoordinator from .entity import LIFXEntity from .manager import ( SERVICE_EFFECT_COLORLOOP, @@ -78,13 +77,12 @@ HSBK_KELVIN = 3 async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LIFXConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LIFX from a config entry.""" - domain_data = hass.data[DOMAIN] - coordinator: LIFXUpdateCoordinator = domain_data[entry.entry_id] - manager: LIFXManager = domain_data[DATA_LIFX_MANAGER] + coordinator = entry.runtime_data + manager = hass.data[DATA_LIFX_MANAGER] device = coordinator.device platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( @@ -123,7 +121,7 @@ class LIFXLight(LIFXEntity, LightEntity): self, coordinator: LIFXUpdateCoordinator, manager: LIFXManager, - entry: ConfigEntry, + entry: LIFXConfigEntry, ) -> None: """Initialize the light.""" super().__init__(coordinator) diff --git a/homeassistant/components/lifx/manager.py b/homeassistant/components/lifx/manager.py index 9fae2628f1d..33712441157 100644 --- a/homeassistant/components/lifx/manager.py +++ b/homeassistant/components/lifx/manager.py @@ -30,8 +30,8 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import async_extract_referenced_entity_ids -from .const import _ATTR_COLOR_TEMP, ATTR_THEME, DATA_LIFX_MANAGER, DOMAIN -from .coordinator import LIFXUpdateCoordinator +from .const import _ATTR_COLOR_TEMP, ATTR_THEME, DOMAIN +from .coordinator import LIFXConfigEntry, LIFXUpdateCoordinator from .util import convert_8_to_16, find_hsbk if TYPE_CHECKING: @@ -494,13 +494,11 @@ class LIFXManager: coordinators: list[LIFXUpdateCoordinator] = [] bulbs: list[Light] = [] - for entry_id, coordinator in self.hass.data[DOMAIN].items(): - if ( - entry_id != DATA_LIFX_MANAGER - and self.entry_id_to_entity_id[entry_id] in entity_ids - ): - coordinators.append(coordinator) - bulbs.append(coordinator.device) + entry: LIFXConfigEntry + for entry in self.hass.config_entries.async_loaded_entries(DOMAIN): + if self.entry_id_to_entity_id[entry.entry_id] in entity_ids: + coordinators.append(entry.runtime_data) + bulbs.append(entry.runtime_data.device) if start_effect_func := self._effect_dispatch.get(service): await start_effect_func(self, bulbs, coordinators, **kwargs) diff --git a/homeassistant/components/lifx/migration.py b/homeassistant/components/lifx/migration.py index 9f8365cbceb..1e8855e40db 100644 --- a/homeassistant/components/lifx/migration.py +++ b/homeassistant/components/lifx/migration.py @@ -2,11 +2,11 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from .const import _LOGGER, DOMAIN +from .coordinator import LIFXConfigEntry from .discovery import async_init_discovery_flow @@ -15,7 +15,7 @@ def async_migrate_legacy_entries( hass: HomeAssistant, discovered_hosts_by_serial: dict[str, str], existing_serials: set[str], - legacy_entry: ConfigEntry, + legacy_entry: LIFXConfigEntry, ) -> int: """Migrate the legacy config entries to have an entry per device.""" _LOGGER.debug( @@ -45,7 +45,7 @@ def async_migrate_legacy_entries( @callback def async_migrate_entities_devices( - hass: HomeAssistant, legacy_entry_id: str, new_entry: ConfigEntry + hass: HomeAssistant, legacy_entry_id: str, new_entry: LIFXConfigEntry ) -> None: """Move entities and devices to the new config entry.""" migrated_devices = [] diff --git a/homeassistant/components/lifx/select.py b/homeassistant/components/lifx/select.py index 13b81e2a784..0913d7a1662 100644 --- a/homeassistant/components/lifx/select.py +++ b/homeassistant/components/lifx/select.py @@ -5,18 +5,12 @@ from __future__ import annotations from aiolifx_themes.themes import ThemeLibrary from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ( - ATTR_THEME, - DOMAIN, - INFRARED_BRIGHTNESS, - INFRARED_BRIGHTNESS_VALUES_MAP, -) -from .coordinator import LIFXUpdateCoordinator +from .const import ATTR_THEME, INFRARED_BRIGHTNESS, INFRARED_BRIGHTNESS_VALUES_MAP +from .coordinator import LIFXConfigEntry, LIFXUpdateCoordinator from .entity import LIFXEntity from .util import lifx_features @@ -39,11 +33,11 @@ THEME_ENTITY = SelectEntityDescription( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LIFXConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LIFX from a config entry.""" - coordinator: LIFXUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data entities: list[LIFXEntity] = [] diff --git a/homeassistant/components/lifx/sensor.py b/homeassistant/components/lifx/sensor.py index 96feba633f4..8a9877dc468 100644 --- a/homeassistant/components/lifx/sensor.py +++ b/homeassistant/components/lifx/sensor.py @@ -10,13 +10,12 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ATTR_RSSI, DOMAIN -from .coordinator import LIFXUpdateCoordinator +from .const import ATTR_RSSI +from .coordinator import LIFXConfigEntry, LIFXUpdateCoordinator from .entity import LIFXEntity SCAN_INTERVAL = timedelta(seconds=30) @@ -33,11 +32,11 @@ RSSI_SENSOR = SensorEntityDescription( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LIFXConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LIFX sensor from config entry.""" - coordinator: LIFXUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities([LIFXRssiSensor(coordinator, RSSI_SENSOR)]) diff --git a/homeassistant/components/lifx/util.py b/homeassistant/components/lifx/util.py index 8286622e6f3..c99880891d2 100644 --- a/homeassistant/components/lifx/util.py +++ b/homeassistant/components/lifx/util.py @@ -5,7 +5,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable from functools import partial -from typing import Any +from typing import TYPE_CHECKING, Any from aiolifx import products from aiolifx.aiolifx import Light @@ -21,7 +21,6 @@ from homeassistant.components.light import ( ATTR_RGB_COLOR, ATTR_XY_COLOR, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.util import color as color_util @@ -35,17 +34,20 @@ from .const import ( OVERALL_TIMEOUT, ) +if TYPE_CHECKING: + from .coordinator import LIFXConfigEntry + FIX_MAC_FW = AwesomeVersion("3.70") @callback -def async_entry_is_legacy(entry: ConfigEntry) -> bool: +def async_entry_is_legacy(entry: LIFXConfigEntry) -> bool: """Check if a config entry is the legacy shared one.""" return entry.unique_id is None or entry.unique_id == DOMAIN @callback -def async_get_legacy_entry(hass: HomeAssistant) -> ConfigEntry | None: +def async_get_legacy_entry(hass: HomeAssistant) -> LIFXConfigEntry | None: """Get the legacy config entry.""" for entry in hass.config_entries.async_entries(DOMAIN): if async_entry_is_legacy(entry): diff --git a/homeassistant/components/linear_garage_door/__init__.py b/homeassistant/components/linear_garage_door/__init__.py index c2a6c6a7ed1..a80aa99628b 100644 --- a/homeassistant/components/linear_garage_door/__init__.py +++ b/homeassistant/components/linear_garage_door/__init__.py @@ -2,18 +2,17 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir from .const import DOMAIN -from .coordinator import LinearUpdateCoordinator +from .coordinator import LinearConfigEntry, LinearUpdateCoordinator PLATFORMS: list[Platform] = [Platform.COVER, Platform.LIGHT] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: LinearConfigEntry) -> bool: """Set up Linear Garage Door from a config entry.""" ir.async_create_issue( @@ -35,21 +34,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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: LinearConfigEntry) -> 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) -async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_remove_entry(hass: HomeAssistant, entry: LinearConfigEntry) -> None: """Remove a config entry.""" if not hass.config_entries.async_loaded_entries(DOMAIN): ir.async_delete_issue(hass, DOMAIN, DOMAIN) diff --git a/homeassistant/components/linear_garage_door/coordinator.py b/homeassistant/components/linear_garage_door/coordinator.py index b55affe92e7..3844e1ae7de 100644 --- a/homeassistant/components/linear_garage_door/coordinator.py +++ b/homeassistant/components/linear_garage_door/coordinator.py @@ -19,6 +19,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator _LOGGER = logging.getLogger(__name__) +type LinearConfigEntry = ConfigEntry[LinearUpdateCoordinator] + @dataclass class LinearDevice: @@ -32,9 +34,9 @@ class LinearUpdateCoordinator(DataUpdateCoordinator[dict[str, LinearDevice]]): """DataUpdateCoordinator for Linear.""" _devices: list[dict[str, Any]] | None = None - config_entry: ConfigEntry + config_entry: LinearConfigEntry - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, config_entry: LinearConfigEntry) -> None: """Initialize DataUpdateCoordinator for Linear.""" super().__init__( hass, diff --git a/homeassistant/components/linear_garage_door/cover.py b/homeassistant/components/linear_garage_door/cover.py index 7b0510f00d1..1f6c0999531 100644 --- a/homeassistant/components/linear_garage_door/cover.py +++ b/homeassistant/components/linear_garage_door/cover.py @@ -8,12 +8,10 @@ from homeassistant.components.cover import ( CoverEntity, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import LinearUpdateCoordinator +from .coordinator import LinearConfigEntry from .entity import LinearEntity SUPPORTED_SUBDEVICES = ["GDO"] @@ -23,11 +21,11 @@ SCAN_INTERVAL = timedelta(seconds=10) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LinearConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Linear Garage Door cover.""" - coordinator: LinearUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( LinearCoverEntity(coordinator, device_id, device_data.name, sub_device_id) diff --git a/homeassistant/components/linear_garage_door/diagnostics.py b/homeassistant/components/linear_garage_door/diagnostics.py index 21414f02f87..ff5ca5639bf 100644 --- a/homeassistant/components/linear_garage_door/diagnostics.py +++ b/homeassistant/components/linear_garage_door/diagnostics.py @@ -6,21 +6,19 @@ from dataclasses import asdict from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import LinearUpdateCoordinator +from .coordinator import LinearConfigEntry TO_REDACT = {CONF_PASSWORD, CONF_EMAIL} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: LinearConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: LinearUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data return { "entry": async_redact_data(entry.as_dict(), TO_REDACT), diff --git a/homeassistant/components/linear_garage_door/light.py b/homeassistant/components/linear_garage_door/light.py index ac03894d446..59243817fbb 100644 --- a/homeassistant/components/linear_garage_door/light.py +++ b/homeassistant/components/linear_garage_door/light.py @@ -5,12 +5,10 @@ from typing import Any from linear_garage_door import Linear from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import LinearUpdateCoordinator +from .coordinator import LinearConfigEntry from .entity import LinearEntity SUPPORTED_SUBDEVICES = ["Light"] @@ -18,11 +16,11 @@ SUPPORTED_SUBDEVICES = ["Light"] async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LinearConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Linear Garage Door cover.""" - coordinator: LinearUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data data = coordinator.data async_add_entities( diff --git a/homeassistant/components/livisi/__init__.py b/homeassistant/components/livisi/__init__.py index fc9e381a1c3..befbe6858ef 100644 --- a/homeassistant/components/livisi/__init__.py +++ b/homeassistant/components/livisi/__init__.py @@ -8,19 +8,18 @@ from aiohttp import ClientConnectorError from livisi.aiolivisi import AioLivisi from homeassistant import core -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, device_registry as dr from .const import DOMAIN -from .coordinator import LivisiDataUpdateCoordinator +from .coordinator import LivisiConfigEntry, LivisiDataUpdateCoordinator PLATFORMS: Final = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.SWITCH] -async def async_setup_entry(hass: core.HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: core.HomeAssistant, entry: LivisiConfigEntry) -> bool: """Set up Livisi Smart Home from a config entry.""" web_session = aiohttp_client.async_get_clientsession(hass) aiolivisi = AioLivisi(web_session) @@ -31,7 +30,7 @@ async def async_setup_entry(hass: core.HomeAssistant, entry: ConfigEntry) -> boo except ClientConnectorError as exception: raise ConfigEntryNotReady from exception - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, @@ -45,16 +44,10 @@ async def async_setup_entry(hass: core.HomeAssistant, entry: ConfigEntry) -> boo entry.async_create_background_task( hass, coordinator.ws_connect(), "livisi-ws_connect" ) + entry.async_on_unload(coordinator.websocket.disconnect) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: LivisiConfigEntry) -> bool: """Unload a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - - unload_success = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - await coordinator.websocket.disconnect() - if unload_success: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_success + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/livisi/binary_sensor.py b/homeassistant/components/livisi/binary_sensor.py index 50eb4cd28b9..ea61e7741b8 100644 --- a/homeassistant/components/livisi/binary_sensor.py +++ b/homeassistant/components/livisi/binary_sensor.py @@ -8,23 +8,22 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, LIVISI_STATE_CHANGE, LOGGER, WDS_DEVICE_TYPE -from .coordinator import LivisiDataUpdateCoordinator +from .const import LIVISI_STATE_CHANGE, LOGGER, WDS_DEVICE_TYPE +from .coordinator import LivisiConfigEntry, LivisiDataUpdateCoordinator from .entity import LivisiEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LivisiConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary_sensor device.""" - coordinator: LivisiDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data known_devices = set() @callback @@ -53,7 +52,7 @@ class LivisiBinarySensor(LivisiEntity, BinarySensorEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: LivisiConfigEntry, coordinator: LivisiDataUpdateCoordinator, device: dict[str, Any], capability_name: str, @@ -86,7 +85,7 @@ class LivisiWindowDoorSensor(LivisiBinarySensor): def __init__( self, - config_entry: ConfigEntry, + config_entry: LivisiConfigEntry, coordinator: LivisiDataUpdateCoordinator, device: dict[str, Any], ) -> None: diff --git a/homeassistant/components/livisi/climate.py b/homeassistant/components/livisi/climate.py index 1f5e3360c7d..05539043d74 100644 --- a/homeassistant/components/livisi/climate.py +++ b/homeassistant/components/livisi/climate.py @@ -11,7 +11,6 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -19,24 +18,23 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( - DOMAIN, LIVISI_STATE_CHANGE, LOGGER, MAX_TEMPERATURE, MIN_TEMPERATURE, VRCC_DEVICE_TYPE, ) -from .coordinator import LivisiDataUpdateCoordinator +from .coordinator import LivisiConfigEntry, LivisiDataUpdateCoordinator from .entity import LivisiEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LivisiConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up climate device.""" - coordinator: LivisiDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data @callback def handle_coordinator_update() -> None: @@ -71,7 +69,7 @@ class LivisiClimate(LivisiEntity, ClimateEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: LivisiConfigEntry, coordinator: LivisiDataUpdateCoordinator, device: dict[str, Any], ) -> None: diff --git a/homeassistant/components/livisi/coordinator.py b/homeassistant/components/livisi/coordinator.py index 6557416ed3a..8d490dca952 100644 --- a/homeassistant/components/livisi/coordinator.py +++ b/homeassistant/components/livisi/coordinator.py @@ -26,14 +26,16 @@ from .const import ( LOGGER, ) +type LivisiConfigEntry = ConfigEntry[LivisiDataUpdateCoordinator] + class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): """Class to manage fetching LIVISI data API.""" - config_entry: ConfigEntry + config_entry: LivisiConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, aiolivisi: AioLivisi + self, hass: HomeAssistant, config_entry: LivisiConfigEntry, aiolivisi: AioLivisi ) -> None: """Initialize my coordinator.""" super().__init__( diff --git a/homeassistant/components/livisi/entity.py b/homeassistant/components/livisi/entity.py index af588b0e360..79af35c1f8c 100644 --- a/homeassistant/components/livisi/entity.py +++ b/homeassistant/components/livisi/entity.py @@ -7,14 +7,13 @@ from typing import Any from livisi.const import CAPABILITY_MAP -from homeassistant.config_entries import ConfigEntry from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, LIVISI_REACHABILITY_CHANGE -from .coordinator import LivisiDataUpdateCoordinator +from .coordinator import LivisiConfigEntry, LivisiDataUpdateCoordinator class LivisiEntity(CoordinatorEntity[LivisiDataUpdateCoordinator]): @@ -24,7 +23,7 @@ class LivisiEntity(CoordinatorEntity[LivisiDataUpdateCoordinator]): def __init__( self, - config_entry: ConfigEntry, + config_entry: LivisiConfigEntry, coordinator: LivisiDataUpdateCoordinator, device: dict[str, Any], *, diff --git a/homeassistant/components/livisi/switch.py b/homeassistant/components/livisi/switch.py index 5599a4af0d4..e053923f551 100644 --- a/homeassistant/components/livisi/switch.py +++ b/homeassistant/components/livisi/switch.py @@ -5,24 +5,23 @@ from __future__ import annotations from typing import Any from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, LIVISI_STATE_CHANGE, LOGGER, SWITCH_DEVICE_TYPES -from .coordinator import LivisiDataUpdateCoordinator +from .const import LIVISI_STATE_CHANGE, LOGGER, SWITCH_DEVICE_TYPES +from .coordinator import LivisiConfigEntry, LivisiDataUpdateCoordinator from .entity import LivisiEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LivisiConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switch device.""" - coordinator: LivisiDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data @callback def handle_coordinator_update() -> None: @@ -52,7 +51,7 @@ class LivisiSwitch(LivisiEntity, SwitchEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: LivisiConfigEntry, coordinator: LivisiDataUpdateCoordinator, device: dict[str, Any], ) -> None: diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index 2d7e2888896..32f822414aa 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -93,6 +93,9 @@ "battery_time_to_full_charge": { "default": "mdi:battery-clock" }, + "esa_opt_out_state": { + "default": "mdi:home-lightning-bolt" + }, "evse_state": { "default": "mdi:ev-station" }, diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 0eefb536bcf..0b4d3cc3330 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -93,6 +93,13 @@ CHARGE_STATE_MAP = { clusters.PowerSource.Enums.BatChargeStateEnum.kUnknownEnumValue: None, } +DEM_OPT_OUT_STATE_MAP = { + clusters.DeviceEnergyManagement.Enums.OptOutStateEnum.kNoOptOut: "no_opt_out", + clusters.DeviceEnergyManagement.Enums.OptOutStateEnum.kLocalOptOut: "local_opt_out", + clusters.DeviceEnergyManagement.Enums.OptOutStateEnum.kGridOptOut: "grid_opt_out", + clusters.DeviceEnergyManagement.Enums.OptOutStateEnum.kOptOut: "opt_out", +} + ESA_STATE_MAP = { clusters.DeviceEnergyManagement.Enums.ESAStateEnum.kOffline: "offline", clusters.DeviceEnergyManagement.Enums.ESAStateEnum.kOnline: "online", @@ -1159,6 +1166,19 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterSensor, required_attributes=(clusters.DeviceEnergyManagement.Attributes.ESAState,), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ESAOptOutState", + translation_key="esa_opt_out_state", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=list(DEM_OPT_OUT_STATE_MAP.values()), + measurement_to_ha=DEM_OPT_OUT_STATE_MAP.get, + ), + entity_class=MatterSensor, + required_attributes=(clusters.DeviceEnergyManagement.Attributes.OptOutState,), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index c713dfba615..35a9daa2370 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -363,6 +363,15 @@ "paused": "[%key:common::state::paused%]" } }, + "esa_opt_out_state": { + "name": "Energy optimization opt-out", + "state": { + "no_opt_out": "[%key:common::state::off%]", + "local_opt_out": "Local", + "grid_opt_out": "Grid", + "opt_out": "Local and grid" + } + }, "evse_fault_state": { "name": "Fault state", "state": { diff --git a/homeassistant/components/media_player/intent.py b/homeassistant/components/media_player/intent.py index c9caa2c4a91..be365694579 100644 --- a/homeassistant/components/media_player/intent.py +++ b/homeassistant/components/media_player/intent.py @@ -27,7 +27,12 @@ from . import ( MediaPlayerDeviceClass, SearchMedia, ) -from .const import MediaPlayerEntityFeature, MediaPlayerState +from .const import ( + ATTR_MEDIA_FILTER_CLASSES, + MediaClass, + MediaPlayerEntityFeature, + MediaPlayerState, +) INTENT_MEDIA_PAUSE = "HassMediaPause" INTENT_MEDIA_UNPAUSE = "HassMediaUnpause" @@ -231,6 +236,7 @@ class MediaSearchAndPlayHandler(intent.IntentHandler): intent_type = INTENT_MEDIA_SEARCH_AND_PLAY slot_schema = { vol.Required("search_query"): cv.string, + vol.Optional("media_class"): vol.In([cls.value for cls in MediaClass]), # Optional name/area/floor slots handled by intent matcher vol.Optional("name"): cv.string, vol.Optional("area"): cv.string, @@ -285,14 +291,23 @@ class MediaSearchAndPlayHandler(intent.IntentHandler): target_entity = match_result.states[0] target_entity_id = target_entity.entity_id + # Get media class if provided + media_class_slot = slots.get("media_class", {}) + media_class_value = media_class_slot.get("value") + + # Build search service data + search_data = {"search_query": search_query} + + # Add media_filter_classes if media_class is provided + if media_class_value: + search_data[ATTR_MEDIA_FILTER_CLASSES] = [media_class_value] + # 1. Search Media try: search_response = await hass.services.async_call( DOMAIN, SERVICE_SEARCH_MEDIA, - { - "search_query": search_query, - }, + search_data, target={ "entity_id": target_entity_id, }, diff --git a/homeassistant/components/miele/const.py b/homeassistant/components/miele/const.py index bda276c6d8a..fd2f8631cd2 100644 --- a/homeassistant/components/miele/const.py +++ b/homeassistant/components/miele/const.py @@ -1294,3 +1294,30 @@ STATE_PROGRAM_ID: dict[int, dict[int, str]] = { MieleAppliance.ROBOT_VACUUM_CLEANER: ROBOT_VACUUM_CLEANER_PROGRAM_ID, MieleAppliance.COFFEE_SYSTEM: COFFEE_SYSTEM_PROGRAM_ID, } + + +class PlatePowerStep(MieleEnum): + """Plate power settings.""" + + plate_step_0 = 0 + plate_step_warming = 110, 220 + plate_step_1 = 1 + plate_step_2 = 2 + plate_step_3 = 3 + plate_step_4 = 4 + plate_step_5 = 5 + plate_step_6 = 6 + plate_step_7 = 7 + plate_step_8 = 8 + plate_step_9 = 9 + plate_step_10 = 10 + plate_step_11 = 11 + plate_step_12 = 12 + plate_step_13 = 13 + plate_step_14 = 4 + plate_step_15 = 15 + plate_step_16 = 16 + plate_step_17 = 17 + plate_step_18 = 18 + plate_step_boost = 117, 118, 218 + missing2none = -9999 diff --git a/homeassistant/components/miele/icons.json b/homeassistant/components/miele/icons.json index 1806fe688d6..44b51a67c24 100644 --- a/homeassistant/components/miele/icons.json +++ b/homeassistant/components/miele/icons.json @@ -56,30 +56,27 @@ "plate": { "default": "mdi:circle-outline", "state": { - "0": "mdi:circle-outline", - "110": "mdi:alpha-w-circle-outline", - "220": "mdi:alpha-w-circle-outline", - "1": "mdi:circle-slice-1", - "2": "mdi:circle-slice-1", - "3": "mdi:circle-slice-2", - "4": "mdi:circle-slice-2", - "5": "mdi:circle-slice-3", - "6": "mdi:circle-slice-3", - "7": "mdi:circle-slice-4", - "8": "mdi:circle-slice-4", - "9": "mdi:circle-slice-5", - "10": "mdi:circle-slice-5", - "11": "mdi:circle-slice-5", - "12": "mdi:circle-slice-6", - "13": "mdi:circle-slice-6", - "14": "mdi:circle-slice-6", - "15": "mdi:circle-slice-7", - "16": "mdi:circle-slice-7", - "17": "mdi:circle-slice-8", - "18": "mdi:circle-slice-8", - "117": "mdi:alpha-b-circle-outline", - "118": "mdi:alpha-b-circle-outline", - "217": "mdi:alpha-b-circle-outline" + "plate_step_0": "mdi:circle-outline", + "plate_step_warming": "mdi:alpha-w-circle-outline", + "plate_step_1": "mdi:circle-slice-1", + "plate_step_2": "mdi:circle-slice-1", + "plate_step_3": "mdi:circle-slice-2", + "plate_step_4": "mdi:circle-slice-2", + "plate_step_5": "mdi:circle-slice-3", + "plate_step_6": "mdi:circle-slice-3", + "plate_step_7": "mdi:circle-slice-4", + "plate_step_8": "mdi:circle-slice-4", + "plate_step_9": "mdi:circle-slice-5", + "plate_step_10": "mdi:circle-slice-5", + "plate_step_11": "mdi:circle-slice-5", + "plate_step_12": "mdi:circle-slice-6", + "plate_step_13": "mdi:circle-slice-6", + "plate_step_14": "mdi:circle-slice-6", + "plate_step_15": "mdi:circle-slice-7", + "plate_step_16": "mdi:circle-slice-7", + "plate_step_17": "mdi:circle-slice-8", + "plate_step_18": "mdi:circle-slice-8", + "plate_step_boost": "mdi:alpha-b-circle-outline" } }, "program_type": { diff --git a/homeassistant/components/miele/sensor.py b/homeassistant/components/miele/sensor.py index d5085ae606f..ff72b791735 100644 --- a/homeassistant/components/miele/sensor.py +++ b/homeassistant/components/miele/sensor.py @@ -33,6 +33,7 @@ from .const import ( STATE_PROGRAM_PHASE, STATE_STATUS_TAGS, MieleAppliance, + PlatePowerStep, StateDryingStep, StateProgramType, StateStatus, @@ -46,34 +47,6 @@ _LOGGER = logging.getLogger(__name__) DISABLED_TEMPERATURE = -32768 -PLATE_POWERS = [ - "0", - "110", - "220", - "1", - "2", - "3", - "4", - "5", - "6", - "7", - "8", - "9", - "10", - "11", - "12", - "13", - "14", - "15", - "16", - "17", - "18", - "117", - "118", - "217", -] - - DEFAULT_PLATE_COUNT = 4 PLATE_COUNT = { @@ -543,8 +516,8 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( translation_placeholders={"plate_no": str(i)}, zone=i, device_class=SensorDeviceClass.ENUM, - options=PLATE_POWERS, - value_fn=lambda value: value.state_plate_step[0].value_raw, + options=sorted(PlatePowerStep.keys()), + value_fn=lambda value: None, ), ) for i in range(1, 7) @@ -683,12 +656,19 @@ class MielePlateSensor(MieleSensor): def native_value(self) -> StateType: """Return the state of the plate sensor.""" # state_plate_step is [] if all zones are off - plate_power = ( - self.device.state_plate_step[self.entity_description.zone - 1].value_raw + + return ( + PlatePowerStep( + cast( + int, + self.device.state_plate_step[ + self.entity_description.zone - 1 + ].value_raw, + ) + ).name if self.device.state_plate_step - else 0 + else PlatePowerStep.plate_step_0 ) - return str(plate_power) class MieleStatusSensor(MieleSensor): diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index 94aef8d6d3f..97035da6d5f 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -203,30 +203,27 @@ "plate": { "name": "Plate {plate_no}", "state": { - "0": "0", - "110": "Warming", - "220": "[%key:component::miele::entity::sensor::plate::state::110%]", - "1": "1", - "2": "1\u2022", - "3": "2", - "4": "2\u2022", - "5": "3", - "6": "3\u2022", - "7": "4", - "8": "4\u2022", - "9": "5", - "10": "5\u2022", - "11": "6", - "12": "6\u2022", - "13": "7", - "14": "7\u2022", - "15": "8", - "16": "8\u2022", - "17": "9", - "18": "9\u2022", - "117": "Boost", - "118": "[%key:component::miele::entity::sensor::plate::state::117%]", - "217": "[%key:component::miele::entity::sensor::plate::state::117%]" + "power_step_0": "0", + "power_step_warm": "Warming", + "power_step_1": "1", + "power_step_2": "1\u2022", + "power_step_3": "2", + "power_step_4": "2\u2022", + "power_step_5": "3", + "power_step_6": "3\u2022", + "power_step_7": "4", + "power_step_8": "4\u2022", + "power_step_9": "5", + "power_step_10": "5\u2022", + "power_step_11": "6", + "power_step_12": "6\u2022", + "power_step_13": "7", + "power_step_14": "7\u2022", + "power_step_15": "8", + "power_step_16": "8\u2022", + "power_step_17": "9", + "power_step_18": "9\u2022", + "power_step_boost": "Boost" } }, "drying_step": { diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py index a2d2dae9e3f..32024c5ad13 100644 --- a/homeassistant/components/music_assistant/__init__.py +++ b/homeassistant/components/music_assistant/__init__.py @@ -3,7 +3,8 @@ from __future__ import annotations import asyncio -from dataclasses import dataclass +from collections.abc import Callable +from dataclasses import dataclass, field from typing import TYPE_CHECKING from music_assistant_client import MusicAssistantClient @@ -31,7 +32,7 @@ if TYPE_CHECKING: from homeassistant.helpers.typing import ConfigType -PLATFORMS = [Platform.MEDIA_PLAYER] +PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER] CONNECT_TIMEOUT = 10 LISTEN_READY_TIMEOUT = 30 @@ -39,6 +40,7 @@ LISTEN_READY_TIMEOUT = 30 CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) type MusicAssistantConfigEntry = ConfigEntry[MusicAssistantEntryData] +type PlayerAddCallback = Callable[[str], None] @dataclass @@ -47,6 +49,8 @@ class MusicAssistantEntryData: mass: MusicAssistantClient listen_task: asyncio.Task + discovered_players: set[str] = field(default_factory=set) + platform_handlers: dict[Platform, PlayerAddCallback] = field(default_factory=dict) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -122,6 +126,33 @@ async def async_setup_entry( # initialize platforms await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # register listener for new players + async def handle_player_added(event: MassEvent) -> None: + """Handle Mass Player Added event.""" + if TYPE_CHECKING: + assert event.object_id is not None + if event.object_id in entry.runtime_data.discovered_players: + return + player = mass.players.get(event.object_id) + if TYPE_CHECKING: + assert player is not None + if not player.expose_to_ha: + return + entry.runtime_data.discovered_players.add(event.object_id) + # run callback for each platform + for callback in entry.runtime_data.platform_handlers.values(): + callback(event.object_id) + + entry.async_on_unload(mass.subscribe(handle_player_added, EventType.PLAYER_ADDED)) + + # add all current players + for player in mass.players: + if not player.expose_to_ha: + continue + entry.runtime_data.discovered_players.add(player.player_id) + for callback in entry.runtime_data.platform_handlers.values(): + callback(player.player_id) + # register listener for removed players async def handle_player_removed(event: MassEvent) -> None: """Handle Mass Player Removed event.""" diff --git a/homeassistant/components/music_assistant/button.py b/homeassistant/components/music_assistant/button.py new file mode 100644 index 00000000000..7969954e443 --- /dev/null +++ b/homeassistant/components/music_assistant/button.py @@ -0,0 +1,53 @@ +"""Music Assistant Button platform.""" + +from __future__ import annotations + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import MusicAssistantConfigEntry +from .entity import MusicAssistantEntity +from .helpers import catch_musicassistant_error + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MusicAssistantConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Music Assistant MediaPlayer(s) from Config Entry.""" + mass = entry.runtime_data.mass + + def add_player(player_id: str) -> None: + """Handle add player.""" + async_add_entities( + [ + # Add button entity to favorite the currently playing item on the player + MusicAssistantFavoriteButton(mass, player_id) + ] + ) + + # register callback to add players when they are discovered + entry.runtime_data.platform_handlers.setdefault(Platform.BUTTON, add_player) + + +class MusicAssistantFavoriteButton(MusicAssistantEntity, ButtonEntity): + """Representation of a Button entity to favorite the currently playing item on a player.""" + + entity_description = ButtonEntityDescription( + key="favorite_now_playing", + translation_key="favorite_now_playing", + ) + + @property + def available(self) -> bool: + """Return availability of entity.""" + # mark the button as unavailable if the player has no current media item + return super().available and self.player.current_media is not None + + @catch_musicassistant_error + async def async_press(self) -> None: + """Handle the button press command.""" + await self.mass.players.add_currently_playing_to_favorites(self.player_id) diff --git a/homeassistant/components/music_assistant/helpers.py b/homeassistant/components/music_assistant/helpers.py new file mode 100644 index 00000000000..b228e99f76f --- /dev/null +++ b/homeassistant/components/music_assistant/helpers.py @@ -0,0 +1,28 @@ +"""Helpers for the Music Assistant integration.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +import functools +from typing import Any + +from music_assistant_models.errors import MusicAssistantError + +from homeassistant.exceptions import HomeAssistantError + + +def catch_musicassistant_error[**_P, _R]( + func: Callable[_P, Coroutine[Any, Any, _R]], +) -> Callable[_P, Coroutine[Any, Any, _R]]: + """Check and convert commands to players.""" + + @functools.wraps(func) + async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R: + """Catch Music Assistant errors and convert to Home Assistant error.""" + try: + return await func(*args, **kwargs) + except MusicAssistantError as err: + error_msg = str(err) or err.__class__.__name__ + raise HomeAssistantError(error_msg) from err + + return wrapper diff --git a/homeassistant/components/music_assistant/icons.json b/homeassistant/components/music_assistant/icons.json index 0fa64b8d273..24c6eb2a202 100644 --- a/homeassistant/components/music_assistant/icons.json +++ b/homeassistant/components/music_assistant/icons.json @@ -1,4 +1,11 @@ { + "entity": { + "button": { + "favorite_now_playing": { + "default": "mdi:heart-plus" + } + } + }, "services": { "play_media": { "service": "mdi:play" }, "play_announcement": { "service": "mdi:bullhorn" }, diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index a11e334824a..8d4e69bf082 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -3,11 +3,10 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Coroutine, Mapping +from collections.abc import Mapping from contextlib import suppress -import functools import os -from typing import TYPE_CHECKING, Any, Concatenate +from typing import TYPE_CHECKING, Any from music_assistant_models.constants import PLAYER_CONTROL_NONE from music_assistant_models.enums import ( @@ -18,7 +17,7 @@ from music_assistant_models.enums import ( QueueOption, RepeatMode as MassRepeatMode, ) -from music_assistant_models.errors import MediaNotFoundError, MusicAssistantError +from music_assistant_models.errors import MediaNotFoundError from music_assistant_models.event import MassEvent from music_assistant_models.media_items import ItemMapping, MediaItemType, Track from music_assistant_models.player_queue import PlayerQueue @@ -40,7 +39,7 @@ from homeassistant.components.media_player import ( SearchMediaQuery, async_process_play_media_url, ) -from homeassistant.const import ATTR_NAME, STATE_OFF +from homeassistant.const import ATTR_NAME, STATE_OFF, Platform from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, entity_registry as er @@ -76,6 +75,7 @@ from .const import ( DOMAIN, ) from .entity import MusicAssistantEntity +from .helpers import catch_musicassistant_error from .media_browser import async_browse_media, async_search_media from .schemas import QUEUE_DETAILS_SCHEMA, queue_item_dict_from_mass_item @@ -120,25 +120,6 @@ SERVICE_TRANSFER_QUEUE = "transfer_queue" SERVICE_GET_QUEUE = "get_queue" -def catch_musicassistant_error[_R, **P]( - func: Callable[Concatenate[MusicAssistantPlayer, P], Coroutine[Any, Any, _R]], -) -> Callable[Concatenate[MusicAssistantPlayer, P], Coroutine[Any, Any, _R]]: - """Check and log commands to players.""" - - @functools.wraps(func) - async def wrapper( - self: MusicAssistantPlayer, *args: P.args, **kwargs: P.kwargs - ) -> _R: - """Catch Music Assistant errors and convert to Home Assistant error.""" - try: - return await func(self, *args, **kwargs) - except MusicAssistantError as err: - error_msg = str(err) or err.__class__.__name__ - raise HomeAssistantError(error_msg) from err - - return wrapper - - async def async_setup_entry( hass: HomeAssistant, entry: MusicAssistantConfigEntry, @@ -146,33 +127,13 @@ async def async_setup_entry( ) -> None: """Set up Music Assistant MediaPlayer(s) from Config Entry.""" mass = entry.runtime_data.mass - added_ids = set() - async def handle_player_added(event: MassEvent) -> None: - """Handle Mass Player Added event.""" - if TYPE_CHECKING: - assert event.object_id is not None - if event.object_id in added_ids: - return - player = mass.players.get(event.object_id) - if TYPE_CHECKING: - assert player is not None - if not player.expose_to_ha: - return - added_ids.add(event.object_id) - async_add_entities([MusicAssistantPlayer(mass, event.object_id)]) + def add_player(player_id: str) -> None: + """Handle add player.""" + async_add_entities([MusicAssistantPlayer(mass, player_id)]) - # register listener for new players - entry.async_on_unload(mass.subscribe(handle_player_added, EventType.PLAYER_ADDED)) - mass_players = [] - # add all current players - for player in mass.players: - if not player.expose_to_ha: - continue - added_ids.add(player.player_id) - mass_players.append(MusicAssistantPlayer(mass, player.player_id)) - - async_add_entities(mass_players) + # register callback to add players when they are discovered + entry.runtime_data.platform_handlers.setdefault(Platform.MEDIA_PLAYER, add_player) # add platform service for play_media with advanced options platform = async_get_current_platform() diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json index c7e7baf88f6..c41bfa70d4c 100644 --- a/homeassistant/components/music_assistant/strings.json +++ b/homeassistant/components/music_assistant/strings.json @@ -31,6 +31,13 @@ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, + "entity": { + "button": { + "favorite_now_playing": { + "name": "Favorite current song" + } + } + }, "issues": { "invalid_server_version": { "title": "The Music Assistant server is not the correct version", diff --git a/homeassistant/components/ntfy/__init__.py b/homeassistant/components/ntfy/__init__.py index cd9c35ca4e6..72dbb4d2afb 100644 --- a/homeassistant/components/ntfy/__init__.py +++ b/homeassistant/components/ntfy/__init__.py @@ -12,19 +12,16 @@ from aiontfy.exceptions import ( NtfyUnauthorizedAuthenticationError, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_VERIFY_SSL, 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 NtfyConfigEntry, NtfyDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.NOTIFY] - - -type NtfyConfigEntry = ConfigEntry[Ntfy] +PLATFORMS: list[Platform] = [Platform.NOTIFY, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: NtfyConfigEntry) -> bool: @@ -59,7 +56,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: NtfyConfigEntry) -> bool translation_key="timeout_error", ) from e - entry.runtime_data = ntfy + coordinator = NtfyDataUpdateCoordinator(hass, entry, ntfy) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/ntfy/coordinator.py b/homeassistant/components/ntfy/coordinator.py new file mode 100644 index 00000000000..a52f1b06f41 --- /dev/null +++ b/homeassistant/components/ntfy/coordinator.py @@ -0,0 +1,74 @@ +"""DataUpdateCoordinator for ntfy integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from aiontfy import Account as NtfyAccount, Ntfy +from aiontfy.exceptions import ( + NtfyConnectionError, + NtfyHTTPError, + NtfyTimeoutError, + NtfyUnauthorizedAuthenticationError, +) + +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 .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +type NtfyConfigEntry = ConfigEntry[NtfyDataUpdateCoordinator] + + +class NtfyDataUpdateCoordinator(DataUpdateCoordinator[NtfyAccount]): + """Ntfy data update coordinator.""" + + config_entry: NtfyConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: NtfyConfigEntry, ntfy: Ntfy + ) -> None: + """Initialize the ntfy data update coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=timedelta(minutes=15), + ) + + self.ntfy = ntfy + + async def _async_update_data(self) -> NtfyAccount: + """Fetch account data from ntfy.""" + + try: + return await self.ntfy.account() + except NtfyUnauthorizedAuthenticationError as e: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="authentication_error", + ) from e + except NtfyHTTPError as e: + _LOGGER.debug("Error %s: %s [%s]", e.code, e.error, e.link) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="server_error", + translation_placeholders={"error_msg": str(e.error)}, + ) from e + except NtfyConnectionError as e: + _LOGGER.debug("Error", exc_info=True) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from e + except NtfyTimeoutError as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="timeout_error", + ) from e diff --git a/homeassistant/components/ntfy/icons.json b/homeassistant/components/ntfy/icons.json index 9fe617880af..66489413b5b 100644 --- a/homeassistant/components/ntfy/icons.json +++ b/homeassistant/components/ntfy/icons.json @@ -4,6 +4,68 @@ "publish": { "default": "mdi:console-line" } + }, + "sensor": { + "messages": { + "default": "mdi:message-arrow-right-outline" + }, + "messages_remaining": { + "default": "mdi:message-plus-outline" + }, + "messages_limit": { + "default": "mdi:message-alert-outline" + }, + "messages_expiry_duration": { + "default": "mdi:message-text-clock" + }, + "emails": { + "default": "mdi:email-arrow-right-outline" + }, + "emails_remaining": { + "default": "mdi:email-plus-outline" + }, + "emails_limit": { + "default": "mdi:email-alert-outline" + }, + "calls": { + "default": "mdi:phone-outgoing" + }, + "calls_remaining": { + "default": "mdi:phone-plus" + }, + "calls_limit": { + "default": "mdi:phone-alert" + }, + "reservations": { + "default": "mdi:lock" + }, + "reservations_remaining": { + "default": "mdi:lock-plus" + }, + "reservations_limit": { + "default": "mdi:lock-alert" + }, + "attachment_total_size": { + "default": "mdi:database-arrow-right" + }, + "attachment_total_size_remaining": { + "default": "mdi:database-plus" + }, + "attachment_total_size_limit": { + "default": "mdi:database-alert" + }, + "attachment_expiry_duration": { + "default": "mdi:cloud-clock" + }, + "attachment_file_size": { + "default": "mdi:file-alert" + }, + "attachment_bandwidth": { + "default": "mdi:cloud-upload" + }, + "tier": { + "default": "mdi:star" + } } } } diff --git a/homeassistant/components/ntfy/notify.py b/homeassistant/components/ntfy/notify.py index 7328a1533c2..e10e64caf23 100644 --- a/homeassistant/components/ntfy/notify.py +++ b/homeassistant/components/ntfy/notify.py @@ -22,8 +22,8 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import NtfyConfigEntry from .const import CONF_TOPIC, DOMAIN +from .coordinator import NtfyConfigEntry PARALLEL_UPDATES = 0 @@ -69,9 +69,10 @@ class NtfyNotifyEntity(NotifyEntity): name=subentry.data.get(CONF_NAME, self.topic), configuration_url=URL(config_entry.data[CONF_URL]) / self.topic, identifiers={(DOMAIN, f"{config_entry.entry_id}_{subentry.subentry_id}")}, + via_device=(DOMAIN, config_entry.entry_id), ) self.config_entry = config_entry - self.ntfy = config_entry.runtime_data + self.ntfy = config_entry.runtime_data.ntfy async def async_send_message(self, message: str, title: str | None = None) -> None: """Publish a message to a topic.""" diff --git a/homeassistant/components/ntfy/sensor.py b/homeassistant/components/ntfy/sensor.py new file mode 100644 index 00000000000..0180d9fce72 --- /dev/null +++ b/homeassistant/components/ntfy/sensor.py @@ -0,0 +1,272 @@ +"""Sensor platform for ntfy integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum + +from aiontfy import Account as NtfyAccount +from yarl import URL + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import CONF_URL, EntityCategory, UnitOfInformation, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import NtfyConfigEntry, NtfyDataUpdateCoordinator + +PARALLEL_UPDATES = 0 + + +@dataclass(kw_only=True, frozen=True) +class NtfySensorEntityDescription(SensorEntityDescription): + """Ntfy Sensor Description.""" + + value_fn: Callable[[NtfyAccount], StateType] + + +class NtfySensor(StrEnum): + """Ntfy sensors.""" + + MESSAGES = "messages" + MESSAGES_REMAINING = "messages_remaining" + MESSAGES_LIMIT = "messages_limit" + MESSAGES_EXPIRY_DURATION = "messages_expiry_duration" + EMAILS = "emails" + EMAILS_REMAINING = "emails_remaining" + EMAILS_LIMIT = "emails_limit" + CALLS = "calls" + CALLS_REMAINING = "calls_remaining" + CALLS_LIMIT = "calls_limit" + RESERVATIONS = "reservations" + RESERVATIONS_REMAINING = "reservations_remaining" + RESERVATIONS_LIMIT = "reservations_limit" + ATTACHMENT_TOTAL_SIZE = "attachment_total_size" + ATTACHMENT_TOTAL_SIZE_REMAINING = "attachment_total_size_remaining" + ATTACHMENT_TOTAL_SIZE_LIMIT = "attachment_total_size_limit" + ATTACHMENT_EXPIRY_DURATION = "attachment_expiry_duration" + ATTACHMENT_BANDWIDTH = "attachment_bandwidth" + ATTACHMENT_FILE_SIZE = "attachment_file_size" + TIER = "tier" + + +SENSOR_DESCRIPTIONS: tuple[NtfySensorEntityDescription, ...] = ( + NtfySensorEntityDescription( + key=NtfySensor.MESSAGES, + translation_key=NtfySensor.MESSAGES, + value_fn=lambda account: account.stats.messages, + ), + NtfySensorEntityDescription( + key=NtfySensor.MESSAGES_REMAINING, + translation_key=NtfySensor.MESSAGES_REMAINING, + value_fn=lambda account: account.stats.messages_remaining, + entity_registry_enabled_default=False, + ), + NtfySensorEntityDescription( + key=NtfySensor.MESSAGES_LIMIT, + translation_key=NtfySensor.MESSAGES_LIMIT, + value_fn=lambda account: account.limits.messages if account.limits else None, + entity_category=EntityCategory.DIAGNOSTIC, + ), + NtfySensorEntityDescription( + key=NtfySensor.MESSAGES_EXPIRY_DURATION, + translation_key=NtfySensor.MESSAGES_EXPIRY_DURATION, + value_fn=( + lambda account: account.limits.messages_expiry_duration + if account.limits + else None + ), + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + ), + NtfySensorEntityDescription( + key=NtfySensor.EMAILS, + translation_key=NtfySensor.EMAILS, + value_fn=lambda account: account.stats.emails, + ), + NtfySensorEntityDescription( + key=NtfySensor.EMAILS_REMAINING, + translation_key=NtfySensor.EMAILS_REMAINING, + value_fn=lambda account: account.stats.emails_remaining, + entity_registry_enabled_default=False, + ), + NtfySensorEntityDescription( + key=NtfySensor.EMAILS_LIMIT, + translation_key=NtfySensor.EMAILS_LIMIT, + value_fn=lambda account: account.limits.emails if account.limits else None, + entity_category=EntityCategory.DIAGNOSTIC, + ), + NtfySensorEntityDescription( + key=NtfySensor.CALLS, + translation_key=NtfySensor.CALLS, + value_fn=lambda account: account.stats.calls, + ), + NtfySensorEntityDescription( + key=NtfySensor.CALLS_REMAINING, + translation_key=NtfySensor.CALLS_REMAINING, + value_fn=lambda account: account.stats.calls_remaining, + entity_registry_enabled_default=False, + ), + NtfySensorEntityDescription( + key=NtfySensor.CALLS_LIMIT, + translation_key=NtfySensor.CALLS_LIMIT, + value_fn=lambda account: account.limits.calls if account.limits else None, + entity_category=EntityCategory.DIAGNOSTIC, + ), + NtfySensorEntityDescription( + key=NtfySensor.RESERVATIONS, + translation_key=NtfySensor.RESERVATIONS, + value_fn=lambda account: account.stats.reservations, + ), + NtfySensorEntityDescription( + key=NtfySensor.RESERVATIONS_REMAINING, + translation_key=NtfySensor.RESERVATIONS_REMAINING, + value_fn=lambda account: account.stats.reservations_remaining, + entity_registry_enabled_default=False, + ), + NtfySensorEntityDescription( + key=NtfySensor.RESERVATIONS_LIMIT, + translation_key=NtfySensor.RESERVATIONS_LIMIT, + value_fn=( + lambda account: account.limits.reservations if account.limits else None + ), + entity_category=EntityCategory.DIAGNOSTIC, + ), + NtfySensorEntityDescription( + key=NtfySensor.ATTACHMENT_EXPIRY_DURATION, + translation_key=NtfySensor.ATTACHMENT_EXPIRY_DURATION, + value_fn=( + lambda account: account.limits.attachment_expiry_duration + if account.limits + else None + ), + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + ), + NtfySensorEntityDescription( + key=NtfySensor.ATTACHMENT_TOTAL_SIZE, + translation_key=NtfySensor.ATTACHMENT_TOTAL_SIZE, + value_fn=lambda account: account.stats.attachment_total_size, + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.MEBIBYTES, + suggested_display_precision=0, + ), + NtfySensorEntityDescription( + key=NtfySensor.ATTACHMENT_TOTAL_SIZE_REMAINING, + translation_key=NtfySensor.ATTACHMENT_TOTAL_SIZE_REMAINING, + value_fn=lambda account: account.stats.attachment_total_size_remaining, + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.MEBIBYTES, + suggested_display_precision=0, + entity_registry_enabled_default=False, + ), + NtfySensorEntityDescription( + key=NtfySensor.ATTACHMENT_TOTAL_SIZE_LIMIT, + translation_key=NtfySensor.ATTACHMENT_TOTAL_SIZE_LIMIT, + value_fn=( + lambda account: account.limits.attachment_total_size + if account.limits + else None + ), + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.MEBIBYTES, + suggested_display_precision=0, + ), + NtfySensorEntityDescription( + key=NtfySensor.ATTACHMENT_FILE_SIZE, + translation_key=NtfySensor.ATTACHMENT_FILE_SIZE, + value_fn=( + lambda account: account.limits.attachment_file_size + if account.limits + else None + ), + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.MEBIBYTES, + suggested_display_precision=0, + ), + NtfySensorEntityDescription( + key=NtfySensor.ATTACHMENT_BANDWIDTH, + translation_key=NtfySensor.ATTACHMENT_BANDWIDTH, + value_fn=( + lambda account: account.limits.attachment_bandwidth + if account.limits + else None + ), + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.MEBIBYTES, + suggested_display_precision=0, + ), + NtfySensorEntityDescription( + key=NtfySensor.TIER, + translation_key=NtfySensor.TIER, + value_fn=lambda account: account.tier.name if account.tier else "free", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: NtfyConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the sensor platform.""" + coordinator = config_entry.runtime_data + async_add_entities( + NtfySensorEntity(coordinator, description) + for description in SENSOR_DESCRIPTIONS + ) + + +class NtfySensorEntity(CoordinatorEntity[NtfyDataUpdateCoordinator], SensorEntity): + """Representation of a ntfy sensor entity.""" + + entity_description: NtfySensorEntityDescription + coordinator: NtfyDataUpdateCoordinator + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: NtfyDataUpdateCoordinator, + description: NtfySensorEntityDescription, + ) -> None: + """Initialize a sensor entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + manufacturer="ntfy LLC", + model="ntfy", + configuration_url=URL(coordinator.config_entry.data[CONF_URL]) / "app", + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + ) + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/ntfy/strings.json b/homeassistant/components/ntfy/strings.json index cef662d6f2f..08a0a20a30a 100644 --- a/homeassistant/components/ntfy/strings.json +++ b/homeassistant/components/ntfy/strings.json @@ -120,6 +120,88 @@ } } }, + "entity": { + "sensor": { + "messages": { + "name": "Messages published", + "unit_of_measurement": "messages" + }, + "messages_remaining": { + "name": "Messages remaining", + "unit_of_measurement": "[%key:component::ntfy::entity::sensor::messages::unit_of_measurement%]" + }, + "messages_limit": { + "name": "Messages usage limit", + "unit_of_measurement": "[%key:component::ntfy::entity::sensor::messages::unit_of_measurement%]" + }, + "messages_expiry_duration": { + "name": "Messages expiry duration" + }, + "emails": { + "name": "Emails sent", + "unit_of_measurement": "emails" + }, + "emails_remaining": { + "name": "Emails remaining", + "unit_of_measurement": "[%key:component::ntfy::entity::sensor::emails::unit_of_measurement%]" + }, + "emails_limit": { + "name": "Email usage limit", + "unit_of_measurement": "[%key:component::ntfy::entity::sensor::emails::unit_of_measurement%]" + }, + "calls": { + "name": "Phone calls made", + "unit_of_measurement": "calls" + }, + "calls_remaining": { + "name": "Phone calls remaining", + "unit_of_measurement": "[%key:component::ntfy::entity::sensor::calls::unit_of_measurement%]" + }, + "calls_limit": { + "name": "Phone calls usage limit", + "unit_of_measurement": "[%key:component::ntfy::entity::sensor::calls::unit_of_measurement%]" + }, + "reservations": { + "name": "Reserved topics", + "unit_of_measurement": "topics" + }, + "reservations_remaining": { + "name": "Reserved topics remaining", + "unit_of_measurement": "[%key:component::ntfy::entity::sensor::reservations::unit_of_measurement%]" + }, + "reservations_limit": { + "name": "Reserved topics limit", + "unit_of_measurement": "[%key:component::ntfy::entity::sensor::reservations::unit_of_measurement%]" + }, + "attachment_total_size": { + "name": "Attachment storage" + }, + "attachment_total_size_remaining": { + "name": "Attachment storage remaining" + }, + "attachment_total_size_limit": { + "name": "Attachment storage limit" + }, + "attachment_expiry_duration": { + "name": "Attachment expiry duration" + }, + "attachment_file_size": { + "name": "Attachment file size limit" + }, + "attachment_bandwidth": { + "name": "Attachment bandwidth limit" + }, + "tier": { + "name": "Subscription tier", + "state": { + "free": "Free", + "supporter": "Supporter", + "pro": "Pro", + "business": "Business" + } + } + } + }, "exceptions": { "publish_failed_request_error": { "message": "Failed to publish notification: {error_msg}" diff --git a/homeassistant/components/playstation_network/__init__.py b/homeassistant/components/playstation_network/__init__.py new file mode 100644 index 00000000000..c111cf8c960 --- /dev/null +++ b/homeassistant/components/playstation_network/__init__.py @@ -0,0 +1,34 @@ +"""The PlayStation Network integration.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import CONF_NPSSO +from .coordinator import PlaystationNetworkConfigEntry, PlaystationNetworkCoordinator +from .helpers import PlaystationNetwork + +PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER] + + +async def async_setup_entry( + hass: HomeAssistant, entry: PlaystationNetworkConfigEntry +) -> bool: + """Set up Playstation Network from a config entry.""" + + psn = PlaystationNetwork(hass, entry.data[CONF_NPSSO]) + + coordinator = PlaystationNetworkCoordinator(hass, psn, 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: PlaystationNetworkConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/playstation_network/config_flow.py b/homeassistant/components/playstation_network/config_flow.py new file mode 100644 index 00000000000..e2b402a212e --- /dev/null +++ b/homeassistant/components/playstation_network/config_flow.py @@ -0,0 +1,69 @@ +"""Config flow for the PlayStation Network integration.""" + +import logging +from typing import Any + +from psnawp_api.core.psnawp_exceptions import ( + PSNAWPAuthenticationError, + PSNAWPError, + PSNAWPInvalidTokenError, + PSNAWPNotFoundError, +) +from psnawp_api.models.user import User +from psnawp_api.utils.misc import parse_npsso_token +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult + +from .const import CONF_NPSSO, DOMAIN +from .helpers import PlaystationNetwork + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_NPSSO): str}) + + +class PlaystationNetworkConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Playstation Network.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + npsso: str | None = None + if user_input is not None: + try: + npsso = parse_npsso_token(user_input[CONF_NPSSO]) + except PSNAWPInvalidTokenError: + errors["base"] = "invalid_account" + else: + psn = PlaystationNetwork(self.hass, npsso) + try: + user: User = await psn.get_user() + except PSNAWPAuthenticationError: + errors["base"] = "invalid_auth" + except PSNAWPNotFoundError: + errors["base"] = "invalid_account" + except PSNAWPError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(user.account_id) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user.online_id, + data={CONF_NPSSO: npsso}, + ) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + description_placeholders={ + "npsso_link": "https://ca.account.sony.com/api/v1/ssocookie", + "psn_link": "https://playstation.com", + }, + ) diff --git a/homeassistant/components/playstation_network/const.py b/homeassistant/components/playstation_network/const.py new file mode 100644 index 00000000000..2db43f433e6 --- /dev/null +++ b/homeassistant/components/playstation_network/const.py @@ -0,0 +1,15 @@ +"""Constants for the Playstation Network integration.""" + +from typing import Final + +from psnawp_api.models.trophies import PlatformType + +DOMAIN = "playstation_network" +CONF_NPSSO: Final = "npsso" + +SUPPORTED_PLATFORMS = { + PlatformType.PS5, + PlatformType.PS4, + PlatformType.PS3, + PlatformType.PSPC, +} diff --git a/homeassistant/components/playstation_network/coordinator.py b/homeassistant/components/playstation_network/coordinator.py new file mode 100644 index 00000000000..f6fd53ccb24 --- /dev/null +++ b/homeassistant/components/playstation_network/coordinator.py @@ -0,0 +1,69 @@ +"""Coordinator for the PlayStation Network Integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from psnawp_api.core.psnawp_exceptions import ( + PSNAWPAuthenticationError, + PSNAWPServerError, +) +from psnawp_api.models.user import User + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN +from .helpers import PlaystationNetwork, PlaystationNetworkData + +_LOGGER = logging.getLogger(__name__) + +type PlaystationNetworkConfigEntry = ConfigEntry[PlaystationNetworkCoordinator] + + +class PlaystationNetworkCoordinator(DataUpdateCoordinator[PlaystationNetworkData]): + """Data update coordinator for PSN.""" + + config_entry: PlaystationNetworkConfigEntry + user: User + + def __init__( + self, + hass: HomeAssistant, + psn: PlaystationNetwork, + config_entry: PlaystationNetworkConfigEntry, + ) -> None: + """Initialize the Coordinator.""" + super().__init__( + hass, + name=DOMAIN, + logger=_LOGGER, + config_entry=config_entry, + update_interval=timedelta(seconds=30), + ) + + self.psn = psn + + async def _async_setup(self) -> None: + """Set up the coordinator.""" + + try: + self.user = await self.psn.get_user() + except PSNAWPAuthenticationError as error: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="not_ready", + ) from error + + async def _async_update_data(self) -> PlaystationNetworkData: + """Get the latest data from the PSN.""" + try: + return await self.psn.get_data() + except (PSNAWPAuthenticationError, PSNAWPServerError) as error: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + ) from error diff --git a/homeassistant/components/playstation_network/helpers.py b/homeassistant/components/playstation_network/helpers.py new file mode 100644 index 00000000000..38f8d5e1356 --- /dev/null +++ b/homeassistant/components/playstation_network/helpers.py @@ -0,0 +1,151 @@ +"""Helper methods for common PlayStation Network integration operations.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from functools import partial +from typing import Any + +from psnawp_api import PSNAWP +from psnawp_api.core.psnawp_exceptions import PSNAWPNotFoundError +from psnawp_api.models.client import Client +from psnawp_api.models.trophies import PlatformType +from psnawp_api.models.user import User +from pyrate_limiter import Duration, Rate + +from homeassistant.core import HomeAssistant + +from .const import SUPPORTED_PLATFORMS + +LEGACY_PLATFORMS = {PlatformType.PS3, PlatformType.PS4} + + +@dataclass +class SessionData: + """Dataclass representing console session data.""" + + platform: PlatformType = PlatformType.UNKNOWN + title_id: str | None = None + title_name: str | None = None + format: PlatformType | None = None + media_image_url: str | None = None + status: str = "" + + +@dataclass +class PlaystationNetworkData: + """Dataclass representing data retrieved from the Playstation Network api.""" + + presence: dict[str, Any] = field(default_factory=dict) + username: str = "" + account_id: str = "" + available: bool = False + active_sessions: dict[PlatformType, SessionData] = field(default_factory=dict) + registered_platforms: set[PlatformType] = field(default_factory=set) + + +class PlaystationNetwork: + """Helper Class to return playstation network data in an easy to use structure.""" + + def __init__(self, hass: HomeAssistant, npsso: str) -> None: + """Initialize the class with the npsso token.""" + rate = Rate(300, Duration.MINUTE * 15) + self.psn = PSNAWP(npsso, rate_limit=rate) + self.client: Client | None = None + self.hass = hass + self.user: User + self.legacy_profile: dict[str, Any] | None = None + + async def get_user(self) -> User: + """Get the user object from the PlayStation Network.""" + self.user = await self.hass.async_add_executor_job( + partial(self.psn.user, online_id="me") + ) + return self.user + + def retrieve_psn_data(self) -> PlaystationNetworkData: + """Bundle api calls to retrieve data from the PlayStation Network.""" + data = PlaystationNetworkData() + + if not self.client: + self.client = self.psn.me() + + data.registered_platforms = { + PlatformType(device["deviceType"]) + for device in self.client.get_account_devices() + } & SUPPORTED_PLATFORMS + + data.presence = self.user.get_presence() + + # check legacy platforms if owned + if LEGACY_PLATFORMS & data.registered_platforms: + self.legacy_profile = self.client.get_profile_legacy() + return data + + async def get_data(self) -> PlaystationNetworkData: + """Get title data from the PlayStation Network.""" + data = await self.hass.async_add_executor_job(self.retrieve_psn_data) + data.username = self.user.online_id + data.account_id = self.user.account_id + + data.available = ( + data.presence.get("basicPresence", {}).get("availability") + == "availableToPlay" + ) + + session = SessionData() + session.platform = PlatformType( + data.presence["basicPresence"]["primaryPlatformInfo"]["platform"] + ) + + if session.platform in SUPPORTED_PLATFORMS: + session.status = data.presence.get("basicPresence", {}).get( + "primaryPlatformInfo" + )["onlineStatus"] + + game_title_info = data.presence.get("basicPresence", {}).get( + "gameTitleInfoList" + ) + + if game_title_info: + session.title_id = game_title_info[0]["npTitleId"] + session.title_name = game_title_info[0]["titleName"] + session.format = PlatformType(game_title_info[0]["format"]) + if session.format in {PlatformType.PS5, PlatformType.PSPC}: + session.media_image_url = game_title_info[0]["conceptIconUrl"] + else: + session.media_image_url = game_title_info[0]["npTitleIconUrl"] + + data.active_sessions[session.platform] = session + + if self.legacy_profile: + presence = self.legacy_profile["profile"].get("presences", []) + game_title_info = presence[0] if presence else {} + session = SessionData() + + # If primary console isn't online, check legacy platforms for status + if not data.available: + data.available = game_title_info["onlineStatus"] == "online" + + if "npTitleId" in game_title_info: + session.title_id = game_title_info["npTitleId"] + session.title_name = game_title_info["titleName"] + session.format = game_title_info["platform"] + session.platform = game_title_info["platform"] + session.status = game_title_info["onlineStatus"] + if PlatformType(session.format) is PlatformType.PS4: + session.media_image_url = game_title_info["npTitleIconUrl"] + elif PlatformType(session.format) is PlatformType.PS3: + try: + title = self.psn.game_title( + session.title_id, platform=PlatformType.PS3, account_id="me" + ) + except PSNAWPNotFoundError: + session.media_image_url = None + + if title: + session.media_image_url = title.get_title_icon_url() + + if game_title_info["onlineStatus"] == "online": + data.active_sessions[session.platform] = session + return data diff --git a/homeassistant/components/playstation_network/icons.json b/homeassistant/components/playstation_network/icons.json new file mode 100644 index 00000000000..2ff18bf6e59 --- /dev/null +++ b/homeassistant/components/playstation_network/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "media_player": { + "playstation": { + "default": "mdi:sony-playstation" + } + } + } +} diff --git a/homeassistant/components/playstation_network/manifest.json b/homeassistant/components/playstation_network/manifest.json new file mode 100644 index 00000000000..f929e569b66 --- /dev/null +++ b/homeassistant/components/playstation_network/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "playstation_network", + "name": "PlayStation Network", + "codeowners": ["@jackjpowell"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/playstation_network", + "integration_type": "service", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["PSNAWP==3.0.0", "pyrate-limiter==3.7.0"] +} diff --git a/homeassistant/components/playstation_network/media_player.py b/homeassistant/components/playstation_network/media_player.py new file mode 100644 index 00000000000..08840fbbabd --- /dev/null +++ b/homeassistant/components/playstation_network/media_player.py @@ -0,0 +1,128 @@ +"""Media player entity for the PlayStation Network Integration.""" + +import logging + +from psnawp_api.models.trophies import PlatformType + +from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerState, + MediaType, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import PlaystationNetworkConfigEntry, PlaystationNetworkCoordinator +from .const import DOMAIN, SUPPORTED_PLATFORMS + +_LOGGER = logging.getLogger(__name__) + + +PLATFORM_MAP = { + PlatformType.PS5: "PlayStation 5", + PlatformType.PS4: "PlayStation 4", + PlatformType.PS3: "PlayStation 3", + PlatformType.PSPC: "PlayStation PC", +} +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PlaystationNetworkConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Media Player Entity Setup.""" + coordinator = config_entry.runtime_data + devices_added: set[PlatformType] = set() + device_reg = dr.async_get(hass) + entities = [] + + @callback + def add_entities() -> None: + nonlocal devices_added + + if not SUPPORTED_PLATFORMS - devices_added: + remove_listener() + + new_platforms = set(coordinator.data.active_sessions.keys()) - devices_added + if new_platforms: + async_add_entities( + PsnMediaPlayerEntity(coordinator, platform_type) + for platform_type in new_platforms + ) + devices_added |= new_platforms + + for platform in SUPPORTED_PLATFORMS: + if device_reg.async_get_device( + identifiers={ + (DOMAIN, f"{coordinator.config_entry.unique_id}_{platform.value}") + } + ): + entities.append(PsnMediaPlayerEntity(coordinator, platform)) + devices_added.add(platform) + if entities: + async_add_entities(entities) + + remove_listener = coordinator.async_add_listener(add_entities) + add_entities() + + +class PsnMediaPlayerEntity( + CoordinatorEntity[PlaystationNetworkCoordinator], MediaPlayerEntity +): + """Media player entity representing currently playing game.""" + + _attr_media_image_remotely_accessible = True + _attr_media_content_type = MediaType.GAME + _attr_device_class = MediaPlayerDeviceClass.RECEIVER + _attr_translation_key = "playstation" + _attr_has_entity_name = True + _attr_name = None + + def __init__( + self, coordinator: PlaystationNetworkCoordinator, platform: PlatformType + ) -> None: + """Initialize PSN MediaPlayer.""" + super().__init__(coordinator) + + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{platform.value}" + self.key = platform + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._attr_unique_id)}, + name=PLATFORM_MAP[platform], + manufacturer="Sony Interactive Entertainment", + model=PLATFORM_MAP[platform], + ) + + @property + def state(self) -> MediaPlayerState: + """Media Player state getter.""" + session = self.coordinator.data.active_sessions.get(self.key) + if session and session.status == "online": + if self.coordinator.data.available and session.title_id is not None: + return MediaPlayerState.PLAYING + return MediaPlayerState.ON + return MediaPlayerState.OFF + + @property + def media_title(self) -> str | None: + """Media title getter.""" + session = self.coordinator.data.active_sessions.get(self.key) + return session.title_name if session else None + + @property + def media_content_id(self) -> str | None: + """Content ID of current playing media.""" + session = self.coordinator.data.active_sessions.get(self.key) + return session.title_id if session else None + + @property + def media_image_url(self) -> str | None: + """Media image url getter.""" + session = self.coordinator.data.active_sessions.get(self.key) + return session.media_image_url if session else None diff --git a/homeassistant/components/playstation_network/quality_scale.yaml b/homeassistant/components/playstation_network/quality_scale.yaml new file mode 100644 index 00000000000..36c28f19145 --- /dev/null +++ b/homeassistant/components/playstation_network/quality_scale.yaml @@ -0,0 +1,72 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration has no 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: Integration has no actions + + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Integration does not register 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: Integration has no actions + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration has no configuration options + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: Discovery flow is not applicable for this integration + discovery: todo + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: done + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/playstation_network/strings.json b/homeassistant/components/playstation_network/strings.json new file mode 100644 index 00000000000..e4581322edb --- /dev/null +++ b/homeassistant/components/playstation_network/strings.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "data": { + "npsso": "NPSSO token" + }, + "data_description": { + "npsso": "The NPSSO token is generated upon successful login of your PlayStation Network account and is used to authenticate your requests within Home Assistant." + }, + "description": "To obtain your NPSSO token, log in to your [PlayStation account]({psn_link}) first. Then [click here]({npsso_link}) to retrieve the token." + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_account": "[%key:common::config_flow::error::invalid_access_token%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + } + }, + "exceptions": { + "not_ready": { + "message": "Authentication to the PlayStation Network failed." + }, + "update_failed": { + "message": "Data retrieval failed when trying to access the PlayStation Network." + } + } +} diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 38445b912bc..3260bff44b5 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -32,6 +32,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import ( BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL, + CONF_BC_ONLY, CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, @@ -107,6 +108,7 @@ async def async_setup_entry( or host.api.supported(None, "privacy_mode") != config_entry.data.get(CONF_SUPPORTS_PRIVACY_MODE) or host.api.baichuan.port != config_entry.data.get(CONF_BC_PORT) + or host.api.baichuan_only != config_entry.data.get(CONF_BC_ONLY) ): if host.api.port != config_entry.data[CONF_PORT]: _LOGGER.warning( @@ -130,6 +132,7 @@ async def async_setup_entry( CONF_PORT: host.api.port, CONF_USE_HTTPS: host.api.use_https, CONF_BC_PORT: host.api.baichuan.port, + CONF_BC_ONLY: host.api.baichuan_only, CONF_SUPPORTS_PRIVACY_MODE: host.api.supported(None, "privacy_mode"), } hass.config_entries.async_update_entry(config_entry, data=data) diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py index 119fb625349..44386434cad 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -69,21 +69,28 @@ CAMERA_ENTITIES = ( ), ReolinkCameraEntityDescription( key="autotrack_sub", - stream="autotrack_sub", - translation_key="autotrack_sub", + stream="telephoto_sub", + translation_key="telephoto_sub", supported=lambda api, ch: api.supported(ch, "autotrack_stream"), ), + ReolinkCameraEntityDescription( + key="autotrack_main", + stream="telephoto_main", + translation_key="telephoto_main", + supported=lambda api, ch: api.supported(ch, "autotrack_stream"), + entity_registry_enabled_default=False, + ), ReolinkCameraEntityDescription( key="autotrack_snapshots_sub", stream="autotrack_snapshots_sub", - translation_key="autotrack_snapshots_sub", + translation_key="telephoto_snapshots_sub", supported=lambda api, ch: api.supported(ch, "autotrack_stream"), entity_registry_enabled_default=False, ), ReolinkCameraEntityDescription( key="autotrack_snapshots_main", stream="autotrack_snapshots_main", - translation_key="autotrack_snapshots_main", + translation_key="telephoto_snapshots_main", supported=lambda api, ch: api.supported(ch, "autotrack_stream"), entity_registry_enabled_default=False, ), diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index 659169c3618..eee8b04dfcc 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -38,7 +38,13 @@ from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .const import CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN +from .const import ( + CONF_BC_ONLY, + CONF_BC_PORT, + CONF_SUPPORTS_PRIVACY_MODE, + CONF_USE_HTTPS, + DOMAIN, +) from .exceptions import ( PasswordIncompatible, ReolinkException, @@ -296,6 +302,7 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): user_input[CONF_PORT] = host.api.port user_input[CONF_USE_HTTPS] = host.api.use_https user_input[CONF_BC_PORT] = host.api.baichuan.port + user_input[CONF_BC_ONLY] = host.api.baichuan_only user_input[CONF_SUPPORTS_PRIVACY_MODE] = host.api.supported( None, "privacy_mode" ) diff --git a/homeassistant/components/reolink/const.py b/homeassistant/components/reolink/const.py index bd9c4bb84a2..db2d105984b 100644 --- a/homeassistant/components/reolink/const.py +++ b/homeassistant/components/reolink/const.py @@ -4,6 +4,7 @@ DOMAIN = "reolink" CONF_USE_HTTPS = "use_https" CONF_BC_PORT = "baichuan_port" +CONF_BC_ONLY = "baichuan_only" CONF_SUPPORTS_PRIVACY_MODE = "privacy_mode_supported" # Conserve battery by not waking the battery cameras each minute during normal update diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 467472fef9c..a83dc259e1b 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -24,7 +24,7 @@ class ReolinkEntityDescription(EntityDescription): """A class that describes entities for Reolink.""" cmd_key: str | None = None - cmd_id: int | None = None + cmd_id: int | list[int] | None = None always_available: bool = False @@ -120,12 +120,15 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None] """Entity created.""" await super().async_added_to_hass() cmd_key = self.entity_description.cmd_key - cmd_id = self.entity_description.cmd_id + cmd_ids = self.entity_description.cmd_id callback_id = f"{self.platform.domain}_{self._attr_unique_id}" if cmd_key is not None: self._host.async_register_update_cmd(cmd_key) - if cmd_id is not None: - self.register_callback(callback_id, cmd_id) + if isinstance(cmd_ids, int): + self.register_callback(callback_id, cmd_ids) + elif isinstance(cmd_ids, list): + for cmd_id in cmd_ids: + self.register_callback(callback_id, cmd_id) # Privacy mode self.register_callback(f"{callback_id}_623", 623) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 39b58c92ac3..0f64dc05902 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -38,6 +38,7 @@ from .const import ( BATTERY_ALL_WAKE_UPDATE_INTERVAL, BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL, BATTERY_WAKE_UPDATE_INTERVAL, + CONF_BC_ONLY, CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, @@ -97,6 +98,7 @@ class ReolinkHost: timeout=DEFAULT_TIMEOUT, aiohttp_get_session_callback=get_aiohttp_session, bc_port=config.get(CONF_BC_PORT, DEFAULT_BC_PORT), + bc_only=config.get(CONF_BC_ONLY, False), ) self.last_wake: defaultdict[int, float] = defaultdict(float) @@ -220,19 +222,27 @@ class ReolinkHost: enable_onvif = None enable_rtmp = None - if not self._api.rtsp_enabled: + if not self._api.rtsp_enabled and not self._api.baichuan_only: _LOGGER.debug( "RTSP is disabled on %s, trying to enable it", self._api.nvr_name ) enable_rtsp = True - if not self._api.onvif_enabled and onvif_supported: + if ( + not self._api.onvif_enabled + and onvif_supported + and not self._api.baichuan_only + ): _LOGGER.debug( "ONVIF is disabled on %s, trying to enable it", self._api.nvr_name ) enable_onvif = True - if not self._api.rtmp_enabled and self._api.protocol == "rtmp": + if ( + not self._api.rtmp_enabled + and self._api.protocol == "rtmp" + and not self._api.baichuan_only + ): _LOGGER.debug( "RTMP is disabled on %s, trying to enable it", self._api.nvr_name ) diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py index d48790264d1..1e2c6d49528 100644 --- a/homeassistant/components/reolink/light.py +++ b/homeassistant/components/reolink/light.py @@ -57,7 +57,7 @@ LIGHT_ENTITIES = ( ReolinkLightEntityDescription( key="floodlight", cmd_key="GetWhiteLed", - cmd_id=291, + cmd_id=[291, 289, 438], translation_key="floodlight", supported=lambda api, ch: api.supported(ch, "floodLight"), is_on_fn=lambda api, ch: api.whiteled_state(ch), diff --git a/homeassistant/components/reolink/media_source.py b/homeassistant/components/reolink/media_source.py index 36a2f3c5489..9c8c685d898 100644 --- a/homeassistant/components/reolink/media_source.py +++ b/homeassistant/components/reolink/media_source.py @@ -42,9 +42,9 @@ def res_name(stream: str) -> str: case "main": return "High res." case "autotrack_sub": - return "Autotrack low res." + return "Telephoto low res." case "autotrack_main": - return "Autotrack high res." + return "Telephoto high res." case _: return "Low res." @@ -284,7 +284,7 @@ class ReolinkVODMediaSource(MediaSource): identifier=f"RES|{config_entry_id}|{channel}|autotrack_sub", media_class=MediaClass.CHANNEL, media_content_type=MediaType.PLAYLIST, - title="Autotrack low resolution", + title="Telephoto low resolution", can_play=False, can_expand=True, ), @@ -293,7 +293,7 @@ class ReolinkVODMediaSource(MediaSource): identifier=f"RES|{config_entry_id}|{channel}|autotrack_main", media_class=MediaClass.CHANNEL, media_content_type=MediaType.PLAYLIST, - title="Autotrack high resolution", + title="Telephoto high resolution", can_play=False, can_expand=True, ), diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 6de702a0395..2de2468ca3d 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -113,6 +113,7 @@ NUMBER_ENTITIES = ( ReolinkNumberEntityDescription( key="floodlight_brightness", cmd_key="GetWhiteLed", + cmd_id=[289, 438], translation_key="floodlight_brightness", entity_category=EntityCategory.CONFIG, native_step=1, diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index e7a970ec1c8..5473887a8ff 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -504,14 +504,17 @@ "ext_lens_1": { "name": "Balanced lens 1" }, - "autotrack_sub": { - "name": "Autotrack fluent" + "telephoto_sub": { + "name": "Telephoto fluent" }, - "autotrack_snapshots_sub": { - "name": "Autotrack snapshots fluent" + "telephoto_main": { + "name": "Telephoto clear" }, - "autotrack_snapshots_main": { - "name": "Autotrack snapshots clear" + "telephoto_snapshots_sub": { + "name": "Telephoto snapshots fluent" + }, + "telephoto_snapshots_main": { + "name": "Telephoto snapshots clear" } }, "light": { diff --git a/homeassistant/components/tedee/manifest.json b/homeassistant/components/tedee/manifest.json index 012e82318ed..6e0f6ee588b 100644 --- a/homeassistant/components/tedee/manifest.json +++ b/homeassistant/components/tedee/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["aiotedee"], "quality_scale": "platinum", - "requirements": ["aiotedee==0.2.23"] + "requirements": ["aiotedee==0.2.25"] } diff --git a/homeassistant/components/telegram_bot/bot.py b/homeassistant/components/telegram_bot/bot.py index f313972635f..9debc7bbbf1 100644 --- a/homeassistant/components/telegram_bot/bot.py +++ b/homeassistant/components/telegram_bot/bot.py @@ -2,8 +2,10 @@ from abc import abstractmethod import asyncio +from collections.abc import Callable, Sequence import io import logging +from ssl import SSLContext from types import MappingProxyType from typing import Any @@ -13,6 +15,7 @@ from telegram import ( CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, + InputPollOption, Message, ReplyKeyboardMarkup, ReplyKeyboardRemove, @@ -262,7 +265,9 @@ class TelegramNotificationService: return allowed_chat_ids - def _get_msg_ids(self, msg_data, chat_id): + def _get_msg_ids( + self, msg_data: dict[str, Any], chat_id: int + ) -> tuple[Any | None, int | None]: """Get the message id to edit. This can be one of (message_id, inline_message_id) from a msg dict, @@ -270,7 +275,8 @@ class TelegramNotificationService: **You can use 'last' as message_id** to edit the message last sent in the chat_id. """ - message_id = inline_message_id = None + message_id: Any | None = None + inline_message_id: int | None = None if ATTR_MESSAGEID in msg_data: message_id = msg_data[ATTR_MESSAGEID] if ( @@ -283,7 +289,7 @@ class TelegramNotificationService: inline_message_id = msg_data["inline_message_id"] return message_id, inline_message_id - def _get_target_chat_ids(self, target): + def _get_target_chat_ids(self, target: Any) -> list[int]: """Validate chat_id targets or return default target (first). :param target: optional list of integers ([12234, -12345]) @@ -302,10 +308,10 @@ class TelegramNotificationService: ) return [default_user] - def _get_msg_kwargs(self, data): + def _get_msg_kwargs(self, data: dict[str, Any]) -> dict[str, Any]: """Get parameters in message data kwargs.""" - def _make_row_inline_keyboard(row_keyboard): + def _make_row_inline_keyboard(row_keyboard: Any) -> list[InlineKeyboardButton]: """Make a list of InlineKeyboardButtons. It can accept: @@ -350,7 +356,7 @@ class TelegramNotificationService: return buttons # Defaults - params = { + params: dict[str, Any] = { ATTR_PARSER: self.parse_mode, ATTR_DISABLE_NOTIF: False, ATTR_DISABLE_WEB_PREV: None, @@ -399,8 +405,14 @@ class TelegramNotificationService: return params async def _send_msg( - self, func_send, msg_error, message_tag, *args_msg, context=None, **kwargs_msg - ): + self, + func_send: Callable, + msg_error: str, + message_tag: str | None, + *args_msg: Any, + context: Context | None = None, + **kwargs_msg: Any, + ) -> Any: """Send one message.""" try: out = await func_send(*args_msg, **kwargs_msg) @@ -438,7 +450,13 @@ class TelegramNotificationService: return None return out - async def send_message(self, message="", target=None, context=None, **kwargs): + async def send_message( + self, + message: str = "", + target: Any = None, + context: Context | None = None, + **kwargs: dict[str, Any], + ) -> dict[int, int]: """Send a message to one or multiple pre-allowed chat IDs.""" title = kwargs.get(ATTR_TITLE) text = f"{title}\n{message}" if title else message @@ -465,12 +483,17 @@ class TelegramNotificationService: msg_ids[chat_id] = msg.id return msg_ids - async def delete_message(self, chat_id=None, context=None, **kwargs): + async def delete_message( + self, + chat_id: int | None = None, + context: Context | None = None, + **kwargs: dict[str, Any], + ) -> bool: """Delete a previously sent message.""" chat_id = self._get_target_chat_ids(chat_id)[0] message_id, _ = self._get_msg_ids(kwargs, chat_id) _LOGGER.debug("Delete message %s in chat ID %s", message_id, chat_id) - deleted = await self._send_msg( + deleted: bool = await self._send_msg( self.bot.delete_message, "Error deleting message", None, @@ -484,7 +507,13 @@ class TelegramNotificationService: self._last_message_id[chat_id] -= 1 return deleted - async def edit_message(self, type_edit, chat_id=None, context=None, **kwargs): + async def edit_message( + self, + type_edit: str, + chat_id: int | None = None, + context: Context | None = None, + **kwargs: dict[str, Any], + ) -> Any: """Edit a previously sent message.""" chat_id = self._get_target_chat_ids(chat_id)[0] message_id, inline_message_id = self._get_msg_ids(kwargs, chat_id) @@ -542,8 +571,13 @@ class TelegramNotificationService: ) async def answer_callback_query( - self, message, callback_query_id, show_alert=False, context=None, **kwargs - ): + self, + message: str | None, + callback_query_id: str, + show_alert: bool = False, + context: Context | None = None, + **kwargs: dict[str, Any], + ) -> None: """Answer a callback originated with a press in an inline keyboard.""" params = self._get_msg_kwargs(kwargs) _LOGGER.debug( @@ -564,16 +598,20 @@ class TelegramNotificationService: ) async def send_file( - self, file_type=SERVICE_SEND_PHOTO, target=None, context=None, **kwargs - ): + self, + file_type: str, + target: Any = None, + context: Context | None = None, + **kwargs: Any, + ) -> dict[int, int]: """Send a photo, sticker, video, or document.""" params = self._get_msg_kwargs(kwargs) file_content = await load_data( self.hass, url=kwargs.get(ATTR_URL), filepath=kwargs.get(ATTR_FILE), - username=kwargs.get(ATTR_USERNAME), - password=kwargs.get(ATTR_PASSWORD), + username=kwargs.get(ATTR_USERNAME, ""), + password=kwargs.get(ATTR_PASSWORD, ""), authentication=kwargs.get(ATTR_AUTHENTICATION), verify_ssl=( get_default_context() @@ -690,7 +728,12 @@ class TelegramNotificationService: return msg_ids - async def send_sticker(self, target=None, context=None, **kwargs) -> dict: + async def send_sticker( + self, + target: Any = None, + context: Context | None = None, + **kwargs: Any, + ) -> dict[int, int]: """Send a sticker from a telegram sticker pack.""" params = self._get_msg_kwargs(kwargs) stickerid = kwargs.get(ATTR_STICKER_ID) @@ -713,11 +756,16 @@ class TelegramNotificationService: ) msg_ids[chat_id] = msg.id return msg_ids - return await self.send_file(SERVICE_SEND_STICKER, target, **kwargs) + return await self.send_file(SERVICE_SEND_STICKER, target, context, **kwargs) async def send_location( - self, latitude, longitude, target=None, context=None, **kwargs - ): + self, + latitude: Any, + longitude: Any, + target: Any = None, + context: Context | None = None, + **kwargs: dict[str, Any], + ) -> dict[int, int]: """Send a location.""" latitude = float(latitude) longitude = float(longitude) @@ -745,14 +793,14 @@ class TelegramNotificationService: async def send_poll( self, - question, - options, - is_anonymous, - allows_multiple_answers, - target=None, - context=None, - **kwargs, - ): + question: str, + options: Sequence[str | InputPollOption], + is_anonymous: bool | None, + allows_multiple_answers: bool | None, + target: Any = None, + context: Context | None = None, + **kwargs: dict[str, Any], + ) -> dict[int, int]: """Send a poll.""" params = self._get_msg_kwargs(kwargs) openperiod = kwargs.get(ATTR_OPEN_PERIOD) @@ -778,7 +826,12 @@ class TelegramNotificationService: msg_ids[chat_id] = msg.id return msg_ids - async def leave_chat(self, chat_id=None, context=None, **kwargs): + async def leave_chat( + self, + chat_id: Any = None, + context: Context | None = None, + **kwargs: dict[str, Any], + ) -> Any: """Remove bot from chat.""" chat_id = self._get_target_chat_ids(chat_id)[0] _LOGGER.debug("Leave from chat ID %s", chat_id) @@ -792,7 +845,7 @@ class TelegramNotificationService: reaction: str, is_big: bool = False, context: Context | None = None, - **kwargs, + **kwargs: dict[str, Any], ) -> None: """Set the bot's reaction for a given message.""" chat_id = self._get_target_chat_ids(chat_id)[0] @@ -878,19 +931,19 @@ def initialize_bot(hass: HomeAssistant, p_config: MappingProxyType[str, Any]) -> async def load_data( hass: HomeAssistant, - url=None, - filepath=None, - username=None, - password=None, - authentication=None, - num_retries=5, - verify_ssl=None, -): + url: str | None, + filepath: str | None, + username: str, + password: str, + authentication: str | None, + verify_ssl: SSLContext, + num_retries: int = 5, +) -> io.BytesIO: """Load data into ByteIO/File container from a source.""" if url is not None: # Load data from URL params: dict[str, Any] = {} - headers = {} + headers: dict[str, str] = {} _validate_credentials_input(authentication, username, password) if authentication == HTTP_BEARER_AUTHENTICATION: headers = {"Authorization": f"Bearer {password}"} @@ -963,7 +1016,7 @@ def _validate_credentials_input( ) -> None: if ( authentication in (HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION) - and username is None + and not username ): raise ServiceValidationError( "Username is required.", @@ -979,7 +1032,7 @@ def _validate_credentials_input( HTTP_BEARER_AUTHENTICATION, HTTP_BEARER_AUTHENTICATION, ) - and password is None + and not password ): raise ServiceValidationError( "Password is required.", diff --git a/homeassistant/components/telegram_bot/polling.py b/homeassistant/components/telegram_bot/polling.py index f9e69080939..6c38a0e53b8 100644 --- a/homeassistant/components/telegram_bot/polling.py +++ b/homeassistant/components/telegram_bot/polling.py @@ -64,16 +64,18 @@ class PollBot(BaseTelegramBot): """Shutdown the app.""" await self.stop_polling() - async def start_polling(self, event=None): + async def start_polling(self) -> None: """Start the polling task.""" _LOGGER.debug("Starting polling") await self.application.initialize() - await self.application.updater.start_polling(error_callback=error_callback) + if self.application.updater: + await self.application.updater.start_polling(error_callback=error_callback) await self.application.start() - async def stop_polling(self, event=None): + async def stop_polling(self) -> None: """Stop the polling task.""" _LOGGER.debug("Stopping polling") - await self.application.updater.stop() + if self.application.updater: + await self.application.updater.stop() await self.application.stop() await self.application.shutdown() diff --git a/homeassistant/components/telegram_bot/webhooks.py b/homeassistant/components/telegram_bot/webhooks.py index 9218bcbcd67..0bfad34681a 100644 --- a/homeassistant/components/telegram_bot/webhooks.py +++ b/homeassistant/components/telegram_bot/webhooks.py @@ -6,11 +6,12 @@ import logging import secrets import string +from aiohttp.web_response import Response from telegram import Bot, Update from telegram.error import NetworkError, TelegramError -from telegram.ext import ApplicationBuilder, TypeHandler +from telegram.ext import Application, ApplicationBuilder, TypeHandler -from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http import HomeAssistantRequest, HomeAssistantView from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -87,7 +88,7 @@ class PushBot(BaseTelegramBot): """Shutdown the app.""" await self.stop_application() - async def _try_to_set_webhook(self): + async def _try_to_set_webhook(self) -> bool: _LOGGER.debug("Registering webhook URL: %s", self.webhook_url) retry_num = 0 while retry_num < 3: @@ -103,12 +104,12 @@ class PushBot(BaseTelegramBot): return False - async def start_application(self): + async def start_application(self) -> None: """Handle starting the Application object.""" await self.application.initialize() await self.application.start() - async def register_webhook(self): + async def register_webhook(self) -> bool: """Query telegram and register the URL for our webhook.""" current_status = await self.bot.get_webhook_info() # Some logging of Bot current status: @@ -123,13 +124,13 @@ class PushBot(BaseTelegramBot): return True - async def stop_application(self, event=None): + async def stop_application(self) -> None: """Handle gracefully stopping the Application object.""" await self.deregister_webhook() await self.application.stop() await self.application.shutdown() - async def deregister_webhook(self): + async def deregister_webhook(self) -> None: """Query telegram and deregister the URL for our webhook.""" _LOGGER.debug("Deregistering webhook URL") try: @@ -149,7 +150,7 @@ class PushBotView(HomeAssistantView): self, hass: HomeAssistant, bot: Bot, - application, + application: Application, trusted_networks: list[IPv4Network], secret_token: str, ) -> None: @@ -160,15 +161,16 @@ class PushBotView(HomeAssistantView): self.trusted_networks = trusted_networks self.secret_token = secret_token - async def post(self, request): + async def post(self, request: HomeAssistantRequest) -> Response | None: """Accept the POST from telegram.""" - real_ip = ip_address(request.remote) - if not any(real_ip in net for net in self.trusted_networks): - _LOGGER.warning("Access denied from %s", real_ip) + if not request.remote or not any( + ip_address(request.remote) in net for net in self.trusted_networks + ): + _LOGGER.warning("Access denied from %s", request.remote) return self.json_message("Access denied", HTTPStatus.UNAUTHORIZED) secret_token_header = request.headers.get("X-Telegram-Bot-Api-Secret-Token") if secret_token_header is None or self.secret_token != secret_token_header: - _LOGGER.warning("Invalid secret token from %s", real_ip) + _LOGGER.warning("Invalid secret token from %s", request.remote) return self.json_message("Access denied", HTTPStatus.UNAUTHORIZED) try: diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 1e1a27e26c6..86769a0d22a 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -158,8 +158,6 @@ CONFIG_SECTION_SCHEMA = vol.All( ), ensure_domains_do_not_have_trigger_or_action( DOMAIN_BUTTON, - DOMAIN_FAN, - DOMAIN_VACUUM, ), ) diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 4837ded9029..f7b0b57cf27 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -15,6 +15,7 @@ from homeassistant.components.fan import ( ATTR_PRESET_MODE, DIRECTION_FORWARD, DIRECTION_REVERSE, + DOMAIN as FAN_DOMAIN, ENTITY_ID_FORMAT, FanEntity, FanEntityFeature, @@ -38,6 +39,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback 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, @@ -46,6 +48,7 @@ from .template_entity import ( make_template_entity_common_modern_schema, rewrite_common_legacy_to_modern_conf, ) +from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) @@ -193,6 +196,13 @@ async def async_setup_platform( ) return + if "coordinator" in discovery_info: + async_add_entities( + TriggerFanEntity(hass, discovery_info["coordinator"], config) + for config in discovery_info["entities"] + ) + return + _async_create_template_tracking_entities( async_add_entities, hass, @@ -228,7 +238,11 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): self._preset_modes: list[str] | None = config.get(CONF_PRESET_MODES) self._attr_assumed_state = self._template is None - def _register_scripts( + self._attr_supported_features |= ( + FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON + ) + + def _iterate_scripts( self, config: dict[str, Any] ) -> Generator[tuple[str, Sequence[dict[str, Any]], FanEntityFeature | int]]: for action_id, supported_feature in ( @@ -492,10 +506,7 @@ class TemplateFan(TemplateEntity, AbstractTemplateFan): if TYPE_CHECKING: assert name is not None - self._attr_supported_features |= ( - FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON - ) - 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) @@ -551,3 +562,67 @@ class TemplateFan(TemplateEntity, AbstractTemplateFan): none_on_template_error=True, ) super()._async_setup_templates() + + +class TriggerFanEntity(TriggerEntity, AbstractTemplateFan): + """Fan entity based on trigger data.""" + + domain = FAN_DOMAIN + + def __init__( + self, + hass: HomeAssistant, + coordinator: TriggerUpdateCoordinator, + config: ConfigType, + ) -> None: + """Initialize the entity.""" + TriggerEntity.__init__(self, hass, coordinator, config) + AbstractTemplateFan.__init__(self, config) + + self._attr_name = name = self._rendered.get(CONF_NAME, DEFAULT_NAME) + + 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 + + for key in ( + CONF_STATE, + CONF_PRESET_MODE, + CONF_PERCENTAGE, + CONF_OSCILLATING, + CONF_DIRECTION, + ): + if isinstance(config.get(key), template.Template): + self._to_render_simple.append(key) + self._parse_result.add(key) + + @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_PRESET_MODE, self._update_preset_mode), + (CONF_PERCENTAGE, self._update_percentage), + (CONF_OSCILLATING, self._update_oscillating), + (CONF_DIRECTION, self._update_direction), + ): + if (rendered := self._rendered.get(key)) is not None: + updater(rendered) + write_ha_state = True + + if len(self._rendered) > 0: + # In case any non optimistic template + write_ha_state = True + + if write_ha_state: + self.async_set_context(self.coordinator.data["context"]) + self.async_write_ha_state() diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index 79e00e7e1c0..1fb5b89ead2 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -39,6 +39,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback 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, @@ -48,6 +49,7 @@ from .template_entity import ( make_template_entity_common_modern_attributes_schema, rewrite_common_legacy_to_modern_conf, ) +from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) @@ -187,6 +189,13 @@ async def async_setup_platform( ) return + if "coordinator" in discovery_info: + async_add_entities( + TriggerVacuumEntity(hass, discovery_info["coordinator"], config) + for config in discovery_info["entities"] + ) + return + _async_create_template_tracking_entities( async_add_entities, hass, @@ -213,7 +222,14 @@ class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): # List of valid fan speeds self._attr_fan_speed_list = config[CONF_FAN_SPEED_LIST] - def _register_scripts( + self._attr_supported_features = ( + VacuumEntityFeature.START | VacuumEntityFeature.STATE + ) + + if self._battery_level_template: + self._attr_supported_features |= VacuumEntityFeature.BATTERY + + def _iterate_scripts( self, config: dict[str, Any] ) -> Generator[tuple[str, Sequence[dict[str, Any]], VacuumEntityFeature | int]]: for action_id, supported_feature in ( @@ -356,18 +372,12 @@ class TemplateVacuum(TemplateEntity, AbstractTemplateVacuum): if TYPE_CHECKING: assert name is not None - self._attr_supported_features = ( - VacuumEntityFeature.START | VacuumEntityFeature.STATE - ) - 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) self._attr_supported_features |= supported_feature - if self._battery_level_template: - self._attr_supported_features |= VacuumEntityFeature.BATTERY - @callback def _async_setup_templates(self) -> None: """Set up templates.""" @@ -403,3 +413,59 @@ class TemplateVacuum(TemplateEntity, AbstractTemplateVacuum): return self._handle_state(result) + + +class TriggerVacuumEntity(TriggerEntity, AbstractTemplateVacuum): + """Vacuum entity based on trigger data.""" + + domain = VACUUM_DOMAIN + + def __init__( + self, + hass: HomeAssistant, + coordinator: TriggerUpdateCoordinator, + config: ConfigType, + ) -> None: + """Initialize the entity.""" + TriggerEntity.__init__(self, hass, coordinator, config) + AbstractTemplateVacuum.__init__(self, config) + + self._attr_name = name = self._rendered.get(CONF_NAME, DEFAULT_NAME) + + 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 + + for key in (CONF_STATE, CONF_FAN_SPEED, CONF_BATTERY_LEVEL): + if isinstance(config.get(key), template.Template): + self._to_render_simple.append(key) + self._parse_result.add(key) + + @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_FAN_SPEED, self._update_fan_speed), + (CONF_BATTERY_LEVEL, self._update_battery_level), + ): + if (rendered := self._rendered.get(key)) is not None: + updater(rendered) + write_ha_state = True + + if len(self._rendered) > 0: + # In case any non optimistic template + write_ha_state = True + + if write_ha_state: + self.async_set_context(self.coordinator.data["context"]) + self.async_write_ha_state() diff --git a/homeassistant/components/vegehub/__init__.py b/homeassistant/components/vegehub/__init__.py new file mode 100644 index 00000000000..1957ed9295b --- /dev/null +++ b/homeassistant/components/vegehub/__init__.py @@ -0,0 +1,103 @@ +"""The Vegetronix VegeHub integration.""" + +from collections.abc import Awaitable, Callable +from http import HTTPStatus +from typing import Any + +from aiohttp.hdrs import METH_POST +from aiohttp.web import Request, Response +from vegehub import VegeHub + +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.webhook import ( + async_register as webhook_register, + async_unregister as webhook_unregister, +) +from homeassistant.const import ( + CONF_DEVICE, + CONF_IP_ADDRESS, + CONF_MAC, + CONF_WEBHOOK_ID, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.core import HomeAssistant + +from .const import DOMAIN, NAME, PLATFORMS +from .coordinator import VegeHubConfigEntry, VegeHubCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: VegeHubConfigEntry) -> bool: + """Set up VegeHub from a config entry.""" + + device_mac = entry.data[CONF_MAC] + + assert entry.unique_id + + vegehub = VegeHub( + entry.data[CONF_IP_ADDRESS], + device_mac, + entry.unique_id, + info=entry.data[CONF_DEVICE], + ) + + # Initialize runtime data + entry.runtime_data = VegeHubCoordinator( + hass=hass, config_entry=entry, vegehub=vegehub + ) + + async def unregister_webhook(_: Any) -> None: + webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) + + async def register_webhook() -> None: + webhook_name = f"{NAME} {device_mac}" + + webhook_register( + hass, + DOMAIN, + webhook_name, + entry.data[CONF_WEBHOOK_ID], + get_webhook_handler(device_mac, entry.entry_id, entry.runtime_data), + allowed_methods=[METH_POST], + ) + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unregister_webhook) + ) + + # Now add in all the entities for this device. + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + await register_webhook() + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: VegeHubConfigEntry) -> bool: + """Unload a VegeHub config entry.""" + webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) + + # Unload platforms + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +def get_webhook_handler( + device_mac: str, entry_id: str, coordinator: VegeHubCoordinator +) -> Callable[[HomeAssistant, str, Request], Awaitable[Response | None]]: + """Return webhook handler.""" + + async def async_webhook_handler( + hass: HomeAssistant, webhook_id: str, request: Request + ) -> Response | None: + # Handle http post calls to the path. + if not request.body_exists: + return HomeAssistantView.json( + result="No Body", status_code=HTTPStatus.BAD_REQUEST + ) + data = await request.json() + + if coordinator: + await coordinator.update_from_webhook(data) + + return HomeAssistantView.json(result="OK", status_code=HTTPStatus.OK) + + return async_webhook_handler diff --git a/homeassistant/components/vegehub/config_flow.py b/homeassistant/components/vegehub/config_flow.py new file mode 100644 index 00000000000..348457c99e9 --- /dev/null +++ b/homeassistant/components/vegehub/config_flow.py @@ -0,0 +1,168 @@ +"""Config flow for the VegeHub integration.""" + +import logging +from typing import Any + +from vegehub import VegeHub +import voluptuous as vol + +from homeassistant.components.webhook import ( + async_generate_id as webhook_generate_id, + async_generate_url as webhook_generate_url, +) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import ( + CONF_DEVICE, + CONF_HOST, + CONF_IP_ADDRESS, + CONF_MAC, + CONF_WEBHOOK_ID, +) +from homeassistant.helpers.service_info import zeroconf +from homeassistant.util.network import is_ip_address + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class VegeHubConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for VegeHub integration.""" + + _hub: VegeHub + _hostname: str + webhook_id: str + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initiated by the user.""" + errors: dict[str, str] = {} + + if user_input is not None: + if not is_ip_address(user_input[CONF_IP_ADDRESS]): + # User-supplied IP address is invalid. + errors["base"] = "invalid_ip" + + if not errors: + self._hub = VegeHub(user_input[CONF_IP_ADDRESS]) + self._hostname = self._hub.ip_address + errors = await self._setup_device() + if not errors: + # Proceed to create the config entry + return await self._create_entry() + + # Show the form to allow the user to manually enter the IP address + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_IP_ADDRESS): str, + } + ), + errors=errors, + ) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + + # Extract the IP address from the zeroconf discovery info + device_ip = discovery_info.host + + self._async_abort_entries_match({CONF_IP_ADDRESS: device_ip}) + + self._hostname = discovery_info.hostname.removesuffix(".local.") + config_url = f"http://{discovery_info.hostname[:-1]}:{discovery_info.port}" + + # Create a VegeHub object to interact with the device + self._hub = VegeHub(device_ip) + + try: + await self._hub.retrieve_mac_address(retries=2) + except ConnectionError: + return self.async_abort(reason="cannot_connect") + except TimeoutError: + return self.async_abort(reason="timeout_connect") + + if not self._hub.mac_address: + return self.async_abort(reason="cannot_connect") + + # Check if this device already exists + await self.async_set_unique_id(self._hub.mac_address) + self._abort_if_unique_id_configured( + updates={CONF_IP_ADDRESS: device_ip, CONF_HOST: self._hostname} + ) + + # Add title and configuration URL to the context so that the device discovery + # tile has the correct title, and a "Visit Device" link available. + self.context.update( + { + "title_placeholders": {"host": self._hostname + " (" + device_ip + ")"}, + "configuration_url": (config_url), + } + ) + + # If the device is new, allow the user to continue setup + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle user confirmation for a discovered device.""" + errors: dict[str, str] = {} + if user_input is not None: + errors = await self._setup_device() + if not errors: + return await self._create_entry() + + # Show the confirmation form + self._set_confirm_only() + return self.async_show_form(step_id="zeroconf_confirm", errors=errors) + + async def _setup_device(self) -> dict[str, str]: + """Set up the VegeHub device.""" + errors: dict[str, str] = {} + self.webhook_id = webhook_generate_id() + webhook_url = webhook_generate_url( + self.hass, + self.webhook_id, + allow_external=False, + allow_ip=True, + ) + + # Send the webhook address to the hub as its server target. + # This step can happen in the init, because that gets executed + # every time Home Assistant starts up, and this step should + # only happen in the initial setup of the VegeHub. + try: + await self._hub.setup("", webhook_url, retries=1) + except ConnectionError: + errors["base"] = "cannot_connect" + except TimeoutError: + errors["base"] = "timeout_connect" + + if not self._hub.mac_address: + errors["base"] = "cannot_connect" + + return errors + + async def _create_entry(self) -> ConfigFlowResult: + """Create a config entry for the device.""" + + # Check if this device already exists + await self.async_set_unique_id(self._hub.mac_address) + self._abort_if_unique_id_configured() + + # Save Hub info to be used later when defining the VegeHub object + info_data = { + CONF_IP_ADDRESS: self._hub.ip_address, + CONF_HOST: self._hostname, + CONF_MAC: self._hub.mac_address, + CONF_DEVICE: self._hub.info, + CONF_WEBHOOK_ID: self.webhook_id, + } + + # Create the config entry for the new device + return self.async_create_entry(title=self._hostname, data=info_data) diff --git a/homeassistant/components/vegehub/const.py b/homeassistant/components/vegehub/const.py new file mode 100644 index 00000000000..960ea4d3a91 --- /dev/null +++ b/homeassistant/components/vegehub/const.py @@ -0,0 +1,9 @@ +"""Constants for the Vegetronix VegeHub integration.""" + +from homeassistant.const import Platform + +DOMAIN = "vegehub" +NAME = "VegeHub" +PLATFORMS = [Platform.SENSOR] +MANUFACTURER = "vegetronix" +MODEL = "VegeHub" diff --git a/homeassistant/components/vegehub/coordinator.py b/homeassistant/components/vegehub/coordinator.py new file mode 100644 index 00000000000..43fb1c40274 --- /dev/null +++ b/homeassistant/components/vegehub/coordinator.py @@ -0,0 +1,52 @@ +"""Coordinator for the Vegetronix VegeHub.""" + +from __future__ import annotations + +import logging +from typing import Any + +from vegehub import VegeHub, update_data_to_ha_dict + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +type VegeHubConfigEntry = ConfigEntry[VegeHub] + + +class VegeHubCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """The DataUpdateCoordinator for VegeHub.""" + + config_entry: VegeHubConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: VegeHubConfigEntry, vegehub: VegeHub + ) -> None: + """Initialize VegeHub data coordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"{config_entry.unique_id} DataUpdateCoordinator", + config_entry=config_entry, + ) + self.vegehub = vegehub + self.device_id = config_entry.unique_id + assert self.device_id is not None, "Config entry is missing unique_id" + + async def update_from_webhook(self, data: dict) -> None: + """Process and update data from webhook.""" + sensor_data = update_data_to_ha_dict( + data, + self.vegehub.num_sensors or 0, + self.vegehub.num_actuators or 0, + self.vegehub.is_ac or False, + ) + if self.data: + existing_data: dict = self.data + existing_data.update(sensor_data) + if sensor_data: + self.async_set_updated_data(existing_data) + else: + self.async_set_updated_data(sensor_data) diff --git a/homeassistant/components/vegehub/entity.py b/homeassistant/components/vegehub/entity.py new file mode 100644 index 00000000000..a42c1f62957 --- /dev/null +++ b/homeassistant/components/vegehub/entity.py @@ -0,0 +1,28 @@ +"""Base entity for VegeHub.""" + +from homeassistant.const import CONF_HOST, CONF_MAC +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import MANUFACTURER, MODEL +from .coordinator import VegeHubCoordinator + + +class VegeHubEntity(CoordinatorEntity[VegeHubCoordinator]): + """Defines a base VegeHub entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: VegeHubCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + config_entry = coordinator.config_entry + self._mac_address = config_entry.data[CONF_MAC] + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, self._mac_address)}, + name=config_entry.data[CONF_HOST], + manufacturer=MANUFACTURER, + model=MODEL, + sw_version=coordinator.vegehub.sw_version, + configuration_url=coordinator.vegehub.url, + ) diff --git a/homeassistant/components/vegehub/manifest.json b/homeassistant/components/vegehub/manifest.json new file mode 100644 index 00000000000..9ccaabb6b4b --- /dev/null +++ b/homeassistant/components/vegehub/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "vegehub", + "name": "Vegetronix VegeHub", + "codeowners": ["@ghowevege"], + "config_flow": true, + "dependencies": ["http", "webhook"], + "documentation": "https://www.home-assistant.io/integrations/vegehub", + "iot_class": "local_push", + "quality_scale": "bronze", + "requirements": ["vegehub==0.1.24"], + "zeroconf": ["_vege._tcp.local."] +} diff --git a/homeassistant/components/vegehub/quality_scale.yaml b/homeassistant/components/vegehub/quality_scale.yaml new file mode 100644 index 00000000000..51c74033092 --- /dev/null +++ b/homeassistant/components/vegehub/quality_scale.yaml @@ -0,0 +1,84 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: + status: exempt + comment: | + This integration does not poll. + 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 do not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: + status: exempt + comment: | + It is possible for this device to be offline at setup time and still be functioning correctly. It can not be tested at setup. + unique-config-entry: done + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options to configure + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: todo + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + test-coverage: todo + # Gold + devices: done + diagnostics: todo + 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 + dynamic-devices: + status: exempt + comment: | + This integration has a fixed single device. + entity-category: todo + entity-device-class: done + entity-disabled-by-default: done + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: + status: exempt + comment: | + This integration has a fixed single device. + + # Platinum + async-dependency: done + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/vegehub/sensor.py b/homeassistant/components/vegehub/sensor.py new file mode 100644 index 00000000000..1520a56dac4 --- /dev/null +++ b/homeassistant/components/vegehub/sensor.py @@ -0,0 +1,94 @@ +"""Sensor configuration for VegeHub integration.""" + +from itertools import count + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import UnitOfElectricPotential +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import VegeHubConfigEntry, VegeHubCoordinator +from .entity import VegeHubEntity + +SENSOR_TYPES: dict[str, SensorEntityDescription] = { + "analog_sensor": SensorEntityDescription( + key="analog_sensor", + translation_key="analog_sensor", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=2, + ), + "battery_volts": SensorEntityDescription( + key="battery_volts", + translation_key="battery_volts", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=1, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: VegeHubConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Vegetronix sensors from a config entry.""" + sensors: list[VegeHubSensor] = [] + coordinator = config_entry.runtime_data + + sensor_index = count(0) + + # Add each analog sensor input + for _i in range(coordinator.vegehub.num_sensors): + sensor = VegeHubSensor( + index=next(sensor_index), + coordinator=coordinator, + description=SENSOR_TYPES["analog_sensor"], + ) + sensors.append(sensor) + + # Add the battery sensor + if not coordinator.vegehub.is_ac: + sensors.append( + VegeHubSensor( + index=next(sensor_index), + coordinator=coordinator, + description=SENSOR_TYPES["battery_volts"], + ) + ) + + async_add_entities(sensors) + + +class VegeHubSensor(VegeHubEntity, SensorEntity): + """Class for VegeHub Analog Sensors.""" + + def __init__( + self, + index: int, + coordinator: VegeHubCoordinator, + description: SensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + # Set data key for pulling data from the coordinator + if description.key == "battery_volts": + self.data_key = "battery" + else: + self.data_key = f"analog_{index}" + self._attr_translation_placeholders = {"index": str(index + 1)} + self._attr_unique_id = f"{self._mac_address}_{self.data_key}" + self._attr_available = False + + @property + def native_value(self) -> float | None: + """Return the sensor's current value.""" + if self.coordinator.data is None: + return None + return self.coordinator.data.get(self.data_key) diff --git a/homeassistant/components/vegehub/strings.json b/homeassistant/components/vegehub/strings.json new file mode 100644 index 00000000000..aa9b3aad227 --- /dev/null +++ b/homeassistant/components/vegehub/strings.json @@ -0,0 +1,44 @@ +{ + "title": "VegeHub", + "config": { + "flow_title": "{host}", + "step": { + "user": { + "title": "Set up VegeHub", + "description": "Do you want to set up this VegeHub?", + "data": { + "ip_address": "[%key:common::config_flow::data::ip%]" + }, + "data_description": { + "ip_address": "IP address of target VegeHub" + } + }, + "zeroconf_confirm": { + "title": "[%key:component::vegehub::config::step::user::title%]", + "description": "[%key:component::vegehub::config::step::user::description%]" + } + }, + "error": { + "cannot_connect": "Failed to connect. Ensure VegeHub is awake, and try again.", + "timeout_connect": "Timeout establishing connection. Ensure VegeHub is awake, and try again.", + "invalid_ip": "Invalid IPv4 address." + }, + "abort": { + "cannot_connect": "Failed to connect to the device. Please try again.", + "timeout_connect": "Timed out connecting. Ensure VegeHub is awake, and try again.", + "already_in_progress": "Device already detected. Check discovered devices.", + "already_configured": "Device is already configured.", + "unknown_error": "An unknown error has occurred." + } + }, + "entity": { + "sensor": { + "analog_sensor": { + "name": "Input {index}" + }, + "battery_volts": { + "name": "Battery voltage" + } + } + } +} diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 4908298847b..2ba35d1b1ad 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -100,6 +100,12 @@ "pid": "8B34", "description": "*bv 2010/10*", "known_devices": ["Bitron Video AV2010/10"] + }, + { + "vid": "10C4", + "pid": "EA60", + "description": "*sonoff*max*", + "known_devices": ["SONOFF Dongle Max MG24"] } ], "zeroconf": [ diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index e164bc09929..97e7929d317 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -48,6 +48,7 @@ FLOWS = { "airzone_cloud", "alarmdecoder", "alexa_devices", + "altruist", "amberelectric", "ambient_network", "ambient_station", @@ -313,7 +314,6 @@ FLOWS = { "izone", "jellyfin", "jewish_calendar", - "juicenet", "justnimbus", "jvc_projector", "kaleidescape", @@ -481,6 +481,7 @@ FLOWS = { "picnic", "ping", "plaato", + "playstation_network", "plex", "plugwise", "plum_lightpad", @@ -682,6 +683,7 @@ FLOWS = { "uptimerobot", "v2c", "vallox", + "vegehub", "velbus", "velux", "venstar", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1202d7d51ec..bd88338c4b9 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": { @@ -3175,12 +3181,6 @@ "config_flow": false, "iot_class": "cloud_push" }, - "juicenet": { - "name": "JuiceNet", - "integration_type": "hub", - "config_flow": true, - "iot_class": "cloud_polling" - }, "justnimbus": { "name": "JustNimbus", "integration_type": "hub", @@ -6229,6 +6229,12 @@ "config_flow": true, "iot_class": "local_push", "name": "Sony Songpal" + }, + "playstation_network": { + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "PlayStation Network" } } }, @@ -7117,6 +7123,11 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "vegehub": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "velbus": { "name": "Velbus", "integration_type": "hub", @@ -7916,6 +7927,7 @@ "trend", "uptime", "utility_meter", + "vegehub", "version", "waze_travel_time", "workday", diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index 8aea15df283..18623926ce2 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -137,6 +137,12 @@ USB = [ "pid": "8B34", "vid": "10C4", }, + { + "description": "*sonoff*max*", + "domain": "zha", + "pid": "EA60", + "vid": "10C4", + }, { "domain": "zwave_js", "pid": "0200", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index e675a0bb237..3af4b8caa8d 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -342,6 +342,11 @@ ZEROCONF = { "domain": "apple_tv", }, ], + "_altruist._tcp.local.": [ + { + "domain": "altruist", + }, + ], "_amzn-alexa._tcp.local.": [ { "domain": "roomba", @@ -910,6 +915,11 @@ ZEROCONF = { "name": "uzg-01*", }, ], + "_vege._tcp.local.": [ + { + "domain": "vegehub", + }, + ], "_viziocast._tcp.local.": [ { "domain": "vizio", diff --git a/homeassistant/helpers/system_info.py b/homeassistant/helpers/system_info.py index 30b7616319d..1baec4df052 100644 --- a/homeassistant/helpers/system_info.py +++ b/homeassistant/helpers/system_info.py @@ -21,6 +21,7 @@ from .singleton import singleton _LOGGER = logging.getLogger(__name__) _DATA_MAC_VER = "system_info_mac_ver" +_DATA_CONTAINER_ARCH = "system_info_container_arch" @singleton(_DATA_MAC_VER) @@ -29,6 +30,22 @@ async def async_get_mac_ver(hass: HomeAssistant) -> str: return (await hass.async_add_executor_job(platform.mac_ver))[0] +@singleton(_DATA_CONTAINER_ARCH) +async def async_get_container_arch(hass: HomeAssistant) -> str: + """Return the container architecture.""" + + def _read_arch_file() -> str: + """Read the architecture from /etc/apk/arch.""" + with open("/etc/apk/arch", encoding="utf-8") as arch_file: + return arch_file.read().strip() + + try: + raw_arch = await hass.async_add_executor_job(_read_arch_file) + except FileNotFoundError: + return "unknown" + return {"x86": "i386", "x86_64": "amd64"}.get(raw_arch, raw_arch) + + # Cache the result of getuser() because it can call getpwuid() which # can do blocking I/O to look up the username in /etc/passwd. cached_get_user = cache(getuser) @@ -79,6 +96,7 @@ async def async_get_system_info(hass: HomeAssistant) -> dict[str, Any]: if info_object["docker"]: if info_object["user"] == "root" and is_official_image(): info_object["installation_type"] = "Home Assistant Container" + info_object["container_arch"] = await async_get_container_arch(hass) else: info_object["installation_type"] = "Unsupported Third Party Container" diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index acf78f70380..34b19c07f83 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -1734,6 +1734,14 @@ def label_name(hass: HomeAssistant, lookup_value: str) -> str | None: return None +def label_description(hass: HomeAssistant, lookup_value: str) -> str | None: + """Get the label description from a label ID.""" + label_reg = label_registry.async_get(hass) + if label := label_reg.async_get_label(lookup_value): + return label.description + return None + + def _label_id_or_name(hass: HomeAssistant, label_id_or_name: str) -> str | None: """Get the label ID from a label name or ID.""" # If label_name returns a value, we know the input was an ID, otherwise we @@ -3314,6 +3322,9 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["label_name"] = hassfunction(label_name) self.filters["label_name"] = self.globals["label_name"] + self.globals["label_description"] = hassfunction(label_description) + self.filters["label_description"] = self.globals["label_description"] + self.globals["label_areas"] = hassfunction(label_areas) self.filters["label_areas"] = self.globals["label_areas"] diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 15ea1fd0104..66d1560ac70 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -375,7 +375,7 @@ async def async_validate_trigger_config( if hasattr(platform, "async_get_triggers"): trigger_descriptors = await platform.async_get_triggers(hass) trigger_key: str = conf[CONF_PLATFORM] - if not (trigger := trigger_descriptors[trigger_key]): + if not (trigger := trigger_descriptors.get(trigger_key)): raise vol.Invalid(f"Invalid trigger '{trigger_key}' specified") conf = await trigger.async_validate_trigger_config(hass, conf) elif hasattr(platform, "async_validate_trigger_config"): diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e47c5f7d66c..048ec41592c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -3,7 +3,6 @@ aiodhcpwatcher==1.2.0 aiodiscover==2.7.0 aiodns==3.5.0 -aiofiles==24.1.0 aiohasupervisor==0.3.1 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.3.0 @@ -40,7 +39,7 @@ hass-nabucasa==0.103.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250531.3 -home-assistant-intents==2025.6.10 +home-assistant-intents==2025.6.23 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.6 @@ -201,6 +200,14 @@ tenacity!=8.4.0 # TypeError: 'Timeout' object does not support the context manager protocol async-timeout==4.0.3 +# aiofiles keeps getting downgraded by custom components +# causing newer methods to not be available and breaking +# some integrations at startup +# https://github.com/home-assistant/core/issues/127529 +# https://github.com/home-assistant/core/issues/122508 +# https://github.com/home-assistant/core/issues/118004 +aiofiles>=24.1.0 + # multidict < 6.4.0 has memory leaks # https://github.com/aio-libs/multidict/issues/1134 # https://github.com/aio-libs/multidict/issues/1131 diff --git a/mypy.ini b/mypy.ini index 1fdab75663e..a6b673be03b 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 @@ -4778,6 +4788,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.telegram_bot.*] +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.text.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 0760cd33821..32a053527f6 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -2795,6 +2795,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { function_name="state", return_type=["str", None], ), + TypeHintMatch( + function_name="activity", + return_type=["VacuumActivity", None], + ), TypeHintMatch( function_name="battery_level", return_type=["int", None], diff --git a/pyproject.toml b/pyproject.toml index f7ac7476d1b..995308bbf0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,6 @@ classifiers = [ requires-python = ">=3.13.2" dependencies = [ "aiodns==3.5.0", - "aiofiles==24.1.0", # Integrations may depend on hassio integration without listing it to # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 diff --git a/requirements.txt b/requirements.txt index 39ec6dd87dd..687e5584355 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,6 @@ # Home Assistant Core aiodns==3.5.0 -aiofiles==24.1.0 aiohasupervisor==0.3.1 aiohttp==3.12.13 aiohttp_cors==0.8.1 diff --git a/requirements_all.txt b/requirements_all.txt index 0286243cb08..131c1c14b06 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -24,6 +24,9 @@ HATasmota==0.10.0 # homeassistant.components.mastodon Mastodon.py==2.0.1 +# homeassistant.components.playstation_network +PSNAWP==3.0.0 + # homeassistant.components.doods # homeassistant.components.generic # homeassistant.components.image_upload @@ -244,7 +247,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==33.0.0 +aioesphomeapi==33.1.1 # homeassistant.components.flo aioflo==2021.11.0 @@ -405,7 +408,7 @@ aiosyncthing==0.5.1 aiotankerkoenig==0.4.2 # homeassistant.components.tedee -aiotedee==0.2.23 +aiotedee==0.2.25 # homeassistant.components.tractive aiotractive==0.6.0 @@ -461,6 +464,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 @@ -1168,7 +1174,7 @@ holidays==0.75 home-assistant-frontend==20250531.3 # homeassistant.components.conversation -home-assistant-intents==2025.6.10 +home-assistant-intents==2025.6.23 # homeassistant.components.homematicip_cloud homematicip==2.0.6 @@ -1334,7 +1340,7 @@ lektricowifi==0.1 letpot==0.4.0 # homeassistant.components.foscam -libpyfoscam==1.2.2 +libpyfoscamcgi==0.0.6 # homeassistant.components.vivotek libpyvivotek==0.4.0 @@ -1959,7 +1965,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==2.0.1 +pyenphase==2.1.0 # homeassistant.components.envisalink pyenvisalink==4.7 @@ -2278,6 +2284,9 @@ pyrail==0.4.1 # homeassistant.components.rainbird pyrainbird==6.0.1 +# homeassistant.components.playstation_network +pyrate-limiter==3.7.0 + # homeassistant.components.recswitch pyrecswitch==1.0.2 @@ -2435,7 +2444,7 @@ python-google-drive-api==0.1.0 python-homeassistant-analytics==0.9.0 # homeassistant.components.homewizard -python-homewizard-energy==9.1.1 +python-homewizard-energy==9.2.0 # homeassistant.components.hp_ilo python-hpilo==4.4.3 @@ -2446,9 +2455,6 @@ python-izone==1.2.9 # homeassistant.components.joaoapps_join python-join-api==0.0.9 -# homeassistant.components.juicenet -python-juicenet==1.1.0 - # homeassistant.components.tplink python-kasa[speedups]==0.10.2 @@ -3006,7 +3012,7 @@ unifi_ap==0.0.2 unifiled==0.11 # homeassistant.components.homeassistant_hardware -universal-silabs-flasher==0.0.30 +universal-silabs-flasher==0.0.31 # homeassistant.components.upb upb-lib==0.6.1 @@ -3028,6 +3034,9 @@ vacuum-map-parser-roborock==0.1.4 # homeassistant.components.vallox vallox-websocket-api==5.3.0 +# homeassistant.components.vegehub +vegehub==0.1.24 + # homeassistant.components.rdw vehicle==2.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 26f91948e08..41ed2c454c7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -24,6 +24,9 @@ HATasmota==0.10.0 # homeassistant.components.mastodon Mastodon.py==2.0.1 +# homeassistant.components.playstation_network +PSNAWP==3.0.0 + # homeassistant.components.doods # homeassistant.components.generic # homeassistant.components.image_upload @@ -232,7 +235,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==33.0.0 +aioesphomeapi==33.1.1 # homeassistant.components.flo aioflo==2021.11.0 @@ -387,7 +390,7 @@ aiosyncthing==0.5.1 aiotankerkoenig==0.4.2 # homeassistant.components.tedee -aiotedee==0.2.23 +aiotedee==0.2.25 # homeassistant.components.tractive aiotractive==0.6.0 @@ -440,6 +443,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 @@ -1014,7 +1020,7 @@ holidays==0.75 home-assistant-frontend==20250531.3 # homeassistant.components.conversation -home-assistant-intents==2025.6.10 +home-assistant-intents==2025.6.23 # homeassistant.components.homematicip_cloud homematicip==2.0.6 @@ -1150,7 +1156,7 @@ lektricowifi==0.1 letpot==0.4.0 # homeassistant.components.foscam -libpyfoscam==1.2.2 +libpyfoscamcgi==0.0.6 # homeassistant.components.mikrotik librouteros==3.2.0 @@ -1631,7 +1637,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==2.0.1 +pyenphase==2.1.0 # homeassistant.components.everlights pyeverlights==0.1.0 @@ -1896,6 +1902,9 @@ pyrail==0.4.1 # homeassistant.components.rainbird pyrainbird==6.0.1 +# homeassistant.components.playstation_network +pyrate-limiter==3.7.0 + # homeassistant.components.risco pyrisco==0.6.7 @@ -2011,14 +2020,11 @@ python-google-drive-api==0.1.0 python-homeassistant-analytics==0.9.0 # homeassistant.components.homewizard -python-homewizard-energy==9.1.1 +python-homewizard-energy==9.2.0 # homeassistant.components.izone python-izone==1.2.9 -# homeassistant.components.juicenet -python-juicenet==1.1.0 - # homeassistant.components.tplink python-kasa[speedups]==0.10.2 @@ -2471,7 +2477,7 @@ ultraheat-api==0.5.7 unifi-discovery==1.2.0 # homeassistant.components.homeassistant_hardware -universal-silabs-flasher==0.0.30 +universal-silabs-flasher==0.0.31 # homeassistant.components.upb upb-lib==0.6.1 @@ -2493,6 +2499,9 @@ vacuum-map-parser-roborock==0.1.4 # homeassistant.components.vallox vallox-websocket-api==5.3.0 +# homeassistant.components.vegehub +vegehub==0.1.24 + # homeassistant.components.rdw vehicle==2.2.2 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 2e3ecccf5d2..005d97175a7 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -226,6 +226,14 @@ tenacity!=8.4.0 # TypeError: 'Timeout' object does not support the context manager protocol async-timeout==4.0.3 +# aiofiles keeps getting downgraded by custom components +# causing newer methods to not be available and breaking +# some integrations at startup +# https://github.com/home-assistant/core/issues/127529 +# https://github.com/home-assistant/core/issues/122508 +# https://github.com/home-assistant/core/issues/118004 +aiofiles>=24.1.0 + # multidict < 6.4.0 has memory leaks # https://github.com/aio-libs/multidict/issues/1134 # https://github.com/aio-libs/multidict/issues/1131 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 72bd1ab3e7d..afd58539853 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -32,7 +32,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.7.1,source=/uv,target=/bin/uv \ go2rtc-client==0.2.1 \ ha-ffmpeg==3.2.2 \ hassil==2.2.3 \ - home-assistant-intents==2025.6.10 \ + home-assistant-intents==2025.6.23 \ mutagen==1.47.0 \ pymicro-vad==1.0.1 \ pyspeex-noise==1.0.2 diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 52e5f935117..73505e805bc 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1607,7 +1607,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "konnected", "kostal_plenticore", "kraken", - "knx", "kulersky", "kwb", "lacrosse", diff --git a/tests/common.py b/tests/common.py index 894f727478d..ff64dcb33a7 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1186,7 +1186,6 @@ class MockConfigEntry(config_entries.ConfigEntry): async def start_subentry_reconfigure_flow( self, hass: HomeAssistant, - subentry_flow_type: str, subentry_id: str, *, show_advanced_options: bool = False, @@ -1196,6 +1195,8 @@ class MockConfigEntry(config_entries.ConfigEntry): raise ValueError( "Config entry must be added to hass to start reconfiguration flow" ) + # Derive subentry_flow_type from the subentry_id + subentry_flow_type = self.subentries[subentry_id].subentry_type return await hass.config_entries.subentries.async_init( (self.entry_id, subentry_flow_type), context={ diff --git a/tests/components/ai_task/test_task.py b/tests/components/ai_task/test_task.py index d4df66d83f9..d6e266aa02e 100644 --- a/tests/components/ai_task/test_task.py +++ b/tests/components/ai_task/test_task.py @@ -8,6 +8,7 @@ from homeassistant.components.ai_task import AITaskEntityFeature, async_generate 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 00000000000..bdbd8c0532a --- /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 00000000000..3a7fcd1afe7 --- /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 00000000000..86700f50b4f --- /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 00000000000..41aa997326c --- /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 00000000000..ca74e75542f --- /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 00000000000..3d04e893d62 --- /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 00000000000..67d5b01acb6 --- /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 00000000000..1214adc488f --- /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/cloud/snapshots/test_http_api.ambr b/tests/components/cloud/snapshots/test_http_api.ambr index b15cd08c23a..c67691dfa1a 100644 --- a/tests/components/cloud/snapshots/test_http_api.ambr +++ b/tests/components/cloud/snapshots/test_http_api.ambr @@ -9,6 +9,7 @@ dev | False hassio | False docker | False + container_arch | None user | hass virtualenv | False python_version | 3.13.1 diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index b5cce286ba2..79764e552c7 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -1931,6 +1931,7 @@ async def test_download_support_package( "virtualenv": False, "python_version": "3.13.1", "docker": False, + "container_arch": None, "arch": "x86_64", "timezone": "US/Pacific", "os_name": "Linux", diff --git a/tests/components/conversation/snapshots/test_http.ambr b/tests/components/conversation/snapshots/test_http.ambr index abce735dd8a..5179409deb0 100644 --- a/tests/components/conversation/snapshots/test_http.ambr +++ b/tests/components/conversation/snapshots/test_http.ambr @@ -30,6 +30,7 @@ 'id', 'is', 'it', + 'ja', 'ka', 'ko', 'kw', diff --git a/tests/components/enphase_envoy/fixtures/envoy.json b/tests/components/enphase_envoy/fixtures/envoy.json index c619d61a393..85d8990b1ab 100644 --- a/tests/components/enphase_envoy/fixtures/envoy.json +++ b/tests/components/enphase_envoy/fixtures/envoy.json @@ -38,9 +38,19 @@ "inverters": { "1": { "serial_number": "1", - "last_report_date": 1, - "last_report_watts": 1, - "max_report_watts": 1 + "last_report_date": 1750460765, + "last_report_watts": 116, + "max_report_watts": 325, + "dc_voltage": 33.793, + "dc_current": 3.668, + "ac_voltage": 243.438, + "ac_current": 0.504, + "ac_frequency": 50.01, + "temperature": 23, + "energy_produced": 32.254, + "energy_today": 134, + "lifetime_energy": 130209, + "last_report_duration": 903 } }, "tariff": null, diff --git a/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json b/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json index 22aeca50ca0..50f320edbc2 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json +++ b/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json @@ -78,7 +78,17 @@ "serial_number": "1", "last_report_date": 1, "last_report_watts": 1, - "max_report_watts": 1 + "max_report_watts": 1, + "dc_voltage": null, + "dc_current": null, + "ac_voltage": null, + "ac_current": null, + "ac_frequency": null, + "temperature": null, + "energy_produced": null, + "energy_today": null, + "lifetime_energy": null, + "last_report_duration": null } }, "tariff": { diff --git a/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json b/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json index 52e812f979e..5cc35d4050c 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json +++ b/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json @@ -220,7 +220,17 @@ "serial_number": "1", "last_report_date": 1, "last_report_watts": 1, - "max_report_watts": 1 + "max_report_watts": 1, + "dc_voltage": null, + "dc_current": null, + "ac_voltage": null, + "ac_current": null, + "ac_frequency": null, + "temperature": null, + "energy_produced": null, + "energy_today": null, + "lifetime_energy": null, + "last_report_duration": null } }, "tariff": { diff --git a/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json b/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json index 30fbc8d0f4f..b9951a4c6fa 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json +++ b/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json @@ -208,7 +208,17 @@ "serial_number": "1", "last_report_date": 1, "last_report_watts": 1, - "max_report_watts": 1 + "max_report_watts": 1, + "dc_voltage": null, + "dc_current": null, + "ac_voltage": null, + "ac_current": null, + "ac_frequency": null, + "temperature": null, + "energy_produced": null, + "energy_today": null, + "lifetime_energy": null, + "last_report_duration": null } }, "tariff": { diff --git a/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json b/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json index 6cfbfed1e8e..73af5af0e5d 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json +++ b/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json @@ -412,7 +412,17 @@ "serial_number": "1", "last_report_date": 1, "last_report_watts": 1, - "max_report_watts": 1 + "max_report_watts": 1, + "dc_voltage": null, + "dc_current": null, + "ac_voltage": null, + "ac_current": null, + "ac_frequency": null, + "temperature": null, + "energy_produced": null, + "energy_today": null, + "lifetime_energy": null, + "last_report_duration": null } }, "tariff": { diff --git a/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json b/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json index 8c2767e33e5..5a9ca140f8c 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json +++ b/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json @@ -227,7 +227,17 @@ "serial_number": "1", "last_report_date": 1, "last_report_watts": 1, - "max_report_watts": 1 + "max_report_watts": 1, + "dc_voltage": null, + "dc_current": null, + "ac_voltage": null, + "ac_current": null, + "ac_frequency": null, + "temperature": null, + "energy_produced": null, + "energy_today": null, + "lifetime_energy": null, + "last_report_duration": null } }, "tariff": { diff --git a/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json b/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json index 15cf2c173cb..48b4de87867 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json +++ b/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json @@ -73,7 +73,17 @@ "serial_number": "1", "last_report_date": 1, "last_report_watts": 1, - "max_report_watts": 1 + "max_report_watts": 1, + "dc_voltage": null, + "dc_current": null, + "ac_voltage": null, + "ac_current": null, + "ac_frequency": null, + "temperature": null, + "energy_produced": null, + "energy_today": null, + "lifetime_energy": null, + "last_report_duration": null } }, "tariff": { diff --git a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr index 7eb57488d66..7ad45ff51f3 100644 --- a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr +++ b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr @@ -359,9 +359,430 @@ 'unit_of_measurement': 'W', }), 'entity_id': 'sensor.inverter_1', - 'state': '1', + 'state': '116', }), }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_dc_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'voltage', + 'original_icon': None, + 'original_name': 'DC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_voltage', + 'unique_id': '1_dc_voltage', + 'unit_of_measurement': 'V', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_dc_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'current', + 'original_icon': None, + 'original_name': 'DC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_current', + 'unique_id': '1_dc_current', + 'unit_of_measurement': 'A', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_ac_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'voltage', + 'original_icon': None, + 'original_name': 'AC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_voltage', + 'unique_id': '1_ac_voltage', + 'unit_of_measurement': 'V', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_ac_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'current', + 'original_icon': None, + 'original_name': 'AC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_current', + 'unique_id': '1_ac_current', + 'unit_of_measurement': 'A', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'frequency', + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_ac_frequency', + 'unit_of_measurement': 'Hz', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': 'diagnostic', + 'entity_id': 'sensor.inverter_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'temperature', + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_temperature', + 'unit_of_measurement': '°C', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'total_increasing', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_lifetime_energy_production', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': 'kWh', + }), + }), + 'original_device_class': 'energy', + 'original_icon': None, + 'original_name': 'Lifetime energy production', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_energy', + 'unique_id': '1_lifetime_energy', + 'unit_of_measurement': 'kWh', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'total_increasing', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_energy_production_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'energy', + 'original_icon': None, + 'original_name': 'Energy production today', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_today', + 'unique_id': '1_energy_today', + 'unit_of_measurement': 'Wh', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': 'diagnostic', + 'entity_id': 'sensor.inverter_1_last_report_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'duration', + 'original_icon': None, + 'original_name': 'Last report duration', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_report_duration', + 'unique_id': '1_last_report_duration', + 'unit_of_measurement': 's', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'total', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_energy_production_since_previous_report', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'energy', + 'original_icon': None, + 'original_name': 'Energy production since previous report', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_produced', + 'unique_id': '1_energy_produced', + 'unit_of_measurement': 'mWh', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': 'diagnostic', + 'entity_id': 'sensor.inverter_1_lifetime_maximum_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'power', + 'original_icon': None, + 'original_name': 'Lifetime maximum power', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_reported', + 'unique_id': '1_max_reported', + 'unit_of_measurement': 'W', + }), + 'state': None, + }), dict({ 'entity': dict({ 'aliases': list([ @@ -419,7 +840,7 @@ 'inverters': dict({ '1': dict({ '__type': "", - 'repr': "EnvoyInverter(serial_number='1', last_report_date=1, last_report_watts=1, max_report_watts=1)", + 'repr': "EnvoyInverter(serial_number='1', last_report_date=1750460765, last_report_watts=116, max_report_watts=325, dc_voltage=33.793, dc_current=3.668, ac_voltage=243.438, ac_current=0.504, ac_frequency=50.01, temperature=23, lifetime_energy=130209, energy_produced=32.254, energy_today=134, last_report_duration=903)", }), }), 'system_consumption': None, @@ -817,9 +1238,430 @@ 'unit_of_measurement': 'W', }), 'entity_id': 'sensor.inverter_1', - 'state': '1', + 'state': '116', }), }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_dc_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'voltage', + 'original_icon': None, + 'original_name': 'DC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_voltage', + 'unique_id': '1_dc_voltage', + 'unit_of_measurement': 'V', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_dc_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'current', + 'original_icon': None, + 'original_name': 'DC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_current', + 'unique_id': '1_dc_current', + 'unit_of_measurement': 'A', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_ac_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'voltage', + 'original_icon': None, + 'original_name': 'AC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_voltage', + 'unique_id': '1_ac_voltage', + 'unit_of_measurement': 'V', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_ac_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'current', + 'original_icon': None, + 'original_name': 'AC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_current', + 'unique_id': '1_ac_current', + 'unit_of_measurement': 'A', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'frequency', + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_ac_frequency', + 'unit_of_measurement': 'Hz', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': 'diagnostic', + 'entity_id': 'sensor.inverter_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'temperature', + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_temperature', + 'unit_of_measurement': '°C', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'total_increasing', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_lifetime_energy_production', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': 'kWh', + }), + }), + 'original_device_class': 'energy', + 'original_icon': None, + 'original_name': 'Lifetime energy production', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_energy', + 'unique_id': '1_lifetime_energy', + 'unit_of_measurement': 'kWh', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'total_increasing', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_energy_production_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'energy', + 'original_icon': None, + 'original_name': 'Energy production today', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_today', + 'unique_id': '1_energy_today', + 'unit_of_measurement': 'Wh', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': 'diagnostic', + 'entity_id': 'sensor.inverter_1_last_report_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'duration', + 'original_icon': None, + 'original_name': 'Last report duration', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_report_duration', + 'unique_id': '1_last_report_duration', + 'unit_of_measurement': 's', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'total', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_energy_production_since_previous_report', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'energy', + 'original_icon': None, + 'original_name': 'Energy production since previous report', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_produced', + 'unique_id': '1_energy_produced', + 'unit_of_measurement': 'mWh', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': 'diagnostic', + 'entity_id': 'sensor.inverter_1_lifetime_maximum_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'power', + 'original_icon': None, + 'original_name': 'Lifetime maximum power', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_reported', + 'unique_id': '1_max_reported', + 'unit_of_measurement': 'W', + }), + 'state': None, + }), dict({ 'entity': dict({ 'aliases': list([ @@ -877,7 +1719,7 @@ 'inverters': dict({ '1': dict({ '__type': "", - 'repr': "EnvoyInverter(serial_number='1', last_report_date=1, last_report_watts=1, max_report_watts=1)", + 'repr': "EnvoyInverter(serial_number='1', last_report_date=1750460765, last_report_watts=116, max_report_watts=325, dc_voltage=33.793, dc_current=3.668, ac_voltage=243.438, ac_current=0.504, ac_frequency=50.01, temperature=23, lifetime_energy=130209, energy_produced=32.254, energy_today=134, last_report_duration=903)", }), }), 'system_consumption': None, @@ -934,6 +1776,8 @@ '/ivp/meters/readings': 'Testing request replies.', '/ivp/meters/readings_log': '{"headers":{"Hello":"World"},"code":200}', '/ivp/meters_log': '{"headers":{"Hello":"World"},"code":200}', + '/ivp/pdm/device_data': 'Testing request replies.', + '/ivp/pdm/device_data_log': '{"headers":{"Hello":"World"},"code":200}', '/ivp/sc/pvlimit': 'Testing request replies.', '/ivp/sc/pvlimit_log': '{"headers":{"Hello":"World"},"code":200}', '/ivp/ss/dry_contact_settings': 'Testing request replies.', @@ -1317,9 +2161,430 @@ 'unit_of_measurement': 'W', }), 'entity_id': 'sensor.inverter_1', - 'state': '1', + 'state': '116', }), }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_dc_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'voltage', + 'original_icon': None, + 'original_name': 'DC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_voltage', + 'unique_id': '1_dc_voltage', + 'unit_of_measurement': 'V', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_dc_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'current', + 'original_icon': None, + 'original_name': 'DC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_current', + 'unique_id': '1_dc_current', + 'unit_of_measurement': 'A', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_ac_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'voltage', + 'original_icon': None, + 'original_name': 'AC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_voltage', + 'unique_id': '1_ac_voltage', + 'unit_of_measurement': 'V', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_ac_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'current', + 'original_icon': None, + 'original_name': 'AC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_current', + 'unique_id': '1_ac_current', + 'unit_of_measurement': 'A', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'frequency', + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_ac_frequency', + 'unit_of_measurement': 'Hz', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': 'diagnostic', + 'entity_id': 'sensor.inverter_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'temperature', + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_temperature', + 'unit_of_measurement': '°C', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'total_increasing', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_lifetime_energy_production', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': 'kWh', + }), + }), + 'original_device_class': 'energy', + 'original_icon': None, + 'original_name': 'Lifetime energy production', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_energy', + 'unique_id': '1_lifetime_energy', + 'unit_of_measurement': 'kWh', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'total_increasing', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_energy_production_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'energy', + 'original_icon': None, + 'original_name': 'Energy production today', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_today', + 'unique_id': '1_energy_today', + 'unit_of_measurement': 'Wh', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': 'diagnostic', + 'entity_id': 'sensor.inverter_1_last_report_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'duration', + 'original_icon': None, + 'original_name': 'Last report duration', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_report_duration', + 'unique_id': '1_last_report_duration', + 'unit_of_measurement': 's', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'total', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_energy_production_since_previous_report', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'energy', + 'original_icon': None, + 'original_name': 'Energy production since previous report', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_produced', + 'unique_id': '1_energy_produced', + 'unit_of_measurement': 'mWh', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': 'diagnostic', + 'entity_id': 'sensor.inverter_1_lifetime_maximum_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'power', + 'original_icon': None, + 'original_name': 'Lifetime maximum power', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_reported', + 'unique_id': '1_max_reported', + 'unit_of_measurement': 'W', + }), + 'state': None, + }), dict({ 'entity': dict({ 'aliases': list([ @@ -1377,7 +2642,7 @@ 'inverters': dict({ '1': dict({ '__type': "", - 'repr': "EnvoyInverter(serial_number='1', last_report_date=1, last_report_watts=1, max_report_watts=1)", + 'repr': "EnvoyInverter(serial_number='1', last_report_date=1750460765, last_report_watts=116, max_report_watts=325, dc_voltage=33.793, dc_current=3.668, ac_voltage=243.438, ac_current=0.504, ac_frequency=50.01, temperature=23, lifetime_energy=130209, energy_produced=32.254, energy_today=134, last_report_duration=903)", }), }), 'system_consumption': None, @@ -1447,6 +2712,9 @@ '/ivp/meters_log': dict({ 'Error': "EnvoyError('Test')", }), + '/ivp/pdm/device_data_log': dict({ + 'Error': "EnvoyError('Test')", + }), '/ivp/sc/pvlimit_log': dict({ 'Error': "EnvoyError('Test')", }), @@ -1589,9 +2857,430 @@ 'unit_of_measurement': 'W', }), 'entity_id': 'sensor.inverter_1', - 'state': '1', + 'state': '116', }), }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_dc_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'voltage', + 'original_icon': None, + 'original_name': 'DC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_voltage', + 'unique_id': '1_dc_voltage', + 'unit_of_measurement': 'V', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_dc_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'current', + 'original_icon': None, + 'original_name': 'DC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_current', + 'unique_id': '1_dc_current', + 'unit_of_measurement': 'A', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_ac_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'voltage', + 'original_icon': None, + 'original_name': 'AC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_voltage', + 'unique_id': '1_ac_voltage', + 'unit_of_measurement': 'V', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_ac_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'current', + 'original_icon': None, + 'original_name': 'AC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_current', + 'unique_id': '1_ac_current', + 'unit_of_measurement': 'A', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'frequency', + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_ac_frequency', + 'unit_of_measurement': 'Hz', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': 'diagnostic', + 'entity_id': 'sensor.inverter_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'temperature', + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_temperature', + 'unit_of_measurement': '°C', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'total_increasing', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_lifetime_energy_production', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': 'kWh', + }), + }), + 'original_device_class': 'energy', + 'original_icon': None, + 'original_name': 'Lifetime energy production', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_energy', + 'unique_id': '1_lifetime_energy', + 'unit_of_measurement': 'kWh', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'total_increasing', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_energy_production_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'energy', + 'original_icon': None, + 'original_name': 'Energy production today', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_today', + 'unique_id': '1_energy_today', + 'unit_of_measurement': 'Wh', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': 'diagnostic', + 'entity_id': 'sensor.inverter_1_last_report_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'duration', + 'original_icon': None, + 'original_name': 'Last report duration', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_report_duration', + 'unique_id': '1_last_report_duration', + 'unit_of_measurement': 's', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'total', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_energy_production_since_previous_report', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'energy', + 'original_icon': None, + 'original_name': 'Energy production since previous report', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_produced', + 'unique_id': '1_energy_produced', + 'unit_of_measurement': 'mWh', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': 'diagnostic', + 'entity_id': 'sensor.inverter_1_lifetime_maximum_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'power', + 'original_icon': None, + 'original_name': 'Lifetime maximum power', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_reported', + 'unique_id': '1_max_reported', + 'unit_of_measurement': 'W', + }), + 'state': None, + }), dict({ 'entity': dict({ 'aliases': list([ @@ -1901,7 +3590,7 @@ 'inverters': dict({ '1': dict({ '__type': "", - 'repr': "EnvoyInverter(serial_number='1', last_report_date=1, last_report_watts=1, max_report_watts=1)", + 'repr': "EnvoyInverter(serial_number='1', last_report_date=1750460765, last_report_watts=116, max_report_watts=325, dc_voltage=33.793, dc_current=3.668, ac_voltage=243.438, ac_current=0.504, ac_frequency=50.01, temperature=23, lifetime_energy=130209, energy_produced=32.254, energy_today=134, last_report_duration=903)", }), }), 'system_consumption': None, diff --git a/tests/components/enphase_envoy/snapshots/test_sensor.ambr b/tests/components/enphase_envoy/snapshots/test_sensor.ambr index d548b2a0f93..4a9563ce906 100644 --- a/tests/components/enphase_envoy/snapshots/test_sensor.ambr +++ b/tests/components/enphase_envoy/snapshots/test_sensor.ambr @@ -285,7 +285,455 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1', + 'state': '116', + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_ac_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': None, + 'entity_id': 'sensor.inverter_1_ac_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_current', + 'unique_id': '1_ac_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_ac_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Inverter 1 AC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_ac_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.504', + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_ac_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': None, + 'entity_id': 'sensor.inverter_1_ac_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_voltage', + 'unique_id': '1_ac_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_ac_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Inverter 1 AC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_ac_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '243.438', + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_dc_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': None, + 'entity_id': 'sensor.inverter_1_dc_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_current', + 'unique_id': '1_dc_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_dc_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Inverter 1 DC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_dc_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.668', + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_dc_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': None, + 'entity_id': 'sensor.inverter_1_dc_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_voltage', + 'unique_id': '1_dc_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_dc_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Inverter 1 DC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_dc_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '33.793', + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_energy_production_since_previous_report-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.inverter_1_energy_production_since_previous_report', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy production since previous report', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_produced', + 'unique_id': '1_energy_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_energy_production_since_previous_report-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Energy production since previous report', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_energy_production_since_previous_report', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '32.254', + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_energy_production_today-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.inverter_1_energy_production_today', + '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': 'Energy production today', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_today', + 'unique_id': '1_energy_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_energy_production_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Energy production today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_energy_production_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '134', + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_frequency-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.inverter_1_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_ac_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Inverter 1 Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.01', + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_last_report_duration-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.inverter_1_last_report_duration', + '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': 'Last report duration', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_report_duration', + 'unique_id': '1_last_report_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_last_report_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Inverter 1 Last report duration', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_last_report_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '903', }) # --- # name: test_sensor[envoy][sensor.inverter_1_last_reported-entry] @@ -334,7 +782,178 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1970-01-01T00:00:01+00:00', + 'state': '2025-06-20T23:06:05+00:00', + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_lifetime_energy_production-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.inverter_1_lifetime_energy_production', + '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': 'Lifetime energy production', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_energy', + 'unique_id': '1_lifetime_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_lifetime_energy_production-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Lifetime energy production', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_lifetime_energy_production', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '130.209', + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_lifetime_maximum_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.inverter_1_lifetime_maximum_power', + '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': 'Lifetime maximum power', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_reported', + 'unique_id': '1_max_reported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_lifetime_maximum_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Inverter 1 Lifetime maximum power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_lifetime_maximum_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '325', + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_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': , + 'entity_id': 'sensor.inverter_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Inverter 1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23', }) # --- # name: test_sensor[envoy_1p_metered][sensor.envoy_1234_balanced_net_power_consumption-entry] @@ -1828,6 +2447,454 @@ 'state': '1', }) # --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_ac_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': None, + 'entity_id': 'sensor.inverter_1_ac_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_current', + 'unique_id': '1_ac_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_ac_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Inverter 1 AC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_ac_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_ac_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': None, + 'entity_id': 'sensor.inverter_1_ac_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_voltage', + 'unique_id': '1_ac_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_ac_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Inverter 1 AC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_ac_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_dc_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': None, + 'entity_id': 'sensor.inverter_1_dc_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_current', + 'unique_id': '1_dc_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_dc_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Inverter 1 DC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_dc_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_dc_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': None, + 'entity_id': 'sensor.inverter_1_dc_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_voltage', + 'unique_id': '1_dc_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_dc_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Inverter 1 DC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_dc_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_energy_production_since_previous_report-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.inverter_1_energy_production_since_previous_report', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy production since previous report', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_produced', + 'unique_id': '1_energy_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_energy_production_since_previous_report-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Energy production since previous report', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_energy_production_since_previous_report', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_energy_production_today-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.inverter_1_energy_production_today', + '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': 'Energy production today', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_today', + 'unique_id': '1_energy_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_energy_production_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Energy production today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_energy_production_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_frequency-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.inverter_1_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_ac_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Inverter 1 Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_last_report_duration-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.inverter_1_last_report_duration', + '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': 'Last report duration', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_report_duration', + 'unique_id': '1_last_report_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_last_report_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Inverter 1 Last report duration', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_last_report_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor[envoy_1p_metered][sensor.inverter_1_last_reported-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1877,6 +2944,177 @@ 'state': '1970-01-01T00:00:01+00:00', }) # --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_lifetime_energy_production-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.inverter_1_lifetime_energy_production', + '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': 'Lifetime energy production', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_energy', + 'unique_id': '1_lifetime_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_lifetime_energy_production-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Lifetime energy production', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_lifetime_energy_production', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_lifetime_maximum_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.inverter_1_lifetime_maximum_power', + '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': 'Lifetime maximum power', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_reported', + 'unique_id': '1_max_reported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_lifetime_maximum_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Inverter 1 Lifetime maximum power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_lifetime_maximum_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_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': , + 'entity_id': 'sensor.inverter_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Inverter 1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor[envoy_acb_batt][sensor.acb_1234_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2431,7 +3669,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Aggregated battery soc', + 'original_name': 'Aggregated battery SOC', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'suggested_object_id': None, @@ -2445,7 +3683,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', - 'friendly_name': 'Envoy 1234 Aggregated battery soc', + 'friendly_name': 'Envoy 1234 Aggregated battery SOC', 'unit_of_measurement': '%', }), 'context': , @@ -6812,6 +8050,454 @@ 'state': '1', }) # --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_ac_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': None, + 'entity_id': 'sensor.inverter_1_ac_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_current', + 'unique_id': '1_ac_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_ac_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Inverter 1 AC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_ac_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_ac_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': None, + 'entity_id': 'sensor.inverter_1_ac_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_voltage', + 'unique_id': '1_ac_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_ac_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Inverter 1 AC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_ac_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_dc_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': None, + 'entity_id': 'sensor.inverter_1_dc_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_current', + 'unique_id': '1_dc_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_dc_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Inverter 1 DC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_dc_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_dc_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': None, + 'entity_id': 'sensor.inverter_1_dc_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_voltage', + 'unique_id': '1_dc_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_dc_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Inverter 1 DC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_dc_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_energy_production_since_previous_report-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.inverter_1_energy_production_since_previous_report', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy production since previous report', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_produced', + 'unique_id': '1_energy_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_energy_production_since_previous_report-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Energy production since previous report', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_energy_production_since_previous_report', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_energy_production_today-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.inverter_1_energy_production_today', + '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': 'Energy production today', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_today', + 'unique_id': '1_energy_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_energy_production_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Energy production today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_energy_production_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_frequency-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.inverter_1_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_ac_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Inverter 1 Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_last_report_duration-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.inverter_1_last_report_duration', + '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': 'Last report duration', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_report_duration', + 'unique_id': '1_last_report_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_last_report_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Inverter 1 Last report duration', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_last_report_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor[envoy_acb_batt][sensor.inverter_1_last_reported-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -6861,6 +8547,177 @@ 'state': '1970-01-01T00:00:01+00:00', }) # --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_lifetime_energy_production-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.inverter_1_lifetime_energy_production', + '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': 'Lifetime energy production', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_energy', + 'unique_id': '1_lifetime_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_lifetime_energy_production-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Lifetime energy production', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_lifetime_energy_production', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_lifetime_maximum_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.inverter_1_lifetime_maximum_power', + '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': 'Lifetime maximum power', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_reported', + 'unique_id': '1_max_reported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_lifetime_maximum_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Inverter 1 Lifetime maximum power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_lifetime_maximum_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_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': , + 'entity_id': 'sensor.inverter_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Inverter 1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor[envoy_eu_batt][sensor.encharge_123456_apparent_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -11422,6 +13279,454 @@ 'state': '1', }) # --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_ac_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': None, + 'entity_id': 'sensor.inverter_1_ac_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_current', + 'unique_id': '1_ac_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_ac_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Inverter 1 AC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_ac_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_ac_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': None, + 'entity_id': 'sensor.inverter_1_ac_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_voltage', + 'unique_id': '1_ac_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_ac_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Inverter 1 AC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_ac_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_dc_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': None, + 'entity_id': 'sensor.inverter_1_dc_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_current', + 'unique_id': '1_dc_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_dc_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Inverter 1 DC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_dc_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_dc_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': None, + 'entity_id': 'sensor.inverter_1_dc_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_voltage', + 'unique_id': '1_dc_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_dc_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Inverter 1 DC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_dc_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_energy_production_since_previous_report-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.inverter_1_energy_production_since_previous_report', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy production since previous report', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_produced', + 'unique_id': '1_energy_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_energy_production_since_previous_report-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Energy production since previous report', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_energy_production_since_previous_report', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_energy_production_today-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.inverter_1_energy_production_today', + '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': 'Energy production today', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_today', + 'unique_id': '1_energy_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_energy_production_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Energy production today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_energy_production_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_frequency-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.inverter_1_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_ac_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Inverter 1 Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_last_report_duration-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.inverter_1_last_report_duration', + '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': 'Last report duration', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_report_duration', + 'unique_id': '1_last_report_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_last_report_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Inverter 1 Last report duration', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_last_report_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor[envoy_eu_batt][sensor.inverter_1_last_reported-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -11471,6 +13776,177 @@ 'state': '1970-01-01T00:00:01+00:00', }) # --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_lifetime_energy_production-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.inverter_1_lifetime_energy_production', + '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': 'Lifetime energy production', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_energy', + 'unique_id': '1_lifetime_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_lifetime_energy_production-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Lifetime energy production', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_lifetime_energy_production', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_lifetime_maximum_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.inverter_1_lifetime_maximum_power', + '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': 'Lifetime maximum power', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_reported', + 'unique_id': '1_max_reported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_lifetime_maximum_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Inverter 1 Lifetime maximum power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_lifetime_maximum_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_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': , + 'entity_id': 'sensor.inverter_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Inverter 1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor[envoy_metered_batt_relay][sensor.encharge_123456_apparent_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -19942,6 +22418,454 @@ 'state': '1', }) # --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_ac_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': None, + 'entity_id': 'sensor.inverter_1_ac_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_current', + 'unique_id': '1_ac_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_ac_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Inverter 1 AC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_ac_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_ac_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': None, + 'entity_id': 'sensor.inverter_1_ac_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_voltage', + 'unique_id': '1_ac_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_ac_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Inverter 1 AC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_ac_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_dc_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': None, + 'entity_id': 'sensor.inverter_1_dc_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_current', + 'unique_id': '1_dc_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_dc_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Inverter 1 DC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_dc_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_dc_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': None, + 'entity_id': 'sensor.inverter_1_dc_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_voltage', + 'unique_id': '1_dc_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_dc_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Inverter 1 DC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_dc_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_energy_production_since_previous_report-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.inverter_1_energy_production_since_previous_report', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy production since previous report', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_produced', + 'unique_id': '1_energy_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_energy_production_since_previous_report-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Energy production since previous report', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_energy_production_since_previous_report', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_energy_production_today-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.inverter_1_energy_production_today', + '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': 'Energy production today', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_today', + 'unique_id': '1_energy_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_energy_production_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Energy production today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_energy_production_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_frequency-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.inverter_1_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_ac_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Inverter 1 Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_last_report_duration-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.inverter_1_last_report_duration', + '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': 'Last report duration', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_report_duration', + 'unique_id': '1_last_report_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_last_report_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Inverter 1 Last report duration', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_last_report_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_last_reported-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -19991,6 +22915,177 @@ 'state': '1970-01-01T00:00:01+00:00', }) # --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_lifetime_energy_production-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.inverter_1_lifetime_energy_production', + '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': 'Lifetime energy production', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_energy', + 'unique_id': '1_lifetime_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_lifetime_energy_production-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Lifetime energy production', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_lifetime_energy_production', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_lifetime_maximum_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.inverter_1_lifetime_maximum_power', + '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': 'Lifetime maximum power', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_reported', + 'unique_id': '1_max_reported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_lifetime_maximum_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Inverter 1 Lifetime maximum power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_lifetime_maximum_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_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': , + 'entity_id': 'sensor.inverter_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Inverter 1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_balanced_net_power_consumption-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -25787,6 +28882,454 @@ 'state': '1', }) # --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_ac_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': None, + 'entity_id': 'sensor.inverter_1_ac_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_current', + 'unique_id': '1_ac_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_ac_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Inverter 1 AC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_ac_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_ac_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': None, + 'entity_id': 'sensor.inverter_1_ac_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_voltage', + 'unique_id': '1_ac_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_ac_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Inverter 1 AC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_ac_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_dc_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': None, + 'entity_id': 'sensor.inverter_1_dc_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_current', + 'unique_id': '1_dc_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_dc_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Inverter 1 DC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_dc_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_dc_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': None, + 'entity_id': 'sensor.inverter_1_dc_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_voltage', + 'unique_id': '1_dc_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_dc_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Inverter 1 DC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_dc_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_energy_production_since_previous_report-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.inverter_1_energy_production_since_previous_report', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy production since previous report', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_produced', + 'unique_id': '1_energy_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_energy_production_since_previous_report-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Energy production since previous report', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_energy_production_since_previous_report', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_energy_production_today-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.inverter_1_energy_production_today', + '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': 'Energy production today', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_today', + 'unique_id': '1_energy_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_energy_production_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Energy production today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_energy_production_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_frequency-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.inverter_1_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_ac_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Inverter 1 Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_last_report_duration-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.inverter_1_last_report_duration', + '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': 'Last report duration', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_report_duration', + 'unique_id': '1_last_report_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_last_report_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Inverter 1 Last report duration', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_last_report_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_last_reported-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -25836,6 +29379,177 @@ 'state': '1970-01-01T00:00:01+00:00', }) # --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_lifetime_energy_production-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.inverter_1_lifetime_energy_production', + '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': 'Lifetime energy production', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_energy', + 'unique_id': '1_lifetime_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_lifetime_energy_production-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Lifetime energy production', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_lifetime_energy_production', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_lifetime_maximum_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.inverter_1_lifetime_maximum_power', + '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': 'Lifetime maximum power', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_reported', + 'unique_id': '1_max_reported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_lifetime_maximum_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Inverter 1 Lifetime maximum power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_lifetime_maximum_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_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': , + 'entity_id': 'sensor.inverter_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Inverter 1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_balanced_net_power_consumption-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -26580,6 +30294,454 @@ 'state': '1', }) # --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_ac_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': None, + 'entity_id': 'sensor.inverter_1_ac_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_current', + 'unique_id': '1_ac_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_ac_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Inverter 1 AC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_ac_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_ac_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': None, + 'entity_id': 'sensor.inverter_1_ac_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_voltage', + 'unique_id': '1_ac_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_ac_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Inverter 1 AC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_ac_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_dc_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': None, + 'entity_id': 'sensor.inverter_1_dc_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_current', + 'unique_id': '1_dc_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_dc_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Inverter 1 DC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_dc_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_dc_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': None, + 'entity_id': 'sensor.inverter_1_dc_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_voltage', + 'unique_id': '1_dc_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_dc_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Inverter 1 DC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_dc_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_energy_production_since_previous_report-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.inverter_1_energy_production_since_previous_report', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy production since previous report', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_produced', + 'unique_id': '1_energy_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_energy_production_since_previous_report-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Energy production since previous report', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_energy_production_since_previous_report', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_energy_production_today-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.inverter_1_energy_production_today', + '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': 'Energy production today', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_today', + 'unique_id': '1_energy_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_energy_production_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Energy production today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_energy_production_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_frequency-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.inverter_1_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_ac_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Inverter 1 Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_last_report_duration-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.inverter_1_last_report_duration', + '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': 'Last report duration', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_report_duration', + 'unique_id': '1_last_report_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_last_report_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Inverter 1 Last report duration', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_last_report_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_last_reported-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -26629,3 +30791,174 @@ 'state': '1970-01-01T00:00:01+00:00', }) # --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_lifetime_energy_production-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.inverter_1_lifetime_energy_production', + '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': 'Lifetime energy production', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_energy', + 'unique_id': '1_lifetime_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_lifetime_energy_production-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Lifetime energy production', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_lifetime_energy_production', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_lifetime_maximum_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.inverter_1_lifetime_maximum_power', + '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': 'Lifetime maximum power', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_reported', + 'unique_id': '1_max_reported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_lifetime_maximum_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Inverter 1 Lifetime maximum power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_lifetime_maximum_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_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': , + 'entity_id': 'sensor.inverter_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Inverter 1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/enphase_envoy/test_init.py b/tests/components/enphase_envoy/test_init.py index 560d0719424..a738b31c183 100644 --- a/tests/components/enphase_envoy/test_init.py +++ b/tests/components/enphase_envoy/test_init.py @@ -54,7 +54,7 @@ async def test_with_pre_v7_firmware( await setup_integration(hass, config_entry) assert (entity_state := hass.states.get("sensor.inverter_1")) - assert entity_state.state == "1" + assert entity_state.state == "116" @pytest.mark.freeze_time("2024-07-23 00:00:00+00:00") @@ -85,7 +85,7 @@ async def test_token_in_config_file( await setup_integration(hass, entry) assert (entity_state := hass.states.get("sensor.inverter_1")) - assert entity_state.state == "1" + assert entity_state.state == "116" @respx.mock @@ -128,7 +128,7 @@ async def test_expired_token_in_config( await setup_integration(hass, entry) assert (entity_state := hass.states.get("sensor.inverter_1")) - assert entity_state.state == "1" + assert entity_state.state == "116" async def test_coordinator_update_error( @@ -226,7 +226,7 @@ async def test_coordinator_token_refresh_error( await setup_integration(hass, entry) assert (entity_state := hass.states.get("sensor.inverter_1")) - assert entity_state.state == "1" + assert entity_state.state == "116" async def test_config_no_unique_id( diff --git a/tests/components/enphase_envoy/test_sensor.py b/tests/components/enphase_envoy/test_sensor.py index 89f28c74514..a9ee1f370a8 100644 --- a/tests/components/enphase_envoy/test_sensor.py +++ b/tests/components/enphase_envoy/test_sensor.py @@ -772,6 +772,70 @@ async def test_sensor_inverter_data( ) == dt_util.utc_from_timestamp(inverter.last_report_date) +@pytest.mark.parametrize( + ("mock_envoy"), + [ + "envoy", + ], + indirect=["mock_envoy"], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor_inverter_detailed_data( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, +) -> None: + """Test enphase_envoy inverter detailed entities values.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, config_entry) + + entity_base = f"{Platform.SENSOR}.inverter" + + for sn, inverter in mock_envoy.data.inverters.items(): + assert (dc_voltage := hass.states.get(f"{entity_base}_{sn}_dc_voltage")) + assert float(dc_voltage.state) == (inverter.dc_voltage) + assert (dc_current := hass.states.get(f"{entity_base}_{sn}_dc_current")) + assert float(dc_current.state) == (inverter.dc_current) + assert (ac_voltage := hass.states.get(f"{entity_base}_{sn}_ac_voltage")) + assert float(ac_voltage.state) == (inverter.ac_voltage) + assert (ac_current := hass.states.get(f"{entity_base}_{sn}_ac_current")) + assert float(ac_current.state) == (inverter.ac_current) + assert (frequency := hass.states.get(f"{entity_base}_{sn}_frequency")) + assert float(frequency.state) == (inverter.ac_frequency) + assert (temperature := hass.states.get(f"{entity_base}_{sn}_temperature")) + assert int(temperature.state) == (inverter.temperature) + assert ( + lifetime_energy := hass.states.get( + f"{entity_base}_{sn}_lifetime_energy_production" + ) + ) + assert float(lifetime_energy.state) == (inverter.lifetime_energy / 1000.0) + assert ( + energy_produced_today := hass.states.get( + f"{entity_base}_{sn}_energy_production_today" + ) + ) + assert int(energy_produced_today.state) == (inverter.energy_today) + assert ( + last_report_duration := hass.states.get( + f"{entity_base}_{sn}_last_report_duration" + ) + ) + assert int(last_report_duration.state) == (inverter.last_report_duration) + assert ( + energy_produced := hass.states.get( + f"{entity_base}_{sn}_energy_production_since_previous_report" + ) + ) + assert float(energy_produced.state) == (inverter.energy_produced) + assert ( + lifetime_maximum_power := hass.states.get( + f"{entity_base}_{sn}_lifetime_maximum_power" + ) + ) + assert int(lifetime_maximum_power.state) == (inverter.max_report_watts) + + @pytest.mark.parametrize( ("mock_envoy"), [ @@ -797,9 +861,23 @@ async def test_sensor_inverter_disabled_by_integration( INVERTER_BASE = f"{Platform.SENSOR}.inverter" assert all( - f"{INVERTER_BASE}_{sn}_last_reported" + f"{INVERTER_BASE}_{sn}_{key}" in integration_disabled_entities(entity_registry, config_entry) for sn in mock_envoy.data.inverters + for key in ( + "dc_voltage", + "dc_current", + "ac_voltage", + "ac_current", + "frequency", + "temperature", + "lifetime_energy_production", + "energy_production_today", + "last_report_duration", + "energy_production_since_previous_report", + "last_reported", + "lifetime_maximum_power", + ) ) diff --git a/tests/components/esphome/snapshots/test_diagnostics.ambr b/tests/components/esphome/snapshots/test_diagnostics.ambr index d88f2045e56..dac224c802f 100644 --- a/tests/components/esphome/snapshots/test_diagnostics.ambr +++ b/tests/components/esphome/snapshots/test_diagnostics.ambr @@ -82,9 +82,17 @@ 'minor': 99, }), 'device_info': dict({ + 'area': dict({ + 'area_id': 0, + 'name': '', + }), + 'areas': list([ + ]), 'bluetooth_mac_address': '', 'bluetooth_proxy_feature_flags': 0, 'compilation_time': '', + 'devices': list([ + ]), 'esphome_version': '1.0.0', 'friendly_name': 'Test', 'has_deep_sleep': False, diff --git a/tests/components/esphome/test_diagnostics.py b/tests/components/esphome/test_diagnostics.py index 8f1843900d7..2653df57adb 100644 --- a/tests/components/esphome/test_diagnostics.py +++ b/tests/components/esphome/test_diagnostics.py @@ -124,9 +124,12 @@ async def test_diagnostics_with_bluetooth( "storage_data": { "api_version": {"major": 99, "minor": 99}, "device_info": { + "area": {"area_id": 0, "name": ""}, + "areas": [], "bluetooth_mac_address": "**REDACTED**", "bluetooth_proxy_feature_flags": 63, "compilation_time": "", + "devices": [], "esphome_version": "1.0.0", "friendly_name": "Test", "has_deep_sleep": False, diff --git a/tests/components/foscam/conftest.py b/tests/components/foscam/conftest.py index 6ff5a0b5af5..f8b4093574f 100644 --- a/tests/components/foscam/conftest.py +++ b/tests/components/foscam/conftest.py @@ -1,6 +1,6 @@ """Common stuff for Foscam tests.""" -from libpyfoscam.foscam import ( +from libpyfoscamcgi.foscamcgi import ( ERROR_FOSCAM_AUTH, ERROR_FOSCAM_CMD, ERROR_FOSCAM_UNAVAILABLE, diff --git a/tests/components/google_generative_ai_conversation/conftest.py b/tests/components/google_generative_ai_conversation/conftest.py index f499f18bc15..36d99cd2764 100644 --- a/tests/components/google_generative_ai_conversation/conftest.py +++ b/tests/components/google_generative_ai_conversation/conftest.py @@ -5,8 +5,9 @@ from unittest.mock import AsyncMock, Mock, patch import pytest -from homeassistant.components.google_generative_ai_conversation.entity import ( +from homeassistant.components.google_generative_ai_conversation.const import ( CONF_USE_GOOGLE_SEARCH_TOOL, + DEFAULT_CONVERSATION_NAME, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API @@ -26,6 +27,15 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: data={ "api_key": "bla", }, + version=2, + subentries_data=[ + { + "data": {}, + "subentry_type": "conversation", + "title": DEFAULT_CONVERSATION_NAME, + "unique_id": None, + } + ], ) entry.runtime_data = Mock() entry.add_to_hass(hass) @@ -38,8 +48,10 @@ async def mock_config_entry_with_assist( ) -> MockConfigEntry: """Mock a config entry with assist.""" with patch("google.genai.models.AsyncModels.get"): - hass.config_entries.async_update_entry( - mock_config_entry, options={CONF_LLM_HASS_API: llm.LLM_API_ASSIST} + hass.config_entries.async_update_subentry( + mock_config_entry, + next(iter(mock_config_entry.subentries.values())), + data={CONF_LLM_HASS_API: llm.LLM_API_ASSIST}, ) await hass.async_block_till_done() return mock_config_entry @@ -51,9 +63,10 @@ async def mock_config_entry_with_google_search( ) -> MockConfigEntry: """Mock a config entry with assist.""" with patch("google.genai.models.AsyncModels.get"): - hass.config_entries.async_update_entry( + hass.config_entries.async_update_subentry( mock_config_entry, - options={ + next(iter(mock_config_entry.subentries.values())), + data={ CONF_LLM_HASS_API: llm.LLM_API_ASSIST, CONF_USE_GOOGLE_SEARCH_TOOL: True, }, diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr index 60d388d0502..a31827c7acc 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr @@ -5,7 +5,7 @@ 'api_key': '**REDACTED**', }), 'options': dict({ - 'chat_model': 'models/gemini-2.0-flash', + 'chat_model': 'models/gemini-2.5-flash', 'dangerous_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', 'harassment_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', 'hate_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr index d8e54b15f61..f89871ff131 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr @@ -11,7 +11,7 @@ File(name='doorbell_snapshot.jpg', display_name=None, mime_type=None, size_bytes=None, create_time=None, expiration_time=None, update_time=None, sha256_hash=None, uri=None, download_uri=None, state=, source=None, video_metadata=None, error=None), File(name='context.txt', display_name=None, mime_type=None, size_bytes=None, create_time=None, expiration_time=None, update_time=None, sha256_hash=None, uri=None, download_uri=None, state=, source=None, video_metadata=None, error=None), ]), - 'model': 'models/gemini-2.0-flash', + 'model': 'models/gemini-2.5-flash', }), ), ]) @@ -28,7 +28,7 @@ b'some file', b'some file', ]), - 'model': 'models/gemini-2.0-flash', + 'model': 'models/gemini-2.5-flash', }), ), ]) @@ -43,7 +43,7 @@ 'contents': list([ 'Write an opening speech for a Home Assistant release party', ]), - 'model': 'models/gemini-2.0-flash', + 'model': 'models/gemini-2.5-flash', }), ), ]) @@ -58,7 +58,7 @@ 'contents': list([ 'Write an opening speech for a Home Assistant release party', ]), - 'model': 'models/gemini-2.0-flash', + 'model': 'models/gemini-2.5-flash', }), ), ]) diff --git a/tests/components/google_generative_ai_conversation/test_config_flow.py b/tests/components/google_generative_ai_conversation/test_config_flow.py index 4234355cb5b..e02d85e41c4 100644 --- a/tests/components/google_generative_ai_conversation/test_config_flow.py +++ b/tests/components/google_generative_ai_conversation/test_config_flow.py @@ -22,6 +22,7 @@ from homeassistant.components.google_generative_ai_conversation.const import ( CONF_TOP_K, CONF_TOP_P, CONF_USE_GOOGLE_SEARCH_TOOL, + DEFAULT_CONVERSATION_NAME, DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_HARM_BLOCK_THRESHOLD, @@ -30,7 +31,7 @@ from homeassistant.components.google_generative_ai_conversation.const import ( RECOMMENDED_TOP_P, RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, ) -from homeassistant.const import CONF_LLM_HASS_API +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -41,6 +42,12 @@ from tests.common import MockConfigEntry def get_models_pager(): """Return a generator that yields the models.""" + model_25_flash = Mock( + display_name="Gemini 2.5 Flash", + supported_actions=["generateContent"], + ) + model_25_flash.name = "models/gemini-2.5-flash" + model_20_flash = Mock( display_name="Gemini 2.0 Flash", supported_actions=["generateContent"], @@ -59,17 +66,11 @@ def get_models_pager(): ) model_15_pro.name = "models/gemini-1.5-pro-latest" - model_10_pro = Mock( - display_name="Gemini 1.0 Pro", - supported_actions=["generateContent"], - ) - model_10_pro.name = "models/gemini-pro" - async def models_pager(): + yield model_25_flash yield model_20_flash yield model_15_flash yield model_15_pro - yield model_10_pro return models_pager() @@ -110,10 +111,100 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["data"] == { "api_key": "bla", } - assert result2["options"] == RECOMMENDED_OPTIONS + assert result2["options"] == {} + assert result2["subentries"] == [ + { + "subentry_type": "conversation", + "data": RECOMMENDED_OPTIONS, + "title": DEFAULT_CONVERSATION_NAME, + "unique_id": None, + } + ] assert len(mock_setup_entry.mock_calls) == 1 +async def test_duplicate_entry(hass: HomeAssistant) -> None: + """Test we get the form.""" + MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "bla"}, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "bla", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_creating_conversation_subentry( + hass: HomeAssistant, + mock_init_component: None, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation subentry.""" + with patch( + "google.genai.models.AsyncModels.list", + return_value=get_models_pager(), + ): + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "set_options" + assert not result["errors"] + + with patch( + "google.genai.models.AsyncModels.list", + return_value=get_models_pager(), + ): + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_NAME: "Mock name", **RECOMMENDED_OPTIONS}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Mock name" + + processed_options = RECOMMENDED_OPTIONS.copy() + processed_options[CONF_PROMPT] = processed_options[CONF_PROMPT].strip() + + assert result2["data"] == processed_options + + +async def test_creating_conversation_subentry_not_loaded( + hass: HomeAssistant, + mock_init_component: None, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation subentry.""" + await hass.config_entries.async_unload(mock_config_entry.entry_id) + with patch( + "google.genai.models.AsyncModels.list", + return_value=get_models_pager(), + ): + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "entry_not_loaded" + + def will_options_be_rendered_again(current_options, new_options) -> bool: """Determine if options will be rendered again.""" return current_options.get(CONF_RECOMMENDED) != new_options.get(CONF_RECOMMENDED) @@ -283,7 +374,7 @@ def will_options_be_rendered_again(current_options, new_options) -> bool: ], ) @pytest.mark.usefixtures("mock_init_component") -async def test_options_switching( +async def test_subentry_options_switching( hass: HomeAssistant, mock_config_entry: MockConfigEntry, current_options, @@ -292,17 +383,18 @@ async def test_options_switching( errors, ) -> None: """Test the options form.""" + subentry = next(iter(mock_config_entry.subentries.values())) with patch("google.genai.models.AsyncModels.get"): - hass.config_entries.async_update_entry( - mock_config_entry, options=current_options + hass.config_entries.async_update_subentry( + mock_config_entry, subentry, data=current_options ) await hass.async_block_till_done() with patch( "google.genai.models.AsyncModels.list", return_value=get_models_pager(), ): - options_flow = await hass.config_entries.options.async_init( - mock_config_entry.entry_id + options_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id ) if will_options_be_rendered_again(current_options, new_options): retry_options = { @@ -313,7 +405,7 @@ async def test_options_switching( "google.genai.models.AsyncModels.list", return_value=get_models_pager(), ): - options_flow = await hass.config_entries.options.async_configure( + options_flow = await hass.config_entries.subentries.async_configure( options_flow["flow_id"], retry_options, ) @@ -321,14 +413,15 @@ async def test_options_switching( "google.genai.models.AsyncModels.list", return_value=get_models_pager(), ): - options = await hass.config_entries.options.async_configure( + options = await hass.config_entries.subentries.async_configure( options_flow["flow_id"], new_options, ) - await hass.async_block_till_done() + await hass.async_block_till_done() if errors is None: - assert options["type"] is FlowResultType.CREATE_ENTRY - assert options["data"] == expected_options + assert options["type"] is FlowResultType.ABORT + assert options["reason"] == "reconfigure_successful" + assert subentry.data == expected_options else: assert options["type"] is FlowResultType.FORM @@ -375,7 +468,10 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: """Test the reauth flow.""" hass.config.components.add("google_generative_ai_conversation") mock_config_entry = MockConfigEntry( - domain=DOMAIN, state=config_entries.ConfigEntryState.LOADED, title="Gemini" + domain=DOMAIN, + state=config_entries.ConfigEntryState.LOADED, + title="Gemini", + version=2, ) mock_config_entry.add_to_hass(hass) mock_config_entry.async_start_reauth(hass) diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 92aa6f08d42..ff9694257f9 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -64,7 +64,7 @@ async def test_error_handling( "hello", None, Context(), - agent_id="conversation.google_generative_ai_conversation", + agent_id="conversation.google_ai_conversation", ) assert result.response.response_type == intent.IntentResponseType.ERROR, result assert result.response.error_code == "unknown", result @@ -82,7 +82,7 @@ async def test_function_call( mock_send_message_stream: AsyncMock, ) -> None: """Test function calling.""" - agent_id = "conversation.google_generative_ai_conversation" + agent_id = "conversation.google_ai_conversation" context = Context() messages = [ @@ -212,7 +212,7 @@ async def test_google_search_tool_is_sent( mock_send_message_stream: AsyncMock, ) -> None: """Test if the Google Search tool is sent to the model.""" - agent_id = "conversation.google_generative_ai_conversation" + agent_id = "conversation.google_ai_conversation" context = Context() messages = [ @@ -278,7 +278,7 @@ async def test_blocked_response( mock_send_message_stream: AsyncMock, ) -> None: """Test blocked response.""" - agent_id = "conversation.google_generative_ai_conversation" + agent_id = "conversation.google_ai_conversation" context = Context() messages = [ @@ -328,7 +328,7 @@ async def test_empty_response( ) -> None: """Test empty response.""" - agent_id = "conversation.google_generative_ai_conversation" + agent_id = "conversation.google_ai_conversation" context = Context() messages = [ @@ -371,7 +371,7 @@ async def test_none_response( mock_send_message_stream: AsyncMock, ) -> None: """Test None response.""" - agent_id = "conversation.google_generative_ai_conversation" + agent_id = "conversation.google_ai_conversation" context = Context() messages = [ @@ -403,10 +403,12 @@ async def test_converse_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test handling ChatLog raising ConverseError.""" + subentry = next(iter(mock_config_entry.subentries.values())) with patch("google.genai.models.AsyncModels.get"): - hass.config_entries.async_update_entry( + hass.config_entries.async_update_subentry( mock_config_entry, - options={**mock_config_entry.options, CONF_LLM_HASS_API: "invalid_llm_api"}, + next(iter(mock_config_entry.subentries.values())), + data={**subentry.data, CONF_LLM_HASS_API: "invalid_llm_api"}, ) await hass.async_block_till_done() @@ -415,7 +417,7 @@ async def test_converse_error( "hello", None, Context(), - agent_id="conversation.google_generative_ai_conversation", + agent_id="conversation.google_ai_conversation", ) assert result.response.response_type == intent.IntentResponseType.ERROR, result @@ -593,7 +595,7 @@ async def test_empty_content_in_chat_history( mock_send_message_stream: AsyncMock, ) -> None: """Tests that in case of an empty entry in the chat history the google API will receive an injected space sign instead.""" - agent_id = "conversation.google_generative_ai_conversation" + agent_id = "conversation.google_ai_conversation" context = Context() messages = [ @@ -648,7 +650,7 @@ async def test_history_always_user_first_turn( ) -> None: """Test that the user is always first in the chat history.""" - agent_id = "conversation.google_generative_ai_conversation" + agent_id = "conversation.google_ai_conversation" context = Context() messages = [ @@ -674,7 +676,7 @@ async def test_history_always_user_first_turn( mock_chat_log.async_add_assistant_content_without_tools( conversation.AssistantContent( - agent_id="conversation.google_generative_ai_conversation", + agent_id="conversation.google_ai_conversation", content="Garage door left open, do you want to close it?", ) ) diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 6cc0bdd5f44..8de678213c2 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -7,9 +7,12 @@ import pytest from requests.exceptions import Timeout from syrupy.assertion import SnapshotAssertion +from homeassistant.components.google_generative_ai_conversation.const import DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import API_ERROR_500, CLIENT_ERROR_API_KEY_INVALID @@ -387,3 +390,335 @@ async def test_load_entry_with_unloaded_entries( "text": stubbed_generated_content, } assert [tuple(mock_call) for mock_call in mock_generate.mock_calls] == snapshot + + +async def test_migration_from_v1_to_v2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 1 to version 2.""" + # Create a v1 config entry with conversation options and an entity + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "models/gemini-2.0-flash", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "1234"}, + options=options, + version=1, + title="Google Generative AI", + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "1234"}, + options=options, + version=1, + title="Google Generative AI 2", + ) + mock_config_entry_2.add_to_hass(hass) + + device_1 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Google", + model="Generative AI", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device_1.id, + suggested_object_id="google_generative_ai_conversation", + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry_2.entry_id, + identifiers={(DOMAIN, mock_config_entry_2.entry_id)}, + name=mock_config_entry_2.title, + manufacturer="Google", + model="Generative AI", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry_2.entry_id, + config_entry=mock_config_entry_2, + device_id=device_2.id, + suggested_object_id="google_generative_ai_conversation_2", + ) + + # Run migration + with patch( + "homeassistant.components.google_generative_ai_conversation.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.version == 2 + assert not entry.options + assert len(entry.subentries) == 2 + for subentry in entry.subentries.values(): + assert subentry.subentry_type == "conversation" + assert subentry.data == options + assert "Google Generative AI" in subentry.title + + subentry = list(entry.subentries.values())[0] + + entity = entity_registry.async_get("conversation.google_generative_ai_conversation") + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_1.id + + subentry = list(entry.subentries.values())[1] + + entity = entity_registry.async_get( + "conversation.google_generative_ai_conversation_2" + ) + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry_2.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_2.id + + +async def test_migration_from_v1_to_v2_with_multiple_keys( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 1 to version 2.""" + # Create a v1 config entry with conversation options and an entity + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "models/gemini-2.0-flash", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "1234"}, + options=options, + version=1, + title="Google Generative AI", + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "12345"}, + options=options, + version=1, + title="Google Generative AI 2", + ) + mock_config_entry_2.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Google", + model="Generative AI", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device.id, + suggested_object_id="google_generative_ai_conversation", + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry_2.entry_id, + identifiers={(DOMAIN, mock_config_entry_2.entry_id)}, + name=mock_config_entry_2.title, + manufacturer="Google", + model="Generative AI", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry_2.entry_id, + config_entry=mock_config_entry_2, + device_id=device_2.id, + suggested_object_id="google_generative_ai_conversation_2", + ) + + # Run migration + with patch( + "homeassistant.components.google_generative_ai_conversation.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 2 + + for entry in entries: + assert entry.version == 2 + assert not entry.options + assert len(entry.subentries) == 1 + subentry = list(entry.subentries.values())[0] + assert subentry.subentry_type == "conversation" + assert subentry.data == options + assert "Google Generative AI" in subentry.title + + dev = device_registry.async_get_device( + identifiers={(DOMAIN, list(entry.subentries.values())[0].subentry_id)} + ) + assert dev is not None + + +async def test_migration_from_v1_to_v2_with_same_keys( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 1 to version 2 with same API keys.""" + # Create v1 config entries with the same API key + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "models/gemini-2.0-flash", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "1234"}, + options=options, + version=1, + title="Google Generative AI", + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "1234"}, + options=options, + version=1, + title="Google Generative AI 2", + ) + mock_config_entry_2.add_to_hass(hass) + + device_1 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Google", + model="Generative AI", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device_1.id, + suggested_object_id="google_generative_ai_conversation", + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry_2.entry_id, + identifiers={(DOMAIN, mock_config_entry_2.entry_id)}, + name=mock_config_entry_2.title, + manufacturer="Google", + model="Generative AI", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry_2.entry_id, + config_entry=mock_config_entry_2, + device_id=device_2.id, + suggested_object_id="google_generative_ai_conversation_2", + ) + + # Run migration + with patch( + "homeassistant.components.google_generative_ai_conversation.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.version == 2 + assert not entry.options + assert len(entry.subentries) == 2 + for subentry in entry.subentries.values(): + assert subentry.subentry_type == "conversation" + assert subentry.data == options + assert "Google Generative AI" in subentry.title + + subentry = list(entry.subentries.values())[0] + + entity = entity_registry.async_get("conversation.google_generative_ai_conversation") + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_1.id + + subentry = list(entry.subentries.values())[1] + + entity = entity_registry.async_get( + "conversation.google_generative_ai_conversation_2" + ) + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry_2.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_2.id diff --git a/tests/components/google_generative_ai_conversation/test_tts.py b/tests/components/google_generative_ai_conversation/test_tts.py index 5ea056307b5..4f197f0535f 100644 --- a/tests/components/google_generative_ai_conversation/test_tts.py +++ b/tests/components/google_generative_ai_conversation/test_tts.py @@ -122,7 +122,9 @@ async def mock_setup(hass: HomeAssistant, config: dict[str, Any]) -> None: async def mock_config_entry_setup(hass: HomeAssistant, config: dict[str, Any]) -> None: """Mock config entry setup.""" default_config = {tts.CONF_LANG: "en-US"} - config_entry = MockConfigEntry(domain=DOMAIN, data=default_config | config) + config_entry = MockConfigEntry( + domain=DOMAIN, data=default_config | config, version=2 + ) client_mock = Mock() client_mock.models.get = None diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index 56f7ffaa5b9..a71ee370b32 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -260,16 +260,3 @@ def all_setup_requests( }, }, ) - - -@pytest.fixture -def arch() -> str: - """Arch found in apk file.""" - return "amd64" - - -@pytest.fixture(autouse=True) -def mock_arch_file(arch: str) -> Generator[None]: - """Mock arch file.""" - with patch("homeassistant.components.hassio._get_arch", return_value=arch): - yield diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index f424beedc85..2874ea726dc 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -1156,10 +1156,6 @@ def test_deprecated_constants( ("rpi2", "deprecated_os_armv7"), ], ) -@pytest.mark.parametrize( - "arch", - ["armv7"], -) async def test_deprecated_installation_issue_os_armv7( hass: HomeAssistant, issue_registry: ir.IssueRegistry, @@ -1170,13 +1166,6 @@ async def test_deprecated_installation_issue_os_armv7( """Test deprecated installation issue.""" with ( patch.dict(os.environ, MOCK_ENVIRON), - patch( - "homeassistant.components.homeassistant.async_get_system_info", - return_value={ - "installation_type": "Home Assistant OS", - "arch": "armv7", - }, - ), patch( "homeassistant.components.hassio._is_32_bit", return_value=True, @@ -1185,7 +1174,8 @@ async def test_deprecated_installation_issue_os_armv7( "homeassistant.components.hassio.get_os_info", return_value={"board": board} ), patch( - "homeassistant.components.hassio.get_info", return_value={"hassos": True} + "homeassistant.components.hassio.get_info", + return_value={"hassos": True, "arch": "armv7"}, ), patch("homeassistant.components.hardware.async_setup", return_value=True), ): @@ -1238,13 +1228,6 @@ async def test_deprecated_installation_issue_32bit_os( """Test deprecated architecture issue.""" with ( patch.dict(os.environ, MOCK_ENVIRON), - patch( - "homeassistant.components.homeassistant.async_get_system_info", - return_value={ - "installation_type": "Home Assistant OS", - "arch": arch, - }, - ), patch( "homeassistant.components.hassio._is_32_bit", return_value=True, @@ -1254,7 +1237,8 @@ async def test_deprecated_installation_issue_32bit_os( return_value={"board": "rpi3-64"}, ), patch( - "homeassistant.components.hassio.get_info", return_value={"hassos": True} + "homeassistant.components.hassio.get_info", + return_value={"hassos": True, "arch": arch}, ), patch("homeassistant.components.hardware.async_setup", return_value=True), ): @@ -1305,13 +1289,6 @@ async def test_deprecated_installation_issue_32bit_supervised( """Test deprecated architecture issue.""" with ( patch.dict(os.environ, MOCK_ENVIRON), - patch( - "homeassistant.components.homeassistant.async_get_system_info", - return_value={ - "installation_type": "Home Assistant Supervised", - "arch": arch, - }, - ), patch( "homeassistant.components.hassio._is_32_bit", return_value=True, @@ -1321,7 +1298,8 @@ async def test_deprecated_installation_issue_32bit_supervised( return_value={"board": "rpi3-64"}, ), patch( - "homeassistant.components.hassio.get_info", return_value={"hassos": None} + "homeassistant.components.hassio.get_info", + return_value={"hassos": None, "arch": arch}, ), patch("homeassistant.components.hardware.async_setup", return_value=True), ): @@ -1376,13 +1354,6 @@ async def test_deprecated_installation_issue_64bit_supervised( """Test deprecated architecture issue.""" with ( patch.dict(os.environ, MOCK_ENVIRON), - patch( - "homeassistant.components.homeassistant.async_get_system_info", - return_value={ - "installation_type": "Home Assistant Supervised", - "arch": arch, - }, - ), patch( "homeassistant.components.hassio._is_32_bit", return_value=False, @@ -1392,7 +1363,8 @@ async def test_deprecated_installation_issue_64bit_supervised( return_value={"board": "generic-x86-64"}, ), patch( - "homeassistant.components.hassio.get_info", return_value={"hassos": None} + "homeassistant.components.hassio.get_info", + return_value={"hassos": None, "arch": arch}, ), patch("homeassistant.components.hardware.async_setup", return_value=True), ): @@ -1445,13 +1417,6 @@ async def test_deprecated_installation_issue_supported_board( """Test no deprecated installation issue for a supported board.""" with ( patch.dict(os.environ, MOCK_ENVIRON), - patch( - "homeassistant.components.homeassistant.async_get_system_info", - return_value={ - "installation_type": "Home Assistant OS", - "arch": "aarch64", - }, - ), patch( "homeassistant.components.hassio._is_32_bit", return_value=False, @@ -1460,7 +1425,8 @@ async def test_deprecated_installation_issue_supported_board( "homeassistant.components.hassio.get_os_info", return_value={"board": board} ), patch( - "homeassistant.components.hassio.get_info", return_value={"hassos": True} + "homeassistant.components.hassio.get_info", + return_value={"hassos": True, "arch": "aarch64"}, ), ): assert await async_setup_component(hass, "homeassistant", {}) diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index 0779339cf65..80211c48eed 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -24,6 +24,7 @@ from homeassistant.const import ( ENTITY_MATCH_ALL, ENTITY_MATCH_NONE, EVENT_CORE_CONFIG_UPDATE, + EVENT_HOMEASSISTANT_STARTED, SERVICE_SAVE_PERSISTENT_STATES, SERVICE_TOGGLE, SERVICE_TURN_OFF, @@ -668,6 +669,7 @@ async def test_deprecated_installation_issue_32bit_core( ), ): assert await async_setup_component(hass, DOMAIN, {}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() assert len(issue_registry.issues) == 1 @@ -707,6 +709,7 @@ async def test_deprecated_installation_issue_64bit_core( ), ): assert await async_setup_component(hass, DOMAIN, {}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() assert len(issue_registry.issues) == 1 @@ -738,6 +741,7 @@ async def test_deprecated_installation_issue_32bit( "homeassistant.components.homeassistant.async_get_system_info", return_value={ "installation_type": "Home Assistant Container", + "container_arch": arch, "arch": arch, }, ), @@ -745,12 +749,9 @@ async def test_deprecated_installation_issue_32bit( "homeassistant.components.homeassistant._is_32_bit", return_value=True, ), - patch( - "homeassistant.components.homeassistant._get_arch", - return_value=arch, - ), ): assert await async_setup_component(hass, DOMAIN, {}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() assert len(issue_registry.issues) == 1 diff --git a/tests/components/homewizard/fixtures/v2/HWE-KWH1/batteries.json b/tests/components/homewizard/fixtures/v2/HWE-KWH1/batteries.json new file mode 100644 index 00000000000..279e49606b3 --- /dev/null +++ b/tests/components/homewizard/fixtures/v2/HWE-KWH1/batteries.json @@ -0,0 +1,7 @@ +{ + "mode": "zero", + "power_w": -404, + "target_power_w": -400, + "max_consumption_w": 1600, + "max_production_w": 800 +} diff --git a/tests/components/homewizard/fixtures/v2/HWE-KWH1/device.json b/tests/components/homewizard/fixtures/v2/HWE-KWH1/device.json new file mode 100644 index 00000000000..efac68ded02 --- /dev/null +++ b/tests/components/homewizard/fixtures/v2/HWE-KWH1/device.json @@ -0,0 +1,7 @@ +{ + "product_type": "HWE-KWH1", + "product_name": "kWh Meter 1-phase", + "serial": "5c2fafabcdef", + "firmware_version": "4.19", + "api_version": "2.0.0" +} diff --git a/tests/components/homewizard/fixtures/v2/HWE-KWH1/measurement.json b/tests/components/homewizard/fixtures/v2/HWE-KWH1/measurement.json new file mode 100644 index 00000000000..0c52ce17516 --- /dev/null +++ b/tests/components/homewizard/fixtures/v2/HWE-KWH1/measurement.json @@ -0,0 +1,13 @@ +{ + "energy_import_kwh": 123.456, + "energy_export_kwh": 78.91, + "power_w": 123, + "voltage_v": 230, + "current_a": 1.5, + "apparent_current_a": 1.6, + "reactive_current_a": 0.5, + "apparent_power_va": 345, + "reactive_power_var": 67, + "power_factor": 0.95, + "frequency_hz": 50 +} diff --git a/tests/components/homewizard/fixtures/v2/HWE-KWH1/system.json b/tests/components/homewizard/fixtures/v2/HWE-KWH1/system.json new file mode 100644 index 00000000000..3ef59c93aba --- /dev/null +++ b/tests/components/homewizard/fixtures/v2/HWE-KWH1/system.json @@ -0,0 +1,7 @@ +{ + "wifi_ssid": "My Wi-Fi", + "wifi_rssi_db": -77, + "cloud_enabled": false, + "uptime_s": 356, + "api_v1_enabled": true +} diff --git a/tests/components/homewizard/test_config_flow.py b/tests/components/homewizard/test_config_flow.py index c39853c3f9a..feb0e8ed0f0 100644 --- a/tests/components/homewizard/test_config_flow.py +++ b/tests/components/homewizard/test_config_flow.py @@ -620,6 +620,7 @@ async def test_reconfigure_cannot_connect( @pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize(("device_fixture"), ["HWE-P1", "HWE-KWH1"]) async def test_manual_flow_works_with_v2_api_support( hass: HomeAssistant, mock_homewizardenergy_v2: MagicMock, @@ -659,6 +660,7 @@ async def test_manual_flow_works_with_v2_api_support( @pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize(("device_fixture"), ["HWE-P1", "HWE-KWH1"]) async def test_manual_flow_detects_failed_user_authorization( hass: HomeAssistant, mock_homewizardenergy_v2: MagicMock, @@ -704,6 +706,7 @@ async def test_manual_flow_detects_failed_user_authorization( @pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize(("device_fixture"), ["HWE-P1", "HWE-KWH1"]) async def test_reauth_flow_updates_token( hass: HomeAssistant, mock_setup_entry: AsyncMock, @@ -739,6 +742,7 @@ async def test_reauth_flow_updates_token( @pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize(("device_fixture"), ["HWE-P1", "HWE-KWH1"]) async def test_reauth_flow_handles_user_not_pressing_button( hass: HomeAssistant, mock_setup_entry: AsyncMock, @@ -785,9 +789,9 @@ async def test_reauth_flow_handles_user_not_pressing_button( @pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize(("device_fixture"), ["HWE-P1", "HWE-KWH1"]) async def test_discovery_with_v2_api_ask_authorization( hass: HomeAssistant, - # mock_setup_entry: AsyncMock, mock_homewizardenergy_v2: MagicMock, ) -> None: """Test discovery detecting missing discovery info.""" diff --git a/tests/components/homewizard/test_init.py b/tests/components/homewizard/test_init.py index be811355e1d..b0562afbb3d 100644 --- a/tests/components/homewizard/test_init.py +++ b/tests/components/homewizard/test_init.py @@ -38,6 +38,7 @@ async def test_load_unload_v1( assert weak_ref() is None +@pytest.mark.parametrize(("device_fixture"), ["HWE-P1", "HWE-KWH1"]) async def test_load_unload_v2( hass: HomeAssistant, mock_config_entry_v2: MockConfigEntry, diff --git a/tests/components/juicenet/test_config_flow.py b/tests/components/juicenet/test_config_flow.py deleted file mode 100644 index 48d63cd8cd0..00000000000 --- a/tests/components/juicenet/test_config_flow.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Test the JuiceNet config flow.""" - -from unittest.mock import MagicMock, patch - -import aiohttp -from pyjuicenet import TokenError - -from homeassistant import config_entries -from homeassistant.components.juicenet.const import DOMAIN -from homeassistant.const import CONF_ACCESS_TOKEN -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType - - -def _mock_juicenet_return_value(get_devices=None): - juicenet_mock = MagicMock() - type(juicenet_mock).get_devices = MagicMock(return_value=get_devices) - return juicenet_mock - - -async def test_form(hass: HomeAssistant) -> None: - """Test we get the form.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - - with ( - patch( - "homeassistant.components.juicenet.config_flow.Api.get_devices", - return_value=MagicMock(), - ), - patch( - "homeassistant.components.juicenet.async_setup", return_value=True - ) as mock_setup, - patch( - "homeassistant.components.juicenet.async_setup_entry", return_value=True - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: "access_token"} - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "JuiceNet" - assert result2["data"] == {CONF_ACCESS_TOKEN: "access_token"} - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_form_invalid_auth(hass: HomeAssistant) -> None: - """Test we handle invalid auth.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.juicenet.config_flow.Api.get_devices", - side_effect=TokenError, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: "access_token"} - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} - - -async def test_form_cannot_connect(hass: HomeAssistant) -> None: - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.juicenet.config_flow.Api.get_devices", - side_effect=aiohttp.ClientError, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: "access_token"} - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_form_catch_unknown_errors(hass: HomeAssistant) -> None: - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.juicenet.config_flow.Api.get_devices", - side_effect=Exception, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: "access_token"} - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} - - -async def test_import(hass: HomeAssistant) -> None: - """Test that import works as expected.""" - - with ( - patch( - "homeassistant.components.juicenet.config_flow.Api.get_devices", - return_value=MagicMock(), - ), - patch( - "homeassistant.components.juicenet.async_setup", return_value=True - ) as mock_setup, - patch( - "homeassistant.components.juicenet.async_setup_entry", return_value=True - ) as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_ACCESS_TOKEN: "access_token"}, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "JuiceNet" - assert result["data"] == {CONF_ACCESS_TOKEN: "access_token"} - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/juicenet/test_init.py b/tests/components/juicenet/test_init.py new file mode 100644 index 00000000000..8896798abe3 --- /dev/null +++ b/tests/components/juicenet/test_init.py @@ -0,0 +1,50 @@ +"""Tests for the JuiceNet component.""" + +from homeassistant.components.juicenet import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from tests.common import MockConfigEntry + + +async def test_juicenet_repair_issue( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test the JuiceNet configuration entry loading/unloading handles the repair.""" + config_entry_1 = MockConfigEntry( + title="Example 1", + domain=DOMAIN, + ) + config_entry_1.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_1.entry_id) + await hass.async_block_till_done() + assert config_entry_1.state is ConfigEntryState.LOADED + + # Add a second one + config_entry_2 = MockConfigEntry( + title="Example 2", + domain=DOMAIN, + ) + config_entry_2.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_2.entry_id) + await hass.async_block_till_done() + + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + # Remove the first one + await hass.config_entries.async_remove(config_entry_1.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + # Remove the second one + await hass.config_entries.async_remove(config_entry_2.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.NOT_LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None diff --git a/tests/components/keenetic_ndms2/test_config_flow.py b/tests/components/keenetic_ndms2/test_config_flow.py index c8e23786e68..3293bd3d4da 100644 --- a/tests/components/keenetic_ndms2/test_config_flow.py +++ b/tests/components/keenetic_ndms2/test_config_flow.py @@ -6,10 +6,11 @@ from unittest.mock import Mock, patch from ndms2_client import ConnectionException from ndms2_client.client import InterfaceInfo, RouterInfo import pytest +import voluptuous as vol from homeassistant import config_entries from homeassistant.components import keenetic_ndms2 as keenetic -from homeassistant.components.keenetic_ndms2 import const +from homeassistant.components.keenetic_ndms2 import CONF_INTERFACES, const from homeassistant.const import CONF_HOST, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -145,6 +146,70 @@ async def test_connection_error(hass: HomeAssistant, connect_error) -> None: assert result["errors"] == {"base": "cannot_connect"} +async def test_options_not_initialized(hass: HomeAssistant) -> None: + """Test the error when the integration is not initialized.""" + + entry = MockConfigEntry(domain=keenetic.DOMAIN, data=MOCK_DATA) + entry.add_to_hass(hass) + + # not setting entry.runtime_data + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "not_initialized" + + +async def test_options_connection_error(hass: HomeAssistant) -> None: + """Test updating options.""" + + entry = MockConfigEntry(domain=keenetic.DOMAIN, data=MOCK_DATA) + entry.add_to_hass(hass) + + def get_interfaces_error(): + raise ConnectionException("Mocked failure") + + # fake with connection error + entry.runtime_data = Mock( + client=Mock(get_interfaces=Mock(wraps=get_interfaces_error)) + ) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_options_interface_filter(hass: HomeAssistant) -> None: + """Test the case when the default Home interface is missing on the router.""" + + entry = MockConfigEntry(domain=keenetic.DOMAIN, data=MOCK_DATA) + entry.add_to_hass(hass) + + # fake interfaces + entry.runtime_data = Mock( + client=Mock( + get_interfaces=Mock( + return_value=[ + InterfaceInfo.from_dict({"id": name, "type": "bridge"}) + for name in ("not_a_home", "also_not_home") + ] + ) + ) + ) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] is FlowResultType.FORM + interfaces_schema = next( + i + for i, s in result["data_schema"].schema.items() + if i.schema == CONF_INTERFACES + ) + assert isinstance(interfaces_schema, vol.Required) + assert interfaces_schema.default() == [] + + async def test_ssdp_works(hass: HomeAssistant, connect) -> None: """Test host already configured and discovered.""" diff --git a/tests/components/kitchen_sink/test_config_flow.py b/tests/components/kitchen_sink/test_config_flow.py index 88bacc2cb0b..bc85edc592d 100644 --- a/tests/components/kitchen_sink/test_config_flow.py +++ b/tests/components/kitchen_sink/test_config_flow.py @@ -171,9 +171,7 @@ async def test_subentry_reconfigure_flow(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - result = await config_entry.start_subentry_reconfigure_flow( - hass, "entity", subentry_id - ) + result = await config_entry.start_subentry_reconfigure_flow(hass, subentry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reconfigure_sensor" diff --git a/tests/components/lametric/conftest.py b/tests/components/lametric/conftest.py index da86d1bc4de..f8837054691 100644 --- a/tests/components/lametric/conftest.py +++ b/tests/components/lametric/conftest.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Generator +from contextlib import nullcontext from unittest.mock import AsyncMock, MagicMock, patch from demetriek import CloudDevice, Device @@ -97,12 +98,20 @@ def mock_lametric(device_fixture: str) -> Generator[MagicMock]: @pytest.fixture async def init_integration( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_lametric: MagicMock + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_lametric: MagicMock, + request: pytest.FixtureRequest, ) -> MockConfigEntry: """Set up the LaMetric integration for testing.""" mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + context = nullcontext() + if platform := getattr(request, "param", None): + context = patch("homeassistant.components.lametric.PLATFORMS", [platform]) + + with context: + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() return mock_config_entry diff --git a/tests/components/lametric/fixtures/device.json b/tests/components/lametric/fixtures/device.json index a184d9f0aa1..bf2580a0c5d 100644 --- a/tests/components/lametric/fixtures/device.json +++ b/tests/components/lametric/fixtures/device.json @@ -12,7 +12,7 @@ }, "bluetooth": { "active": false, - "address": "AA:BB:CC:DD:EE:FF", + "address": "AA:BB:CC:DD:EE:EE", "available": true, "discoverable": true, "low_energy": { diff --git a/tests/components/lametric/fixtures/device_sa5.json b/tests/components/lametric/fixtures/device_sa5.json index 47120f672ef..b82a4bda2af 100644 --- a/tests/components/lametric/fixtures/device_sa5.json +++ b/tests/components/lametric/fixtures/device_sa5.json @@ -57,6 +57,9 @@ "name": "spyfly's LaMetric SKY", "os_version": "3.0.13", "serial_number": "SA52100000123TBNC", + "update_available": { + "version": "3.2.1" + }, "wifi": { "active": true, "mac": "AA:BB:CC:DD:EE:FF", diff --git a/tests/components/lametric/snapshots/test_diagnostics.ambr b/tests/components/lametric/snapshots/test_diagnostics.ambr index bc16e318a73..ea9dfdde92f 100644 --- a/tests/components/lametric/snapshots/test_diagnostics.ambr +++ b/tests/components/lametric/snapshots/test_diagnostics.ambr @@ -15,7 +15,7 @@ }), 'bluetooth': dict({ 'active': False, - 'address': 'AA:BB:CC:DD:EE:FF', + 'address': 'AA:BB:CC:DD:EE:EE', 'available': True, 'discoverable': True, 'name': '**REDACTED**', diff --git a/tests/components/lametric/snapshots/test_update.ambr b/tests/components/lametric/snapshots/test_update.ambr new file mode 100644 index 00000000000..342cac5b39b --- /dev/null +++ b/tests/components/lametric/snapshots/test_update.ambr @@ -0,0 +1,62 @@ +# serializer version: 1 +# name: test_all_entities[device_sa5-update][update.spyfly_s_lametric_sky_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.spyfly_s_lametric_sky_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'lametric', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'SA52100000123TBNC-update', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[device_sa5-update][update.spyfly_s_lametric_sky_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/lametric/icon.png', + 'friendly_name': "spyfly's LaMetric SKY Firmware", + 'in_progress': False, + 'installed_version': '3.0.13', + 'latest_version': '3.2.1', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.spyfly_s_lametric_sky_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/lametric/test_button.py b/tests/components/lametric/test_button.py index cf8d76ca5f3..e42e3248a73 100644 --- a/tests/components/lametric/test_button.py +++ b/tests/components/lametric/test_button.py @@ -44,7 +44,8 @@ async def test_button_app_next( assert device_entry assert device_entry.configuration_url == "https://127.0.0.1/" assert device_entry.connections == { - (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff") + (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff"), + (dr.CONNECTION_BLUETOOTH, "aa:bb:cc:dd:ee:ee"), } assert device_entry.entry_type is None assert device_entry.identifiers == {(DOMAIN, "SA110405124500W00BS9")} @@ -91,7 +92,8 @@ async def test_button_app_previous( assert device_entry assert device_entry.configuration_url == "https://127.0.0.1/" assert device_entry.connections == { - (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff") + (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff"), + (dr.CONNECTION_BLUETOOTH, "aa:bb:cc:dd:ee:ee"), } assert device_entry.entry_type is None assert device_entry.identifiers == {(DOMAIN, "SA110405124500W00BS9")} @@ -139,7 +141,8 @@ async def test_button_dismiss_current_notification( assert device_entry assert device_entry.configuration_url == "https://127.0.0.1/" assert device_entry.connections == { - (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff") + (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff"), + (dr.CONNECTION_BLUETOOTH, "aa:bb:cc:dd:ee:ee"), } assert device_entry.entry_type is None assert device_entry.identifiers == {(DOMAIN, "SA110405124500W00BS9")} @@ -187,7 +190,8 @@ async def test_button_dismiss_all_notifications( assert device_entry assert device_entry.configuration_url == "https://127.0.0.1/" assert device_entry.connections == { - (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff") + (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff"), + (dr.CONNECTION_BLUETOOTH, "aa:bb:cc:dd:ee:ee"), } assert device_entry.entry_type is None assert device_entry.identifiers == {(DOMAIN, "SA110405124500W00BS9")} diff --git a/tests/components/lametric/test_number.py b/tests/components/lametric/test_number.py index f34cf04aed9..dea693e86aa 100644 --- a/tests/components/lametric/test_number.py +++ b/tests/components/lametric/test_number.py @@ -56,7 +56,10 @@ async def test_brightness( device = device_registry.async_get(entry.device_id) assert device assert device.configuration_url == "https://127.0.0.1/" - assert device.connections == {(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")} + assert device.connections == { + (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff"), + (dr.CONNECTION_BLUETOOTH, "aa:bb:cc:dd:ee:ee"), + } assert device.entry_type is None assert device.hw_version is None assert device.identifiers == {(DOMAIN, "SA110405124500W00BS9")} @@ -105,7 +108,10 @@ async def test_volume( device = device_registry.async_get(entry.device_id) assert device assert device.configuration_url == "https://127.0.0.1/" - assert device.connections == {(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")} + assert device.connections == { + (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff"), + (dr.CONNECTION_BLUETOOTH, "aa:bb:cc:dd:ee:ee"), + } assert device.entry_type is None assert device.hw_version is None assert device.identifiers == {(DOMAIN, "SA110405124500W00BS9")} diff --git a/tests/components/lametric/test_select.py b/tests/components/lametric/test_select.py index 177092f061e..e7a2ad52670 100644 --- a/tests/components/lametric/test_select.py +++ b/tests/components/lametric/test_select.py @@ -49,7 +49,10 @@ async def test_brightness_mode( device = device_registry.async_get(entry.device_id) assert device assert device.configuration_url == "https://127.0.0.1/" - assert device.connections == {(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")} + assert device.connections == { + (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff"), + (dr.CONNECTION_BLUETOOTH, "aa:bb:cc:dd:ee:ee"), + } assert device.entry_type is None assert device.hw_version is None assert device.identifiers == {(DOMAIN, "SA110405124500W00BS9")} diff --git a/tests/components/lametric/test_sensor.py b/tests/components/lametric/test_sensor.py index a0719edfc9d..9915b31d283 100644 --- a/tests/components/lametric/test_sensor.py +++ b/tests/components/lametric/test_sensor.py @@ -42,7 +42,10 @@ async def test_wifi_signal( device = device_registry.async_get(entry.device_id) assert device assert device.configuration_url == "https://127.0.0.1/" - assert device.connections == {(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")} + assert device.connections == { + (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff"), + (dr.CONNECTION_BLUETOOTH, "aa:bb:cc:dd:ee:ee"), + } assert device.entry_type is None assert device.hw_version is None assert device.identifiers == {(DOMAIN, "SA110405124500W00BS9")} diff --git a/tests/components/lametric/test_switch.py b/tests/components/lametric/test_switch.py index 155a315881f..252ced706d3 100644 --- a/tests/components/lametric/test_switch.py +++ b/tests/components/lametric/test_switch.py @@ -51,7 +51,10 @@ async def test_bluetooth( device = device_registry.async_get(entry.device_id) assert device assert device.configuration_url == "https://127.0.0.1/" - assert device.connections == {(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")} + assert device.connections == { + (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff"), + (dr.CONNECTION_BLUETOOTH, "aa:bb:cc:dd:ee:ee"), + } assert device.entry_type is None assert device.hw_version is None assert device.identifiers == {(DOMAIN, "SA110405124500W00BS9")} diff --git a/tests/components/lametric/test_update.py b/tests/components/lametric/test_update.py new file mode 100644 index 00000000000..f8e396bd582 --- /dev/null +++ b/tests/components/lametric/test_update.py @@ -0,0 +1,29 @@ +"""Tests for the LaMetric update platform.""" + +from unittest.mock import MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + +pytestmark = [ + pytest.mark.parametrize("init_integration", [Platform.UPDATE], indirect=True), + pytest.mark.usefixtures("init_integration"), +] + + +@pytest.mark.parametrize("device_fixture", ["device_sa5"]) +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_lametric: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 7de836b7092..17841121445 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -1486,6 +1486,68 @@ 'state': '0.0', }) # --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_energy_optimization_opt_out-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_opt_out', + 'local_opt_out', + 'grid_opt_out', + 'opt_out', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_battery_storage_energy_optimization_opt_out', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy optimization opt-out', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'esa_opt_out_state', + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-1-ESAOptOutState-152-7', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_energy_optimization_opt_out-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Mock Battery Storage Energy optimization opt-out', + 'options': list([ + 'no_opt_out', + 'local_opt_out', + 'grid_opt_out', + 'opt_out', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_battery_storage_energy_optimization_opt_out', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_opt_out', + }) +# --- # name: test_sensors[battery_storage][sensor.mock_battery_storage_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -4645,6 +4707,68 @@ 'state': '32.0', }) # --- +# name: test_sensors[silabs_evse_charging][sensor.evse_energy_optimization_opt_out-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_opt_out', + 'local_opt_out', + 'grid_opt_out', + 'opt_out', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.evse_energy_optimization_opt_out', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy optimization opt-out', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'esa_opt_out_state', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-ESAOptOutState-152-7', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_energy_optimization_opt_out-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'evse Energy optimization opt-out', + 'options': list([ + 'no_opt_out', + 'local_opt_out', + 'grid_opt_out', + 'opt_out', + ]), + }), + 'context': , + 'entity_id': 'sensor.evse_energy_optimization_opt_out', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_opt_out', + }) +# --- # name: test_sensors[silabs_evse_charging][sensor.evse_fault_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5389,6 +5513,68 @@ 'state': '0.1', }) # --- +# name: test_sensors[silabs_water_heater][sensor.water_heater_energy_optimization_opt_out-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_opt_out', + 'local_opt_out', + 'grid_opt_out', + 'opt_out', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.water_heater_energy_optimization_opt_out', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy optimization opt-out', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'esa_opt_out_state', + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-ESAOptOutState-152-7', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[silabs_water_heater][sensor.water_heater_energy_optimization_opt_out-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Water Heater Energy optimization opt-out', + 'options': list([ + 'no_opt_out', + 'local_opt_out', + 'grid_opt_out', + 'opt_out', + ]), + }), + 'context': , + 'entity_id': 'sensor.water_heater_energy_optimization_opt_out', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_opt_out', + }) +# --- # name: test_sensors[silabs_water_heater][sensor.water_heater_hot_water_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index e70101bf804..3e9af4a6e4b 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -524,6 +524,18 @@ async def test_water_heater( assert state assert state.state == "offline" + # DeviceEnergyManagement -> OptOutState attribute + state = hass.states.get("sensor.water_heater_energy_optimization_opt_out") + assert state + assert state.state == "no_opt_out" + + set_node_attribute(matter_node, 2, 152, 7, 3) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.water_heater_energy_optimization_opt_out") + assert state + assert state.state == "opt_out" + @pytest.mark.parametrize("node_fixture", ["pump"]) async def test_pump( diff --git a/tests/components/media_player/test_intent.py b/tests/components/media_player/test_intent.py index 4b08aa43158..d1dc03ed12a 100644 --- a/tests/components/media_player/test_intent.py +++ b/tests/components/media_player/test_intent.py @@ -792,3 +792,84 @@ async def test_search_and_play_media_player_intent(hass: HomeAssistant) -> None: media_player_intent.INTENT_MEDIA_SEARCH_AND_PLAY, {"search_query": {"value": "error query"}}, ) + + +async def test_search_and_play_media_player_intent_with_media_class( + hass: HomeAssistant, +) -> None: + """Test HassMediaSearchAndPlay intent with media_class parameter.""" + await media_player_intent.async_setup_intents(hass) + + entity_id = f"{DOMAIN}.test_media_player" + attributes = { + ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.SEARCH_MEDIA + | MediaPlayerEntityFeature.PLAY_MEDIA + } + hass.states.async_set(entity_id, STATE_IDLE, attributes=attributes) + + # Test successful search and play with media_class filter + search_result_item = BrowseMedia( + title="Test Album", + media_class=MediaClass.ALBUM, + media_content_type=MediaType.ALBUM, + media_content_id="library/album/123", + can_play=True, + can_expand=False, + ) + + # Mock service calls + search_results = [search_result_item] + search_calls = async_mock_service( + hass, + DOMAIN, + SERVICE_SEARCH_MEDIA, + response={entity_id: SearchMedia(result=search_results)}, + ) + play_calls = async_mock_service(hass, DOMAIN, SERVICE_PLAY_MEDIA) + + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_SEARCH_AND_PLAY, + {"search_query": {"value": "test album"}, "media_class": {"value": "album"}}, + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + + # Response should contain a "media" slot with the matched item. + assert not response.speech + media = response.speech_slots.get("media") + assert media["title"] == "Test Album" + + assert len(search_calls) == 1 + search_call = search_calls[0] + assert search_call.domain == DOMAIN + assert search_call.service == SERVICE_SEARCH_MEDIA + assert search_call.data == { + "entity_id": entity_id, + "search_query": "test album", + "media_filter_classes": ["album"], + } + + assert len(play_calls) == 1 + play_call = play_calls[0] + assert play_call.domain == DOMAIN + assert play_call.service == SERVICE_PLAY_MEDIA + assert play_call.data == { + "entity_id": entity_id, + "media_content_id": search_result_item.media_content_id, + "media_content_type": search_result_item.media_content_type, + } + + # Test with invalid media_class (should raise validation error) + with pytest.raises(intent.InvalidSlotInfo): + await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_SEARCH_AND_PLAY, + { + "search_query": {"value": "test query"}, + "media_class": {"value": "invalid_class"}, + }, + ) diff --git a/tests/components/miele/snapshots/test_sensor.ambr b/tests/components/miele/snapshots/test_sensor.ambr index 6984fcc4c50..b1691c28b19 100644 --- a/tests/components/miele/snapshots/test_sensor.ambr +++ b/tests/components/miele/snapshots/test_sensor.ambr @@ -97,30 +97,26 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ - '0', - '110', - '220', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - '10', - '11', - '12', - '13', - '14', - '15', - '16', - '17', - '18', - '117', - '118', - '217', + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_warming', ]), }), 'config_entry_id': , @@ -158,30 +154,26 @@ 'device_class': 'enum', 'friendly_name': 'Hob with extraction Plate 1', 'options': list([ - '0', - '110', - '220', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - '10', - '11', - '12', - '13', - '14', - '15', - '16', - '17', - '18', - '117', - '118', - '217', + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_warming', ]), }), 'context': , @@ -189,7 +181,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': 'plate_step_0', }) # --- # name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction_plate_2-entry] @@ -199,30 +191,26 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ - '0', - '110', - '220', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - '10', - '11', - '12', - '13', - '14', - '15', - '16', - '17', - '18', - '117', - '118', - '217', + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_warming', ]), }), 'config_entry_id': , @@ -260,30 +248,26 @@ 'device_class': 'enum', 'friendly_name': 'Hob with extraction Plate 2', 'options': list([ - '0', - '110', - '220', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - '10', - '11', - '12', - '13', - '14', - '15', - '16', - '17', - '18', - '117', - '118', - '217', + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_warming', ]), }), 'context': , @@ -291,7 +275,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '110', + 'state': 'plate_step_warming', }) # --- # name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction_plate_3-entry] @@ -301,30 +285,26 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ - '0', - '110', - '220', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - '10', - '11', - '12', - '13', - '14', - '15', - '16', - '17', - '18', - '117', - '118', - '217', + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_warming', ]), }), 'config_entry_id': , @@ -362,30 +342,26 @@ 'device_class': 'enum', 'friendly_name': 'Hob with extraction Plate 3', 'options': list([ - '0', - '110', - '220', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - '10', - '11', - '12', - '13', - '14', - '15', - '16', - '17', - '18', - '117', - '118', - '217', + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_warming', ]), }), 'context': , @@ -393,7 +369,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '8', + 'state': 'plate_step_8', }) # --- # name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction_plate_4-entry] @@ -403,30 +379,26 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ - '0', - '110', - '220', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - '10', - '11', - '12', - '13', - '14', - '15', - '16', - '17', - '18', - '117', - '118', - '217', + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_warming', ]), }), 'config_entry_id': , @@ -464,30 +436,26 @@ 'device_class': 'enum', 'friendly_name': 'Hob with extraction Plate 4', 'options': list([ - '0', - '110', - '220', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - '10', - '11', - '12', - '13', - '14', - '15', - '16', - '17', - '18', - '117', - '118', - '217', + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_warming', ]), }), 'context': , @@ -495,7 +463,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '15', + 'state': 'plate_step_15', }) # --- # name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction_plate_5-entry] @@ -505,30 +473,26 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ - '0', - '110', - '220', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - '10', - '11', - '12', - '13', - '14', - '15', - '16', - '17', - '18', - '117', - '118', - '217', + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_warming', ]), }), 'config_entry_id': , @@ -566,30 +530,26 @@ 'device_class': 'enum', 'friendly_name': 'Hob with extraction Plate 5', 'options': list([ - '0', - '110', - '220', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - '10', - '11', - '12', - '13', - '14', - '15', - '16', - '17', - '18', - '117', - '118', - '217', + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_warming', ]), }), 'context': , @@ -597,7 +557,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '117', + 'state': 'plate_step_boost', }) # --- # name: test_sensor_states[platforms0][sensor.freezer-entry] diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index e30aa5d50d6..a139f729cd9 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -3309,9 +3309,7 @@ async def test_subentry_reconfigure_remove_entity( subentry_id: str subentry: ConfigSubentry subentry_id, subentry = next(iter(config_entry.subentries.items())) - result = await config_entry.start_subentry_reconfigure_flow( - hass, "device", subentry_id - ) + result = await config_entry.start_subentry_reconfigure_flow(hass, subentry_id) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "summary_menu" @@ -3433,9 +3431,7 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites( subentry_id: str subentry: ConfigSubentry subentry_id, subentry = next(iter(config_entry.subentries.items())) - result = await config_entry.start_subentry_reconfigure_flow( - hass, "device", subentry_id - ) + result = await config_entry.start_subentry_reconfigure_flow(hass, subentry_id) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "summary_menu" @@ -3651,9 +3647,7 @@ async def test_subentry_reconfigure_edit_entity_single_entity( subentry_id: str subentry: ConfigSubentry subentry_id, subentry = next(iter(config_entry.subentries.items())) - result = await config_entry.start_subentry_reconfigure_flow( - hass, "device", subentry_id - ) + result = await config_entry.start_subentry_reconfigure_flow(hass, subentry_id) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "summary_menu" @@ -3795,9 +3789,7 @@ async def test_subentry_reconfigure_edit_entity_reset_fields( subentry_id: str subentry: ConfigSubentry subentry_id, subentry = next(iter(config_entry.subentries.items())) - result = await config_entry.start_subentry_reconfigure_flow( - hass, "device", subentry_id - ) + result = await config_entry.start_subentry_reconfigure_flow(hass, subentry_id) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "summary_menu" @@ -3924,9 +3916,7 @@ async def test_subentry_reconfigure_add_entity( subentry_id: str subentry: ConfigSubentry subentry_id, subentry = next(iter(config_entry.subentries.items())) - result = await config_entry.start_subentry_reconfigure_flow( - hass, "device", subentry_id - ) + result = await config_entry.start_subentry_reconfigure_flow(hass, subentry_id) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "summary_menu" @@ -4023,9 +4013,7 @@ async def test_subentry_reconfigure_update_device_properties( subentry_id: str subentry: ConfigSubentry subentry_id, subentry = next(iter(config_entry.subentries.items())) - result = await config_entry.start_subentry_reconfigure_flow( - hass, "device", subentry_id - ) + result = await config_entry.start_subentry_reconfigure_flow(hass, subentry_id) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "summary_menu" @@ -4124,9 +4112,7 @@ async def test_subentry_reconfigure_availablity( } assert subentry.data.get("availability") == expected_availability - result = await config_entry.start_subentry_reconfigure_flow( - hass, "device", subentry_id - ) + result = await config_entry.start_subentry_reconfigure_flow(hass, subentry_id) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "summary_menu" @@ -4174,9 +4160,7 @@ async def test_subentry_reconfigure_availablity( assert subentry.data.get("availability") == expected_availability # Assert we can reset the availability config - result = await config_entry.start_subentry_reconfigure_flow( - hass, "device", subentry_id - ) + result = await config_entry.start_subentry_reconfigure_flow(hass, subentry_id) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "summary_menu" result = await hass.config_entries.subentries.async_configure( diff --git a/tests/components/music_assistant/snapshots/test_button.ambr b/tests/components/music_assistant/snapshots/test_button.ambr new file mode 100644 index 00000000000..ac9e4c660f6 --- /dev/null +++ b/tests/components/music_assistant/snapshots/test_button.ambr @@ -0,0 +1,145 @@ +# serializer version: 1 +# name: test_button_entities[button.my_super_test_player_2_favorite_current_song-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.my_super_test_player_2_favorite_current_song', + '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': 'Favorite current song', + 'platform': 'music_assistant', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'favorite_now_playing', + 'unique_id': '00:00:00:00:00:02_favorite_now_playing', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_entities[button.my_super_test_player_2_favorite_current_song-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Super Test Player 2 Favorite current song', + }), + 'context': , + 'entity_id': 'button.my_super_test_player_2_favorite_current_song', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_entities[button.test_group_player_1_favorite_current_song-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_group_player_1_favorite_current_song', + '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': 'Favorite current song', + 'platform': 'music_assistant', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'favorite_now_playing', + 'unique_id': 'test_group_player_1_favorite_now_playing', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_entities[button.test_group_player_1_favorite_current_song-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Group Player 1 Favorite current song', + }), + 'context': , + 'entity_id': 'button.test_group_player_1_favorite_current_song', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_entities[button.test_player_1_favorite_current_song-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_player_1_favorite_current_song', + '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': 'Favorite current song', + 'platform': 'music_assistant', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'favorite_now_playing', + 'unique_id': '00:00:00:00:00:01_favorite_now_playing', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_entities[button.test_player_1_favorite_current_song-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Player 1 Favorite current song', + }), + 'context': , + 'entity_id': 'button.test_player_1_favorite_current_song', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/music_assistant/test_button.py b/tests/components/music_assistant/test_button.py new file mode 100644 index 00000000000..8a1a4b0e241 --- /dev/null +++ b/tests/components/music_assistant/test_button.py @@ -0,0 +1,48 @@ +"""Test Music Assistant button entities.""" + +from unittest.mock import MagicMock, call + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import setup_integration_from_fixtures, snapshot_music_assistant_entities + + +async def test_button_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + music_assistant_client: MagicMock, +) -> None: + """Test media player.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + snapshot_music_assistant_entities(hass, entity_registry, snapshot, Platform.BUTTON) + + +async def test_button_press_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test button press action.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "button.my_super_test_player_2_favorite_current_song" + state = hass.states.get(entity_id) + assert state + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "music/favorites/add_item", + item="spotify://track/5d95dc5be77e4f7eb4939f62cfef527b", + ) diff --git a/tests/components/ntfy/snapshots/test_sensor.ambr b/tests/components/ntfy/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..fd0dd3c4bd4 --- /dev/null +++ b/tests/components/ntfy/snapshots/test_sensor.ambr @@ -0,0 +1,1029 @@ +# serializer version: 1 +# name: test_setup[sensor.ntfy_sh_attachment_bandwidth_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ntfy_sh_attachment_bandwidth_limit', + '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': 'Attachment bandwidth limit', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_attachment_bandwidth', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.ntfy_sh_attachment_bandwidth_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'ntfy.sh Attachment bandwidth limit', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_attachment_bandwidth_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1024.0', + }) +# --- +# name: test_setup[sensor.ntfy_sh_attachment_expiry_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ntfy_sh_attachment_expiry_duration', + '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': 'Attachment expiry duration', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_attachment_expiry_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.ntfy_sh_attachment_expiry_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'ntfy.sh Attachment expiry duration', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_attachment_expiry_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6.0', + }) +# --- +# name: test_setup[sensor.ntfy_sh_attachment_file_size_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ntfy_sh_attachment_file_size_limit', + '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': 'Attachment file size limit', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_attachment_file_size', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.ntfy_sh_attachment_file_size_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'ntfy.sh Attachment file size limit', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_attachment_file_size_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.0', + }) +# --- +# name: test_setup[sensor.ntfy_sh_attachment_storage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ntfy_sh_attachment_storage', + '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': 'Attachment storage', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_attachment_total_size', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.ntfy_sh_attachment_storage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'ntfy.sh Attachment storage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_attachment_storage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_setup[sensor.ntfy_sh_attachment_storage_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ntfy_sh_attachment_storage_limit', + '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': 'Attachment storage limit', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_attachment_total_size_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.ntfy_sh_attachment_storage_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'ntfy.sh Attachment storage limit', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_attachment_storage_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_setup[sensor.ntfy_sh_attachment_storage_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ntfy_sh_attachment_storage_remaining', + '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': 'Attachment storage remaining', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_attachment_total_size_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.ntfy_sh_attachment_storage_remaining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'ntfy.sh Attachment storage remaining', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_attachment_storage_remaining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_setup[sensor.ntfy_sh_email_usage_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ntfy_sh_email_usage_limit', + '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': 'Email usage limit', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_emails_limit', + 'unit_of_measurement': 'emails', + }) +# --- +# name: test_setup[sensor.ntfy_sh_email_usage_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ntfy.sh Email usage limit', + 'unit_of_measurement': 'emails', + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_email_usage_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- +# name: test_setup[sensor.ntfy_sh_emails_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ntfy_sh_emails_remaining', + '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': 'Emails remaining', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_emails_remaining', + 'unit_of_measurement': 'emails', + }) +# --- +# name: test_setup[sensor.ntfy_sh_emails_remaining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ntfy.sh Emails remaining', + 'unit_of_measurement': 'emails', + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_emails_remaining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- +# name: test_setup[sensor.ntfy_sh_emails_sent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ntfy_sh_emails_sent', + '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': 'Emails sent', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_emails', + 'unit_of_measurement': 'emails', + }) +# --- +# name: test_setup[sensor.ntfy_sh_emails_sent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ntfy.sh Emails sent', + 'unit_of_measurement': 'emails', + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_emails_sent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_setup[sensor.ntfy_sh_messages_expiry_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ntfy_sh_messages_expiry_duration', + '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': 'Messages expiry duration', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_messages_expiry_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.ntfy_sh_messages_expiry_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'ntfy.sh Messages expiry duration', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_messages_expiry_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.0', + }) +# --- +# name: test_setup[sensor.ntfy_sh_messages_published-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ntfy_sh_messages_published', + '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': 'Messages published', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_messages', + 'unit_of_measurement': 'messages', + }) +# --- +# name: test_setup[sensor.ntfy_sh_messages_published-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ntfy.sh Messages published', + 'unit_of_measurement': 'messages', + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_messages_published', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_setup[sensor.ntfy_sh_messages_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ntfy_sh_messages_remaining', + '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': 'Messages remaining', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_messages_remaining', + 'unit_of_measurement': 'messages', + }) +# --- +# name: test_setup[sensor.ntfy_sh_messages_remaining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ntfy.sh Messages remaining', + 'unit_of_measurement': 'messages', + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_messages_remaining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4990', + }) +# --- +# name: test_setup[sensor.ntfy_sh_messages_usage_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ntfy_sh_messages_usage_limit', + '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': 'Messages usage limit', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_messages_limit', + 'unit_of_measurement': 'messages', + }) +# --- +# name: test_setup[sensor.ntfy_sh_messages_usage_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ntfy.sh Messages usage limit', + 'unit_of_measurement': 'messages', + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_messages_usage_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5000', + }) +# --- +# name: test_setup[sensor.ntfy_sh_phone_calls_made-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ntfy_sh_phone_calls_made', + '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': 'Phone calls made', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_calls', + 'unit_of_measurement': 'calls', + }) +# --- +# name: test_setup[sensor.ntfy_sh_phone_calls_made-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ntfy.sh Phone calls made', + 'unit_of_measurement': 'calls', + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_phone_calls_made', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_setup[sensor.ntfy_sh_phone_calls_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ntfy_sh_phone_calls_remaining', + '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': 'Phone calls remaining', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_calls_remaining', + 'unit_of_measurement': 'calls', + }) +# --- +# name: test_setup[sensor.ntfy_sh_phone_calls_remaining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ntfy.sh Phone calls remaining', + 'unit_of_measurement': 'calls', + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_phone_calls_remaining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_setup[sensor.ntfy_sh_phone_calls_usage_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ntfy_sh_phone_calls_usage_limit', + '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': 'Phone calls usage limit', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_calls_limit', + 'unit_of_measurement': 'calls', + }) +# --- +# name: test_setup[sensor.ntfy_sh_phone_calls_usage_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ntfy.sh Phone calls usage limit', + 'unit_of_measurement': 'calls', + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_phone_calls_usage_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_setup[sensor.ntfy_sh_reserved_topics-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ntfy_sh_reserved_topics', + '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': 'Reserved topics', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_reservations', + 'unit_of_measurement': 'topics', + }) +# --- +# name: test_setup[sensor.ntfy_sh_reserved_topics-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ntfy.sh Reserved topics', + 'unit_of_measurement': 'topics', + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_reserved_topics', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_setup[sensor.ntfy_sh_reserved_topics_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ntfy_sh_reserved_topics_limit', + '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': 'Reserved topics limit', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_reservations_limit', + 'unit_of_measurement': 'topics', + }) +# --- +# name: test_setup[sensor.ntfy_sh_reserved_topics_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ntfy.sh Reserved topics limit', + 'unit_of_measurement': 'topics', + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_reserved_topics_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_setup[sensor.ntfy_sh_reserved_topics_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ntfy_sh_reserved_topics_remaining', + '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': 'Reserved topics remaining', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_reservations_remaining', + 'unit_of_measurement': 'topics', + }) +# --- +# name: test_setup[sensor.ntfy_sh_reserved_topics_remaining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ntfy.sh Reserved topics remaining', + 'unit_of_measurement': 'topics', + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_reserved_topics_remaining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_setup[sensor.ntfy_sh_subscription_tier-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ntfy_sh_subscription_tier', + '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': 'Subscription tier', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_tier', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.ntfy_sh_subscription_tier-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ntfy.sh Subscription tier', + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_subscription_tier', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'starter', + }) +# --- diff --git a/tests/components/ntfy/test_init.py b/tests/components/ntfy/test_init.py index b80badd8581..b5b73d1272c 100644 --- a/tests/components/ntfy/test_init.py +++ b/tests/components/ntfy/test_init.py @@ -65,3 +65,37 @@ async def test_config_entry_not_ready( await hass.async_block_till_done() assert config_entry.state is state + + +@pytest.mark.parametrize( + ("exception", "state"), + [ + ( + NtfyUnauthorizedAuthenticationError( + 40101, + 401, + "unauthorized", + "https://ntfy.sh/docs/publish/#authentication", + ), + ConfigEntryState.SETUP_ERROR, + ), + (NtfyHTTPError(418001, 418, "I'm a teapot", ""), ConfigEntryState.SETUP_RETRY), + (NtfyConnectionError, ConfigEntryState.SETUP_RETRY), + (NtfyTimeoutError, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_coordinator_update_exceptions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_aiontfy: AsyncMock, + exception: Exception, + state: ConfigEntryState, +) -> None: + """Test config entry not ready from update failed in _async_update_data.""" + mock_aiontfy.account.side_effect = [None, exception] + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is state diff --git a/tests/components/ntfy/test_sensor.py b/tests/components/ntfy/test_sensor.py new file mode 100644 index 00000000000..4685cf946ee --- /dev/null +++ b/tests/components/ntfy/test_sensor.py @@ -0,0 +1,42 @@ +"""Tests for the ntfy sensor platform.""" + +from collections.abc import Generator +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def sensor_only() -> Generator[None]: + """Enable only the sensor platform.""" + with patch( + "homeassistant.components.ntfy.PLATFORMS", + [Platform.SENSOR], + ): + yield + + +@pytest.mark.usefixtures("mock_aiontfy", "entity_registry_enabled_by_default") +async def test_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Snapshot test states of sensor platform.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) diff --git a/tests/components/playstation_network/__init__.py b/tests/components/playstation_network/__init__.py new file mode 100644 index 00000000000..a05112b4146 --- /dev/null +++ b/tests/components/playstation_network/__init__.py @@ -0,0 +1 @@ +"""Tests for the Playstation Network integration.""" diff --git a/tests/components/playstation_network/conftest.py b/tests/components/playstation_network/conftest.py new file mode 100644 index 00000000000..f03b3e6f1cf --- /dev/null +++ b/tests/components/playstation_network/conftest.py @@ -0,0 +1,110 @@ +"""Common fixtures for the Playstation Network tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.playstation_network.const import CONF_NPSSO, DOMAIN + +from tests.common import MockConfigEntry + +NPSSO_TOKEN: str = "npsso-token" +NPSSO_TOKEN_INVALID_JSON: str = "{'npsso': 'npsso-token'" +PSN_ID: str = "my-psn-id" + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Mock PlayStation Network configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="test-user", + data={ + CONF_NPSSO: NPSSO_TOKEN, + }, + unique_id=PSN_ID, + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.playstation_network.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_user() -> Generator[MagicMock]: + """Mock psnawp_api User object.""" + + with patch( + "homeassistant.components.playstation_network.helpers.User", + autospec=True, + ) as mock_client: + client = mock_client.return_value + + client.account_id = PSN_ID + client.online_id = "testuser" + + client.get_presence.return_value = { + "basicPresence": { + "availability": "availableToPlay", + "primaryPlatformInfo": {"onlineStatus": "online", "platform": "PS5"}, + "gameTitleInfoList": [ + { + "npTitleId": "PPSA07784_00", + "titleName": "STAR WARS Jedi: Survivor™", + "format": "PS5", + "launchPlatform": "PS5", + "conceptIconUrl": "https://image.api.playstation.com/vulcan/ap/rnd/202211/2222/l8QTN7ThQK3lRBHhB3nX1s7h.png", + } + ], + } + } + + yield client + + +@pytest.fixture +def mock_psnawpapi(mock_user: MagicMock) -> Generator[MagicMock]: + """Mock psnawp_api.""" + + with patch( + "homeassistant.components.playstation_network.helpers.PSNAWP", + autospec=True, + ) as mock_client: + client = mock_client.return_value + + client.user.return_value = mock_user + client.me.return_value.get_account_devices.return_value = [ + { + "deviceId": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234", + "deviceType": "PS5", + "activationType": "PRIMARY", + "activationDate": "2021-01-14T18:00:00.000Z", + "accountDeviceVector": "abcdefghijklmnopqrstuv", + } + ] + yield client + + +@pytest.fixture +def mock_psnawp_npsso(mock_user: MagicMock) -> Generator[MagicMock]: + """Mock psnawp_api.""" + + with patch( + "homeassistant.components.playstation_network.config_flow.parse_npsso_token", + side_effect=lambda token: token, + ) as npsso: + yield npsso + + +@pytest.fixture +def mock_token() -> Generator[MagicMock]: + """Mock token generator.""" + with patch("secrets.token_hex", return_value="123456789") as token: + yield token diff --git a/tests/components/playstation_network/snapshots/test_media_player.ambr b/tests/components/playstation_network/snapshots/test_media_player.ambr new file mode 100644 index 00000000000..a42522592e4 --- /dev/null +++ b/tests/components/playstation_network/snapshots/test_media_player.ambr @@ -0,0 +1,321 @@ +# serializer version: 1 +# name: test_platform[PS4_idle][media_player.playstation_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.playstation_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'playstation', + 'unique_id': 'my-psn-id_PS4', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform[PS4_idle][media_player.playstation_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'receiver', + 'entity_picture_local': None, + 'friendly_name': 'PlayStation 4', + 'media_content_type': , + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.playstation_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform[PS4_offline][media_player.playstation_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.playstation_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'playstation', + 'unique_id': 'my-psn-id_PS4', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform[PS4_offline][media_player.playstation_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'receiver', + 'friendly_name': 'PlayStation 4', + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.playstation_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform[PS4_playing][media_player.playstation_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.playstation_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'playstation', + 'unique_id': 'my-psn-id_PS4', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform[PS4_playing][media_player.playstation_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'receiver', + 'entity_picture': 'http://gs2-sec.ww.prod.dl.playstation.net/gs2-sec/appkgo/prod/CUSA23081_00/5/i_f5d2adec7665af80b8550fb33fe808df10d292cdd47629a991debfdf72bdee34/i/icon0.png', + 'entity_picture_local': '/api/media_player_proxy/media_player.playstation_4?token=123456789&cache=924f463745523102', + 'friendly_name': 'PlayStation 4', + 'media_content_id': 'CUSA23081_00', + 'media_content_type': , + 'media_title': 'Untitled Goose Game', + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.playstation_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_platform[PS5_idle][media_player.playstation_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.playstation_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'playstation', + 'unique_id': 'my-psn-id_PS5', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform[PS5_idle][media_player.playstation_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'receiver', + 'entity_picture_local': None, + 'friendly_name': 'PlayStation 5', + 'media_content_type': , + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.playstation_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform[PS5_offline][media_player.playstation_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.playstation_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'playstation', + 'unique_id': 'my-psn-id_PS5', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform[PS5_offline][media_player.playstation_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'receiver', + 'friendly_name': 'PlayStation 5', + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.playstation_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform[PS5_playing][media_player.playstation_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.playstation_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'playstation', + 'unique_id': 'my-psn-id_PS5', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform[PS5_playing][media_player.playstation_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'receiver', + 'entity_picture': 'https://image.api.playstation.com/vulcan/ap/rnd/202211/2222/l8QTN7ThQK3lRBHhB3nX1s7h.png', + 'entity_picture_local': '/api/media_player_proxy/media_player.playstation_5?token=123456789&cache=50dfb7140be0060b', + 'friendly_name': 'PlayStation 5', + 'media_content_id': 'PPSA07784_00', + 'media_content_type': , + 'media_title': 'STAR WARS Jedi: Survivor™', + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.playstation_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- diff --git a/tests/components/playstation_network/test_config_flow.py b/tests/components/playstation_network/test_config_flow.py new file mode 100644 index 00000000000..031872a7a0b --- /dev/null +++ b/tests/components/playstation_network/test_config_flow.py @@ -0,0 +1,140 @@ +"""Test the Playstation Network config flow.""" + +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.playstation_network.config_flow import ( + PSNAWPAuthenticationError, + PSNAWPError, + PSNAWPInvalidTokenError, + PSNAWPNotFoundError, +) +from homeassistant.components.playstation_network.const import CONF_NPSSO, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import NPSSO_TOKEN, NPSSO_TOKEN_INVALID_JSON, PSN_ID + +from tests.common import MockConfigEntry + +MOCK_DATA_ADVANCED_STEP = {CONF_NPSSO: NPSSO_TOKEN} + + +async def test_manual_config(hass: HomeAssistant, mock_psnawpapi: MagicMock) -> None: + """Test creating via manual configuration.""" + 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_NPSSO: "TEST_NPSSO_TOKEN"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == PSN_ID + assert result["data"] == { + CONF_NPSSO: "TEST_NPSSO_TOKEN", + } + + +async def test_form_already_configured( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, +) -> None: + """Test we abort form login when entry is already configured.""" + + 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"], + {CONF_NPSSO: NPSSO_TOKEN}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (PSNAWPNotFoundError(), "invalid_account"), + (PSNAWPAuthenticationError(), "invalid_auth"), + (PSNAWPError(), "cannot_connect"), + (Exception(), "unknown"), + ], +) +async def test_form_failures( + hass: HomeAssistant, + mock_psnawpapi: MagicMock, + raise_error: Exception, + text_error: str, +) -> None: + """Test we handle a connection error. + + First we generate an error and after fixing it, we are still able to submit. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + mock_psnawpapi.user.side_effect = raise_error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_NPSSO: NPSSO_TOKEN}, + ) + + assert result["step_id"] == "user" + assert result["errors"] == {"base": text_error} + + mock_psnawpapi.user.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_NPSSO: NPSSO_TOKEN}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_NPSSO: NPSSO_TOKEN, + } + + +@pytest.mark.usefixtures("mock_psnawpapi") +async def test_parse_npsso_token_failures( + hass: HomeAssistant, + mock_psnawp_npsso: MagicMock, +) -> None: + """Test parse_npsso_token raises the correct exceptions during config flow.""" + mock_psnawp_npsso.side_effect = PSNAWPInvalidTokenError + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_NPSSO: NPSSO_TOKEN_INVALID_JSON}, + ) + assert result["errors"] == {"base": "invalid_account"} + + mock_psnawp_npsso.side_effect = lambda token: token + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_NPSSO: NPSSO_TOKEN}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_NPSSO: NPSSO_TOKEN, + } diff --git a/tests/components/playstation_network/test_media_player.py b/tests/components/playstation_network/test_media_player.py new file mode 100644 index 00000000000..f503a5ec297 --- /dev/null +++ b/tests/components/playstation_network/test_media_player.py @@ -0,0 +1,123 @@ +"""Test the Playstation Network media player platform.""" + +from collections.abc import AsyncGenerator +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +async def media_player_only() -> AsyncGenerator[None]: + """Enable only the media_player platform.""" + with patch( + "homeassistant.components.playstation_network.PLATFORMS", + [Platform.MEDIA_PLAYER], + ): + yield + + +@pytest.mark.parametrize( + "presence_payload", + [ + { + "basicPresence": { + "availability": "availableToPlay", + "primaryPlatformInfo": {"onlineStatus": "online", "platform": "PS5"}, + "gameTitleInfoList": [ + { + "npTitleId": "PPSA07784_00", + "titleName": "STAR WARS Jedi: Survivor™", + "format": "PS5", + "launchPlatform": "PS5", + "conceptIconUrl": "https://image.api.playstation.com/vulcan/ap/rnd/202211/2222/l8QTN7ThQK3lRBHhB3nX1s7h.png", + } + ], + } + }, + { + "basicPresence": { + "availability": "availableToPlay", + "primaryPlatformInfo": {"onlineStatus": "online", "platform": "PS4"}, + "gameTitleInfoList": [ + { + "npTitleId": "CUSA23081_00", + "titleName": "Untitled Goose Game", + "format": "PS4", + "launchPlatform": "PS4", + "npTitleIconUrl": "http://gs2-sec.ww.prod.dl.playstation.net/gs2-sec/appkgo/prod/CUSA23081_00/5/i_f5d2adec7665af80b8550fb33fe808df10d292cdd47629a991debfdf72bdee34/i/icon0.png", + } + ], + } + }, + { + "basicPresence": { + "availability": "unavailable", + "lastAvailableDate": "2025-05-02T17:47:59.392Z", + "primaryPlatformInfo": { + "onlineStatus": "offline", + "platform": "PS5", + "lastOnlineDate": "2025-05-02T17:47:59.392Z", + }, + } + }, + { + "basicPresence": { + "availability": "unavailable", + "lastAvailableDate": "2025-05-02T17:47:59.392Z", + "primaryPlatformInfo": { + "onlineStatus": "offline", + "platform": "PS4", + "lastOnlineDate": "2025-05-02T17:47:59.392Z", + }, + } + }, + { + "basicPresence": { + "availability": "availableToPlay", + "primaryPlatformInfo": {"onlineStatus": "online", "platform": "PS5"}, + } + }, + { + "basicPresence": { + "availability": "availableToPlay", + "primaryPlatformInfo": {"onlineStatus": "online", "platform": "PS4"}, + } + }, + ], + ids=[ + "PS5_playing", + "PS4_playing", + "PS5_offline", + "PS4_offline", + "PS5_idle", + "PS4_idle", + ], +) +@pytest.mark.usefixtures("mock_psnawpapi", "mock_token") +async def test_platform( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_psnawpapi: MagicMock, + presence_payload: dict[str, Any], +) -> None: + """Test setup of the PlayStation Network media_player platform.""" + + mock_psnawpapi.user().get_presence.return_value = presence_payload + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 0ca5612f8fd..2f37fca251a 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -10,6 +10,7 @@ from reolink_aio.exceptions import ReolinkError from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL from homeassistant.components.reolink.const import ( + CONF_BC_ONLY, CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, @@ -219,6 +220,7 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry: CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, CONF_BC_PORT: TEST_BC_PORT, + CONF_BC_ONLY: False, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index e706af0d067..4b116929ac8 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -19,6 +19,7 @@ from homeassistant import config_entries from homeassistant.components.reolink import DEVICE_UPDATE_INTERVAL from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL from homeassistant.components.reolink.const import ( + CONF_BC_ONLY, CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, @@ -91,6 +92,7 @@ async def test_config_flow_manual_success( CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, CONF_BC_PORT: TEST_BC_PORT, + CONF_BC_ONLY: False, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -144,6 +146,7 @@ async def test_config_flow_privacy_success( CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, CONF_BC_PORT: TEST_BC_PORT, + CONF_BC_ONLY: False, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -153,6 +156,49 @@ async def test_config_flow_privacy_success( reolink_connect.baichuan.privacy_mode.return_value = False +async def test_config_flow_baichuan_only( + hass: HomeAssistant, reolink_connect: MagicMock, mock_setup_entry: MagicMock +) -> None: + """Successful flow manually initialized by the user for baichuan only device.""" + reolink_connect.baichuan_only = True + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.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"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_HOST: TEST_HOST, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_NVR_NAME + assert result["data"] == { + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_PORT: TEST_PORT, + CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, + CONF_BC_PORT: TEST_BC_PORT, + CONF_BC_ONLY: True, + } + assert result["options"] == { + CONF_PROTOCOL: DEFAULT_PROTOCOL, + } + assert result["result"].unique_id == TEST_MAC + + reolink_connect.baichuan_only = False + + async def test_config_flow_errors( hass: HomeAssistant, reolink_connect: MagicMock, mock_setup_entry: MagicMock ) -> None: @@ -308,6 +354,7 @@ async def test_config_flow_errors( CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, CONF_BC_PORT: TEST_BC_PORT, + CONF_BC_ONLY: False, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -329,6 +376,7 @@ async def test_options_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_BC_PORT: TEST_BC_PORT, + CONF_BC_ONLY: False, }, options={ CONF_PROTOCOL: "rtsp", @@ -368,6 +416,7 @@ async def test_reauth(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_BC_PORT: TEST_BC_PORT, + CONF_BC_ONLY: False, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -414,6 +463,7 @@ async def test_reauth_abort_unique_id_mismatch( CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_BC_PORT: TEST_BC_PORT, + CONF_BC_ONLY: False, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -484,6 +534,7 @@ async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> No CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, CONF_BC_PORT: TEST_BC_PORT, + CONF_BC_ONLY: False, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -507,6 +558,7 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac( CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_BC_PORT: TEST_BC_PORT, + CONF_BC_ONLY: False, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -548,6 +600,7 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac( timeout=DEFAULT_TIMEOUT, aiohttp_get_session_callback=ANY, bc_port=TEST_BC_PORT, + bc_only=False, ) assert expected_call in reolink_connect_class.call_args_list @@ -606,6 +659,7 @@ async def test_dhcp_ip_update( CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_BC_PORT: TEST_BC_PORT, + CONF_BC_ONLY: False, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -649,6 +703,7 @@ async def test_dhcp_ip_update( timeout=DEFAULT_TIMEOUT, aiohttp_get_session_callback=ANY, bc_port=TEST_BC_PORT, + bc_only=False, ) assert expected_call in reolink_connect_class.call_args_list @@ -686,6 +741,7 @@ async def test_dhcp_ip_update_ingnored_if_still_connected( CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_BC_PORT: TEST_BC_PORT, + CONF_BC_ONLY: False, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -718,6 +774,7 @@ async def test_dhcp_ip_update_ingnored_if_still_connected( timeout=DEFAULT_TIMEOUT, aiohttp_get_session_callback=ANY, bc_port=TEST_BC_PORT, + bc_only=False, ) assert expected_call in reolink_connect_class.call_args_list @@ -748,6 +805,7 @@ async def test_reconfig(hass: HomeAssistant, mock_setup_entry: MagicMock) -> Non CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_BC_PORT: TEST_BC_PORT, + CONF_BC_ONLY: False, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -795,6 +853,7 @@ async def test_reconfig_abort_unique_id_mismatch( CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_BC_PORT: TEST_BC_PORT, + CONF_BC_ONLY: False, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, diff --git a/tests/components/reolink/test_media_source.py b/tests/components/reolink/test_media_source.py index 59f0c6c195d..67ae78e5fa4 100644 --- a/tests/components/reolink/test_media_source.py +++ b/tests/components/reolink/test_media_source.py @@ -194,13 +194,13 @@ async def test_browsing( hass, f"{URI_SCHEME}{DOMAIN}/{browse_res_AT_sub_id}" ) assert browse.domain == DOMAIN - assert browse.title == f"{TEST_NVR_NAME} lens 0 Autotrack low res." + assert browse.title == f"{TEST_NVR_NAME} lens 0 Telephoto low res." browse = await async_browse_media( hass, f"{URI_SCHEME}{DOMAIN}/{browse_res_AT_main_id}" ) assert browse.domain == DOMAIN - assert browse.title == f"{TEST_NVR_NAME} lens 0 Autotrack high res." + assert browse.title == f"{TEST_NVR_NAME} lens 0 Telephoto high res." browse = await async_browse_media( hass, f"{URI_SCHEME}{DOMAIN}/{browse_res_main_id}" diff --git a/tests/components/shelly/__init__.py b/tests/components/shelly/__init__.py index 6c835d2a636..a333e55560f 100644 --- a/tests/components/shelly/__init__.py +++ b/tests/components/shelly/__init__.py @@ -9,6 +9,7 @@ from unittest.mock import Mock from aioshelly.const import MODEL_25 from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.shelly.const import ( CONF_GEN, @@ -151,3 +152,30 @@ def register_device( config_entry_id=config_entry.entry_id, connections={(CONNECTION_NETWORK_MAC, format_mac(MOCK_MAC))}, ) + + +async def snapshot_device_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + config_entry_id: str, +) -> None: + """Snapshot all device entities.""" + entity_entries = er.async_entries_for_config_entry(entity_registry, config_entry_id) + assert entity_entries + + for entity_entry in entity_entries: + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert entity_entry.disabled_by is None, "Please enable all entities." + state = hass.states.get(entity_entry.entity_id) + assert state, f"State not found for {entity_entry.entity_id}" + assert state == snapshot(name=f"{entity_entry.entity_id}-state") + + +async def force_uptime_value( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Force time to a specific point.""" + await hass.config.async_set_time_zone("UTC") + freezer.move_to("2025-05-26 16:04:00+00:00") diff --git a/tests/components/shelly/snapshots/test_devices.ambr b/tests/components/shelly/snapshots/test_devices.ambr new file mode 100644 index 00000000000..37b0d3ef11c --- /dev/null +++ b/tests/components/shelly/snapshots/test_devices.ambr @@ -0,0 +1,4718 @@ +# serializer version: 1 +# name: test_shelly_2pm_gen3_cover[binary_sensor.test_name_cloud-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.test_name_cloud', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cloud', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-cloud-cloud', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_cover[binary_sensor.test_name_cloud-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Test name Cloud', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_cloud', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_cover[binary_sensor.test_name_input_0-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': None, + 'entity_id': 'binary_sensor.test_name_input_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Input 0', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-input:0-input', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_cover[binary_sensor.test_name_input_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test name Input 0', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_input_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_cover[binary_sensor.test_name_input_1-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': None, + 'entity_id': 'binary_sensor.test_name_input_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Input 1', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-input:1-input', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_cover[binary_sensor.test_name_input_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test name Input 1', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_input_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_cover[binary_sensor.test_name_overcurrent-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.test_name_overcurrent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overcurrent', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-cover:0-overcurrent', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_cover[binary_sensor.test_name_overcurrent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test name Overcurrent', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_overcurrent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_cover[binary_sensor.test_name_overheating-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.test_name_overheating', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overheating', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-cover:0-overtemp', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_cover[binary_sensor.test_name_overheating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test name Overheating', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_overheating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_cover[binary_sensor.test_name_overpowering-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.test_name_overpowering', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overpowering', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-cover:0-overpower', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_cover[binary_sensor.test_name_overpowering-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test name Overpowering', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_overpowering', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_cover[binary_sensor.test_name_overvoltage-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.test_name_overvoltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overvoltage', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-cover:0-overvoltage', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_cover[binary_sensor.test_name_overvoltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test name Overvoltage', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_overvoltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_cover[binary_sensor.test_name_restart_required-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.test_name_restart_required', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart required', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-sys-restart', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_cover[binary_sensor.test_name_restart_required-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test name Restart required', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_restart_required', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_cover[button.test_name_reboot-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.test_name_reboot', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reboot', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC_reboot', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_cover[button.test_name_reboot-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'Test name Reboot', + }), + 'context': , + 'entity_id': 'button.test_name_reboot', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_shelly_2pm_gen3_cover[cover.test_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_name', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '123456789ABC-cover:0', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_cover[cover.test_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'shutter', + 'friendly_name': 'Test name', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_shelly_2pm_gen3_cover[sensor.test_name_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': None, + 'entity_id': 'sensor.test_name_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-cover:0-current', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_cover[sensor.test_name_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Test name Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_shelly_2pm_gen3_cover[sensor.test_name_energy-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.test_name_energy', + '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': 'Energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-cover:0-energy', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_cover[sensor.test_name_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_shelly_2pm_gen3_cover[sensor.test_name_frequency-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.test_name_frequency', + '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': 'Frequency', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-cover:0-freq', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_cover[sensor.test_name_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Test name Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.0', + }) +# --- +# name: test_shelly_2pm_gen3_cover[sensor.test_name_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': None, + 'entity_id': 'sensor.test_name_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-cover:0-power', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_cover[sensor.test_name_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test name Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_shelly_2pm_gen3_cover[sensor.test_name_rssi-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.test_name_rssi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RSSI', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-wifi-rssi', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_shelly_2pm_gen3_cover[sensor.test_name_rssi-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'Test name RSSI', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.test_name_rssi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-53', + }) +# --- +# name: test_shelly_2pm_gen3_cover[sensor.test_name_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': , + 'entity_id': 'sensor.test_name_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': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-cover:0-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_cover[sensor.test_name_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test name Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '36.4', + }) +# --- +# name: test_shelly_2pm_gen3_cover[sensor.test_name_uptime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_name_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-sys-uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_cover[sensor.test_name_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Test name Uptime', + }), + 'context': , + 'entity_id': 'sensor.test_name_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-05-26T15:57:39+00:00', + }) +# --- +# name: test_shelly_2pm_gen3_cover[sensor.test_name_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': None, + 'entity_id': 'sensor.test_name_voltage', + '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': 'Voltage', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-cover:0-voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_cover[sensor.test_name_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Test name Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '217.7', + }) +# --- +# name: test_shelly_2pm_gen3_cover[update.test_name_beta_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.test_name_beta_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Beta firmware', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '123456789ABC-sys-fwupdate_beta', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_cover[update.test_name_beta_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/shelly/icon.png', + 'friendly_name': 'Test name Beta firmware', + 'in_progress': False, + 'installed_version': '1.6.1', + 'latest_version': '1.6.1', + 'release_summary': None, + 'release_url': 'https://shelly-api-docs.shelly.cloud/gen2/changelog/#unreleased', + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.test_name_beta_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_cover[update.test_name_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.test_name_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '123456789ABC-sys-fwupdate', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_cover[update.test_name_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/shelly/icon.png', + 'friendly_name': 'Test name Firmware', + 'in_progress': False, + 'installed_version': '1.6.1', + 'latest_version': '1.6.1', + 'release_summary': None, + 'release_url': 'https://shelly-api-docs.shelly.cloud/gen2/changelog/', + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.test_name_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_cloud-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.test_name_cloud', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cloud', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-cloud-cloud', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_cloud-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Test name Cloud', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_cloud', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_input_0-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': None, + 'entity_id': 'binary_sensor.test_name_input_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Input 0', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-input:0-input', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_input_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test name Input 0', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_input_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_input_1-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': None, + 'entity_id': 'binary_sensor.test_name_input_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Input 1', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-input:1-input', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_input_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test name Input 1', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_input_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_restart_required-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.test_name_restart_required', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart required', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-sys-restart', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_restart_required-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test name Restart required', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_restart_required', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_switch_0_overcurrent-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.test_name_switch_0_overcurrent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overcurrent', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:0-overcurrent', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_switch_0_overcurrent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test name Switch 0 Overcurrent', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_switch_0_overcurrent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_switch_0_overheating-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.test_name_switch_0_overheating', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overheating', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:0-overtemp', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_switch_0_overheating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test name Switch 0 Overheating', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_switch_0_overheating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_switch_0_overpowering-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.test_name_switch_0_overpowering', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overpowering', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:0-overpower', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_switch_0_overpowering-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test name Switch 0 Overpowering', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_switch_0_overpowering', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_switch_0_overvoltage-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.test_name_switch_0_overvoltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overvoltage', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:0-overvoltage', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_switch_0_overvoltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test name Switch 0 Overvoltage', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_switch_0_overvoltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_switch_1_overcurrent-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.test_name_switch_1_overcurrent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overcurrent', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:1-overcurrent', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_switch_1_overcurrent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test name Switch 1 Overcurrent', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_switch_1_overcurrent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_switch_1_overheating-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.test_name_switch_1_overheating', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overheating', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:1-overtemp', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_switch_1_overheating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test name Switch 1 Overheating', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_switch_1_overheating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_switch_1_overpowering-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.test_name_switch_1_overpowering', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overpowering', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:1-overpower', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_switch_1_overpowering-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test name Switch 1 Overpowering', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_switch_1_overpowering', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_switch_1_overvoltage-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.test_name_switch_1_overvoltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overvoltage', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:1-overvoltage', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_switch_1_overvoltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test name Switch 1 Overvoltage', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_switch_1_overvoltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[button.test_name_reboot-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.test_name_reboot', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reboot', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC_reboot', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[button.test_name_reboot-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'Test name Reboot', + }), + 'context': , + 'entity_id': 'button.test_name_reboot', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_rssi-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.test_name_rssi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RSSI', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-wifi-rssi', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_rssi-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'Test name RSSI', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.test_name_rssi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-52', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_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': None, + 'entity_id': 'sensor.test_name_switch_0_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:0-current', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Test name Switch 0 Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_switch_0_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_energy-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.test_name_switch_0_energy', + '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': 'Energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:0-energy', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Switch 0 Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_switch_0_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_frequency-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.test_name_switch_0_frequency', + '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': 'Frequency', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:0-freq', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Test name Switch 0 Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_switch_0_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.0', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_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': None, + 'entity_id': 'sensor.test_name_switch_0_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:0-power', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test name Switch 0 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_switch_0_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_returned_energy-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.test_name_switch_0_returned_energy', + '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': 'Returned energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:0-ret_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_returned_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Switch 0 Returned energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_switch_0_returned_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_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': , + 'entity_id': 'sensor.test_name_switch_0_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': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:0-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test name Switch 0 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_switch_0_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40.6', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_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': None, + 'entity_id': 'sensor.test_name_switch_0_voltage', + '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': 'Voltage', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:0-voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Test name Switch 0 Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_switch_0_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '216.2', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_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': None, + 'entity_id': 'sensor.test_name_switch_1_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:1-current', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Test name Switch 1 Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_switch_1_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_energy-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.test_name_switch_1_energy', + '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': 'Energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:1-energy', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Switch 1 Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_switch_1_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_frequency-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.test_name_switch_1_frequency', + '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': 'Frequency', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:1-freq', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Test name Switch 1 Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_switch_1_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.0', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_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': None, + 'entity_id': 'sensor.test_name_switch_1_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:1-power', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test name Switch 1 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_switch_1_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_returned_energy-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.test_name_switch_1_returned_energy', + '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': 'Returned energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:1-ret_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_returned_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Switch 1 Returned energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_switch_1_returned_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_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': , + 'entity_id': 'sensor.test_name_switch_1_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': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:1-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test name Switch 1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_switch_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40.6', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_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': None, + 'entity_id': 'sensor.test_name_switch_1_voltage', + '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': 'Voltage', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:1-voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Test name Switch 1 Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_switch_1_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '216.3', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_uptime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_name_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-sys-uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Test name Uptime', + }), + 'context': , + 'entity_id': 'sensor.test_name_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-05-26T16:02:17+00:00', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[switch.test_name_switch_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_name_switch_0', + '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': None, + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:0', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[switch.test_name_switch_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test name Switch 0', + }), + 'context': , + 'entity_id': 'switch.test_name_switch_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[switch.test_name_switch_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_name_switch_1', + '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': None, + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:1', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[switch.test_name_switch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test name Switch 1', + }), + 'context': , + 'entity_id': 'switch.test_name_switch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[update.test_name_beta_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.test_name_beta_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Beta firmware', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '123456789ABC-sys-fwupdate_beta', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[update.test_name_beta_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/shelly/icon.png', + 'friendly_name': 'Test name Beta firmware', + 'in_progress': False, + 'installed_version': '1.6.1', + 'latest_version': '1.6.1', + 'release_summary': None, + 'release_url': 'https://shelly-api-docs.shelly.cloud/gen2/changelog/#unreleased', + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.test_name_beta_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[update.test_name_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.test_name_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '123456789ABC-sys-fwupdate', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[update.test_name_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/shelly/icon.png', + 'friendly_name': 'Test name Firmware', + 'in_progress': False, + 'installed_version': '1.6.1', + 'latest_version': '1.6.1', + 'release_summary': None, + 'release_url': 'https://shelly-api-docs.shelly.cloud/gen2/changelog/', + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.test_name_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_pro_3em[binary_sensor.test_name_cloud-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.test_name_cloud', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cloud', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-cloud-cloud', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_pro_3em[binary_sensor.test_name_cloud-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Test name Cloud', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_cloud', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_pro_3em[binary_sensor.test_name_restart_required-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.test_name_restart_required', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart required', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-sys-restart', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_pro_3em[binary_sensor.test_name_restart_required-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test name Restart required', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_restart_required', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_pro_3em[button.test_name_reboot-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.test_name_reboot', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reboot', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC_reboot', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_pro_3em[button.test_name_reboot-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'Test name Reboot', + }), + 'context': , + 'entity_id': 'button.test_name_reboot', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_a_active_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': None, + 'entity_id': 'sensor.test_name_phase_a_active_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-a_act_power', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_a_active_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test name Phase A Active power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_a_active_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2166.2', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_a_apparent_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': None, + 'entity_id': 'sensor.test_name_phase_a_apparent_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-a_aprt_power', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_a_apparent_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Test name Phase A Apparent power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_a_apparent_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2175.9', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_a_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': None, + 'entity_id': 'sensor.test_name_phase_a_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-a_current', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_a_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Test name Phase A Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_a_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.592', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_a_frequency-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.test_name_phase_a_frequency', + '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': 'Frequency', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-a_freq', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_a_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Test name Phase A Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_a_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '49.9', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_a_power_factor-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.test_name_phase_a_power_factor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-a_pf', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_a_power_factor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Test name Phase A Power factor', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_a_power_factor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.99', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_a_total_active_energy-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.test_name_phase_a_total_active_energy', + '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': 'Total active energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-emdata:0-a_total_act_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_a_total_active_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Phase A Total active energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_a_total_active_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3105.57642', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_a_total_active_returned_energy-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.test_name_phase_a_total_active_returned_energy', + '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': 'Total active returned energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-emdata:0-a_total_act_ret_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_a_total_active_returned_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Phase A Total active returned energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_a_total_active_returned_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_a_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': None, + 'entity_id': 'sensor.test_name_phase_a_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-a_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_a_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Test name Phase A Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_a_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '227.0', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_b_active_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': None, + 'entity_id': 'sensor.test_name_phase_b_active_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-b_act_power', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_b_active_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test name Phase B Active power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_b_active_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.6', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_b_apparent_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': None, + 'entity_id': 'sensor.test_name_phase_b_apparent_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-b_aprt_power', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_b_apparent_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Test name Phase B Apparent power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_b_apparent_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.1', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_b_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': None, + 'entity_id': 'sensor.test_name_phase_b_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-b_current', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_b_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Test name Phase B Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_b_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.044', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_b_frequency-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.test_name_phase_b_frequency', + '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': 'Frequency', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-b_freq', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_b_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Test name Phase B Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_b_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '49.9', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_b_power_factor-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.test_name_phase_b_power_factor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-b_pf', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_b_power_factor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Test name Phase B Power factor', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_b_power_factor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.36', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_b_total_active_energy-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.test_name_phase_b_total_active_energy', + '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': 'Total active energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-emdata:0-b_total_act_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_b_total_active_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Phase B Total active energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_b_total_active_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '195.76572', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_b_total_active_returned_energy-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.test_name_phase_b_total_active_returned_energy', + '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': 'Total active returned energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-emdata:0-b_total_act_ret_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_b_total_active_returned_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Phase B Total active returned energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_b_total_active_returned_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_b_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': None, + 'entity_id': 'sensor.test_name_phase_b_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-b_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_b_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Test name Phase B Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_b_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '230.0', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_c_active_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': None, + 'entity_id': 'sensor.test_name_phase_c_active_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-c_act_power', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_c_active_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test name Phase C Active power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_c_active_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '244.0', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_c_apparent_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': None, + 'entity_id': 'sensor.test_name_phase_c_apparent_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-c_aprt_power', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_c_apparent_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Test name Phase C Apparent power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_c_apparent_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '339.7', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_c_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': None, + 'entity_id': 'sensor.test_name_phase_c_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-c_current', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_c_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Test name Phase C Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_c_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.479', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_c_frequency-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.test_name_phase_c_frequency', + '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': 'Frequency', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-c_freq', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_c_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Test name Phase C Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_c_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '49.9', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_c_power_factor-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.test_name_phase_c_power_factor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-c_pf', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_c_power_factor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Test name Phase C Power factor', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_c_power_factor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.72', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_c_total_active_energy-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.test_name_phase_c_total_active_energy', + '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': 'Total active energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-emdata:0-c_total_act_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_c_total_active_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Phase C Total active energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_c_total_active_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2114.07205', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_c_total_active_returned_energy-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.test_name_phase_c_total_active_returned_energy', + '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': 'Total active returned energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-emdata:0-c_total_act_ret_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_c_total_active_returned_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Phase C Total active returned energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_c_total_active_returned_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_c_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': None, + 'entity_id': 'sensor.test_name_phase_c_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-c_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_c_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Test name Phase C Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_c_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '230.2', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_rssi-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.test_name_rssi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RSSI', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-wifi-rssi', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_rssi-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'Test name RSSI', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.test_name_rssi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-57', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_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.test_name_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': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-temperature:0-temperature_0', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test name Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '46.3', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_total_active_energy-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.test_name_total_active_energy', + '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': 'Total active energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-emdata:0-total_act', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_total_active_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Total active energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_total_active_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5415.41419', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_total_active_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': None, + 'entity_id': 'sensor.test_name_total_active_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total active power', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-total_act_power', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_total_active_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test name Total active power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_total_active_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2413.825', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_total_active_returned_energy-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.test_name_total_active_returned_energy', + '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': 'Total active returned energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-emdata:0-total_act_ret', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_total_active_returned_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Total active returned energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_total_active_returned_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_total_apparent_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': None, + 'entity_id': 'sensor.test_name_total_apparent_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total apparent power', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-total_aprt_power', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_total_apparent_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Test name Total apparent power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_total_apparent_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2525.779', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_total_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': None, + 'entity_id': 'sensor.test_name_total_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total current', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-total_current', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_total_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Test name Total current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_total_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.116', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_uptime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_name_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-sys-uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Test name Uptime', + }), + 'context': , + 'entity_id': 'sensor.test_name_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-05-20T20:42:37+00:00', + }) +# --- +# name: test_shelly_pro_3em[update.test_name_beta_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.test_name_beta_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Beta firmware', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '123456789ABC-sys-fwupdate_beta', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_pro_3em[update.test_name_beta_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/shelly/icon.png', + 'friendly_name': 'Test name Beta firmware', + 'in_progress': False, + 'installed_version': '1.6.1', + 'latest_version': '1.6.1', + 'release_summary': None, + 'release_url': 'https://shelly-api-docs.shelly.cloud/gen2/changelog/#unreleased', + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.test_name_beta_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_pro_3em[update.test_name_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.test_name_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '123456789ABC-sys-fwupdate', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_pro_3em[update.test_name_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/shelly/icon.png', + 'friendly_name': 'Test name Firmware', + 'in_progress': False, + 'installed_version': '1.6.1', + 'latest_version': '1.6.1', + 'release_summary': None, + 'release_url': 'https://shelly-api-docs.shelly.cloud/gen2/changelog/', + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.test_name_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/shelly/test_devices.py b/tests/components/shelly/test_devices.py index b24645f651d..b1703ea03e9 100644 --- a/tests/components/shelly/test_devices.py +++ b/tests/components/shelly/test_devices.py @@ -3,24 +3,29 @@ from unittest.mock import Mock from aioshelly.const import MODEL_2PM_G3, MODEL_PRO_EM3 +from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.shelly.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry -from . import init_integration +from . import force_uptime_value, init_integration, snapshot_device_entities from tests.common import async_load_json_object_fixture +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_shelly_2pm_gen3_no_relay_names( hass: HomeAssistant, mock_rpc_device: Mock, entity_registry: EntityRegistry, device_registry: DeviceRegistry, monkeypatch: pytest.MonkeyPatch, + snapshot: SnapshotAssertion, + freezer: FrozenDateTimeFactory, ) -> None: """Test Shelly 2PM Gen3 without relay names. @@ -32,7 +37,9 @@ async def test_shelly_2pm_gen3_no_relay_names( monkeypatch.setattr(mock_rpc_device, "status", device_fixture["status"]) monkeypatch.setattr(mock_rpc_device, "config", device_fixture["config"]) - await init_integration(hass, gen=3, model=MODEL_2PM_G3) + await force_uptime_value(hass, freezer) + + config_entry = await init_integration(hass, gen=3, model=MODEL_2PM_G3) # Relay 0 sub-device entity_id = "switch.test_name_switch_0" @@ -97,6 +104,10 @@ async def test_shelly_2pm_gen3_no_relay_names( assert device_entry assert device_entry.name == "Test name" + await snapshot_device_entities( + hass, entity_registry, snapshot, config_entry.entry_id + ) + async def test_shelly_2pm_gen3_relay_names( hass: HomeAssistant, @@ -183,12 +194,15 @@ async def test_shelly_2pm_gen3_relay_names( assert device_entry.name == "Test name" +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_shelly_2pm_gen3_cover( hass: HomeAssistant, mock_rpc_device: Mock, entity_registry: EntityRegistry, device_registry: DeviceRegistry, monkeypatch: pytest.MonkeyPatch, + snapshot: SnapshotAssertion, + freezer: FrozenDateTimeFactory, ) -> None: """Test Shelly 2PM Gen3 with cover profile. @@ -201,7 +215,9 @@ async def test_shelly_2pm_gen3_cover( monkeypatch.setattr(mock_rpc_device, "status", device_fixture["status"]) monkeypatch.setattr(mock_rpc_device, "config", device_fixture["config"]) - await init_integration(hass, gen=3, model=MODEL_2PM_G3) + await force_uptime_value(hass, freezer) + + config_entry = await init_integration(hass, gen=3, model=MODEL_2PM_G3) entity_id = "cover.test_name" @@ -239,6 +255,10 @@ async def test_shelly_2pm_gen3_cover( assert device_entry assert device_entry.name == "Test name" + await snapshot_device_entities( + hass, entity_registry, snapshot, config_entry.entry_id + ) + async def test_shelly_2pm_gen3_cover_with_name( hass: HomeAssistant, @@ -298,12 +318,15 @@ async def test_shelly_2pm_gen3_cover_with_name( assert device_entry.name == "Test name" +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_shelly_pro_3em( hass: HomeAssistant, mock_rpc_device: Mock, entity_registry: EntityRegistry, device_registry: DeviceRegistry, monkeypatch: pytest.MonkeyPatch, + snapshot: SnapshotAssertion, + freezer: FrozenDateTimeFactory, ) -> None: """Test Shelly Pro 3EM. @@ -314,7 +337,9 @@ async def test_shelly_pro_3em( monkeypatch.setattr(mock_rpc_device, "status", device_fixture["status"]) monkeypatch.setattr(mock_rpc_device, "config", device_fixture["config"]) - await init_integration(hass, gen=2, model=MODEL_PRO_EM3) + await force_uptime_value(hass, freezer) + + config_entry = await init_integration(hass, gen=2, model=MODEL_PRO_EM3) # Main device entity_id = "sensor.test_name_total_active_power" @@ -368,6 +393,10 @@ async def test_shelly_pro_3em( assert device_entry assert device_entry.name == "Test name Phase C" + await snapshot_device_entities( + hass, entity_registry, snapshot, config_entry.entry_id + ) + async def test_shelly_pro_3em_with_emeter_name( hass: HomeAssistant, diff --git a/tests/components/tedee/snapshots/test_diagnostics.ambr b/tests/components/tedee/snapshots/test_diagnostics.ambr index 046a8fd210a..63707477df9 100644 --- a/tests/components/tedee/snapshots/test_diagnostics.ambr +++ b/tests/components/tedee/snapshots/test_diagnostics.ambr @@ -3,6 +3,7 @@ dict({ '0': dict({ 'battery_level': 70, + 'door_state': 0, 'duration_pullspring': 2, 'is_charging': False, 'is_connected': True, @@ -16,6 +17,7 @@ }), '1': dict({ 'battery_level': 70, + 'door_state': 0, 'duration_pullspring': 0, 'is_charging': False, 'is_connected': True, diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py index a061ce86256..708ad6bdecd 100644 --- a/tests/components/template/test_fan.py +++ b/tests/components/template/test_fan.py @@ -28,11 +28,30 @@ from tests.components.fan import common TEST_OBJECT_ID = "test_fan" TEST_ENTITY_ID = f"fan.{TEST_OBJECT_ID}" + # Represent for fan's state _STATE_INPUT_BOOLEAN = "input_boolean.state" -# Represent for fan's state +# Represent for fan's percent +_STATE_TEST_SENSOR = "sensor.test_sensor" +# Represent for fan's availability _STATE_AVAILABILITY_BOOLEAN = "availability_boolean.state" +TEST_STATE_TRIGGER = { + "trigger": { + "trigger": "state", + "entity_id": [ + TEST_ENTITY_ID, + _STATE_INPUT_BOOLEAN, + _STATE_AVAILABILITY_BOOLEAN, + _STATE_TEST_SENSOR, + ], + }, + "variables": {"triggering_entity": "{{ trigger.entity_id }}"}, + "action": [ + {"event": "action_event", "event_data": {"what": "{{ triggering_entity }}"}} + ], +} + OPTIMISTIC_ON_OFF_ACTIONS = { "turn_on": { "service": "test.automation", @@ -177,61 +196,22 @@ async def async_setup_modern_format( await hass.async_block_till_done() -async def async_setup_legacy_named_fan( +async def async_setup_trigger_format( hass: HomeAssistant, count: int, fan_config: dict[str, Any] -): - """Do setup of a named fan via legacy format.""" - await async_setup_legacy_format(hass, count, {TEST_OBJECT_ID: fan_config}) - - -async def async_setup_modern_named_fan( - hass: HomeAssistant, count: int, fan_config: dict[str, Any] -): - """Do setup of a named fan via legacy format.""" - await async_setup_modern_format(hass, count, {"name": TEST_OBJECT_ID, **fan_config}) - - -async def async_setup_legacy_format_with_attribute( - hass: HomeAssistant, - count: int, - attribute: str, - attribute_template: str, - extra_config: dict, ) -> None: - """Do setup of a legacy fan that has a single templated attribute.""" - extra = {attribute: attribute_template} if attribute and attribute_template else {} - await async_setup_legacy_format( - hass, - count, - { - TEST_OBJECT_ID: { - **extra_config, - "value_template": "{{ 1 == 1 }}", - **extra, - } - }, - ) + """Do setup of fan integration via trigger format.""" + config = {"template": {"fan": fan_config, **TEST_STATE_TRIGGER}} + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) -async def async_setup_modern_format_with_attribute( - hass: HomeAssistant, - count: int, - attribute: str, - attribute_template: str, - extra_config: dict, -) -> None: - """Do setup of a modern fan that has a single templated attribute.""" - extra = {attribute: attribute_template} if attribute and attribute_template else {} - await async_setup_modern_format( - hass, - count, - { - "name": TEST_OBJECT_ID, - **extra_config, - "state": "{{ 1 == 1 }}", - **extra, - }, - ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() @pytest.fixture @@ -246,6 +226,8 @@ async def setup_fan( await async_setup_legacy_format(hass, count, fan_config) elif style == ConfigurationStyle.MODERN: await async_setup_modern_format(hass, count, fan_config) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format(hass, count, fan_config) @pytest.fixture @@ -257,9 +239,15 @@ async def setup_named_fan( ) -> None: """Do setup of fan integration.""" if style == ConfigurationStyle.LEGACY: - await async_setup_legacy_named_fan(hass, count, fan_config) + await async_setup_legacy_format(hass, count, {TEST_OBJECT_ID: fan_config}) elif style == ConfigurationStyle.MODERN: - await async_setup_modern_named_fan(hass, count, fan_config) + await async_setup_modern_format( + hass, count, {"name": TEST_OBJECT_ID, **fan_config} + ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, count, {"name": TEST_OBJECT_ID, **fan_config} + ) @pytest.fixture @@ -290,6 +278,15 @@ async def setup_state_fan( "state": state_template, }, ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + { + **NAMED_ON_OFF_ACTIONS, + "state": state_template, + }, + ) @pytest.fixture @@ -309,6 +306,10 @@ async def setup_test_fan_with_extra_config( await async_setup_modern_format( hass, count, {"name": TEST_OBJECT_ID, **fan_config, **extra_config} ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, count, {"name": TEST_OBJECT_ID, **fan_config, **extra_config} + ) @pytest.fixture @@ -320,12 +321,35 @@ async def setup_optimistic_fan_attribute( ) -> None: """Do setup of a non-optimistic fan with an optimistic attribute.""" if style == ConfigurationStyle.LEGACY: - await async_setup_legacy_format_with_attribute( - hass, count, "", "", extra_config + await async_setup_legacy_format( + hass, + count, + { + TEST_OBJECT_ID: { + **extra_config, + "value_template": "{{ 1 == 1 }}", + } + }, ) elif style == ConfigurationStyle.MODERN: - await async_setup_modern_format_with_attribute( - hass, count, "", "", extra_config + await async_setup_modern_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + **extra_config, + "state": "{{ 1 == 1 }}", + }, + ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + **extra_config, + "state": "{{ 1 == 1 }}", + }, ) @@ -365,11 +389,23 @@ async def setup_single_attribute_state_fan( **extra_config, }, ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + { + **NAMED_ON_OFF_ACTIONS, + "state": state_template, + **extra, + **extra_config, + }, + ) @pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 'on' }}")]) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_state_fan") async def test_missing_optional_config(hass: HomeAssistant) -> None: @@ -379,7 +415,8 @@ async def test_missing_optional_config(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( "fan_config", @@ -404,7 +441,8 @@ async def test_wrong_template_config(hass: HomeAssistant) -> None: ("count", "state_template"), [(1, "{{ is_state('input_boolean.state', 'on') }}")] ) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_state_fan") async def test_state_template(hass: HomeAssistant) -> None: @@ -433,7 +471,8 @@ async def test_state_template(hass: HomeAssistant) -> None: ], ) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_state_fan") async def test_state_template_states(hass: HomeAssistant, expected: str) -> None: @@ -442,29 +481,28 @@ async def test_state_template_states(hass: HomeAssistant, expected: str) -> None @pytest.mark.parametrize( - ("count", "state_template", "attribute_template", "extra_config"), + ("count", "state_template", "attribute_template", "extra_config", "attribute"), [ ( 1, "{{ 1 == 1}}", - "{% if states.input_boolean.state.state %}/local/switch.png{% endif %}", + "{% if is_state('sensor.test_sensor', 'on') %}/local/switch.png{% endif %}", {}, + "picture", ) ], ) @pytest.mark.parametrize( - ("style", "attribute"), - [ - (ConfigurationStyle.MODERN, "picture"), - ], + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_single_attribute_state_fan") async def test_picture_template(hass: HomeAssistant) -> None: """Test picture template.""" state = hass.states.get(TEST_ENTITY_ID) - assert state.attributes.get("entity_picture") in ("", None) + assert state.attributes.get("entity_picture") == "" - hass.states.async_set(_STATE_INPUT_BOOLEAN, STATE_ON) + hass.states.async_set(_STATE_TEST_SENSOR, STATE_ON) await hass.async_block_till_done() state = hass.states.get(TEST_ENTITY_ID) @@ -472,27 +510,26 @@ async def test_picture_template(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - ("count", "state_template", "attribute_template", "extra_config"), + ("count", "state_template", "attribute_template", "extra_config", "attribute"), [ ( 1, "{{ 1 == 1}}", "{% if states.input_boolean.state.state %}mdi:eye{% endif %}", {}, + "icon", ) ], ) @pytest.mark.parametrize( - ("style", "attribute"), - [ - (ConfigurationStyle.MODERN, "icon"), - ], + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_single_attribute_state_fan") async def test_icon_template(hass: HomeAssistant) -> None: """Test icon template.""" state = hass.states.get(TEST_ENTITY_ID) - assert state.attributes.get("icon") in ("", None) + assert state.attributes.get("icon") == "" hass.states.async_set(_STATE_INPUT_BOOLEAN, STATE_ON) await hass.async_block_till_done() @@ -507,7 +544,7 @@ async def test_icon_template(hass: HomeAssistant) -> None: ( 1, "{{ 1 == 1 }}", - "{{ states('sensor.percentage') }}", + "{{ states('sensor.test_sensor') }}", PERCENTAGE_ACTION, ) ], @@ -517,6 +554,7 @@ async def test_icon_template(hass: HomeAssistant) -> None: [ (ConfigurationStyle.LEGACY, "percentage_template"), (ConfigurationStyle.MODERN, "percentage"), + (ConfigurationStyle.TRIGGER, "percentage"), ], ) @pytest.mark.parametrize( @@ -534,7 +572,7 @@ async def test_percentage_template( hass: HomeAssistant, percent: str, expected: int, calls: list[ServiceCall] ) -> None: """Test templates with fan percentages from other entities.""" - hass.states.async_set("sensor.percentage", percent) + hass.states.async_set(_STATE_TEST_SENSOR, percent) await hass.async_block_till_done() _verify(hass, STATE_ON, expected, None, None, None) @@ -545,7 +583,7 @@ async def test_percentage_template( ( 1, "{{ 1 == 1 }}", - "{{ states('sensor.preset_mode') }}", + "{{ states('sensor.test_sensor') }}", {"preset_modes": ["auto", "smart"], **PRESET_MODE_ACTION}, ) ], @@ -555,6 +593,7 @@ async def test_percentage_template( [ (ConfigurationStyle.LEGACY, "preset_mode_template"), (ConfigurationStyle.MODERN, "preset_mode"), + (ConfigurationStyle.TRIGGER, "preset_mode"), ], ) @pytest.mark.parametrize( @@ -571,7 +610,7 @@ async def test_preset_mode_template( hass: HomeAssistant, preset_mode: str, expected: int ) -> None: """Test preset_mode template.""" - hass.states.async_set("sensor.preset_mode", preset_mode) + hass.states.async_set(_STATE_TEST_SENSOR, preset_mode) await hass.async_block_till_done() _verify(hass, STATE_ON, None, None, None, expected) @@ -582,7 +621,7 @@ async def test_preset_mode_template( ( 1, "{{ 1 == 1 }}", - "{{ is_state('binary_sensor.oscillating', 'on') }}", + "{{ is_state('sensor.test_sensor', 'on') }}", OSCILLATE_ACTION, ) ], @@ -592,6 +631,7 @@ async def test_preset_mode_template( [ (ConfigurationStyle.LEGACY, "oscillating_template"), (ConfigurationStyle.MODERN, "oscillating"), + (ConfigurationStyle.TRIGGER, "oscillating"), ], ) @pytest.mark.parametrize( @@ -606,7 +646,7 @@ async def test_oscillating_template( hass: HomeAssistant, oscillating: str, expected: bool | None ) -> None: """Test oscillating template.""" - hass.states.async_set("binary_sensor.oscillating", oscillating) + hass.states.async_set(_STATE_TEST_SENSOR, oscillating) await hass.async_block_till_done() _verify(hass, STATE_ON, None, expected, None, None) @@ -617,7 +657,7 @@ async def test_oscillating_template( ( 1, "{{ 1 == 1 }}", - "{{ states('sensor.direction') }}", + "{{ states('sensor.test_sensor') }}", DIRECTION_ACTION, ) ], @@ -627,6 +667,7 @@ async def test_oscillating_template( [ (ConfigurationStyle.LEGACY, "direction_template"), (ConfigurationStyle.MODERN, "direction"), + (ConfigurationStyle.TRIGGER, "direction"), ], ) @pytest.mark.parametrize( @@ -641,7 +682,7 @@ async def test_direction_template( hass: HomeAssistant, direction: str, expected: bool | None ) -> None: """Test direction template.""" - hass.states.async_set("sensor.direction", direction) + hass.states.async_set(_STATE_TEST_SENSOR, direction) await hass.async_block_till_done() _verify(hass, STATE_ON, None, None, expected, None) @@ -674,6 +715,17 @@ async def test_direction_template( "turn_off": {"service": "script.fan_off"}, }, ), + ( + ConfigurationStyle.TRIGGER, + { + "availability": ("{{ is_state('availability_boolean.state', 'on') }}"), + "state": "{{ 'on' }}", + "oscillating": "{{ 1 == 1 }}", + "direction": "{{ 'forward' }}", + "turn_on": {"service": "script.fan_on"}, + "turn_off": {"service": "script.fan_off"}, + }, + ), ], ) @pytest.mark.usefixtures("setup_named_fan") @@ -707,6 +759,14 @@ async def test_availability_template_with_entities(hass: HomeAssistant) -> None: }, [STATE_OFF, None, None, None], ), + ( + ConfigurationStyle.TRIGGER, + { + "state": "{{ 'unavailable' }}", + **OPTIMISTIC_ON_OFF_ACTIONS, + }, + [STATE_OFF, None, None, None], + ), ( ConfigurationStyle.LEGACY, { @@ -733,6 +793,19 @@ async def test_availability_template_with_entities(hass: HomeAssistant) -> None: }, [STATE_ON, 0, None, None], ), + ( + ConfigurationStyle.TRIGGER, + { + "state": "{{ 'on' }}", + "percentage": "{{ 0 }}", + **OPTIMISTIC_PERCENTAGE_CONFIG, + "oscillating": "{{ 'unavailable' }}", + **OSCILLATE_ACTION, + "direction": "{{ 'unavailable' }}", + **DIRECTION_ACTION, + }, + [STATE_ON, 0, None, None], + ), ( ConfigurationStyle.LEGACY, { @@ -759,6 +832,19 @@ async def test_availability_template_with_entities(hass: HomeAssistant) -> None: }, [STATE_ON, 66, True, DIRECTION_FORWARD], ), + ( + ConfigurationStyle.TRIGGER, + { + "state": "{{ 'on' }}", + "percentage": "{{ 66 }}", + **OPTIMISTIC_PERCENTAGE_CONFIG, + "oscillating": "{{ 1 == 1 }}", + **OSCILLATE_ACTION, + "direction": "{{ 'forward' }}", + **DIRECTION_ACTION, + }, + [STATE_ON, 66, True, DIRECTION_FORWARD], + ), ( ConfigurationStyle.LEGACY, { @@ -785,6 +871,19 @@ async def test_availability_template_with_entities(hass: HomeAssistant) -> None: }, [STATE_OFF, 0, None, None], ), + ( + ConfigurationStyle.TRIGGER, + { + "state": "{{ 'abc' }}", + "percentage": "{{ 0 }}", + **OPTIMISTIC_PERCENTAGE_CONFIG, + "oscillating": "{{ 'xyz' }}", + **OSCILLATE_ACTION, + "direction": "{{ 'right' }}", + **DIRECTION_ACTION, + }, + [STATE_OFF, 0, None, None], + ), ], ) @pytest.mark.usefixtures("setup_named_fan") @@ -821,16 +920,33 @@ async def test_template_with_unavailable_entities(hass: HomeAssistant, states) - "turn_off": {"service": "script.fan_off"}, }, ), + ( + ConfigurationStyle.TRIGGER, + { + "state": "{{ 'on' }}", + "availability": "{{ x - 12 }}", + "preset_mode": ("{{ states('input_select.preset_mode') }}"), + "oscillating": "{{ states('input_select.osc') }}", + "direction": "{{ states('input_select.direction') }}", + "turn_on": {"service": "script.fan_on"}, + "turn_off": {"service": "script.fan_off"}, + }, + ), ], ) @pytest.mark.usefixtures("setup_named_fan") 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.""" - assert hass.states.get("fan.test_fan").state != STATE_UNAVAILABLE - assert "TemplateError" in caplog_setup_text - assert "x" in caplog_setup_text + # Ensure trigger entities update. + hass.states.async_set(_STATE_INPUT_BOOLEAN, STATE_ON) + await hass.async_block_till_done() + + assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE + + err = "'x' is undefined" + assert err in caplog_setup_text or err in caplog.text @pytest.mark.parametrize(("count", "extra_config"), [(1, OPTIMISTIC_ON_OFF_ACTIONS)]) @@ -849,6 +965,12 @@ async def test_invalid_availability_template_keeps_component_available( "state": "{{ 'off' }}", }, ), + ( + ConfigurationStyle.TRIGGER, + { + "state": "{{ 'off' }}", + }, + ), ], ) @pytest.mark.usefixtures("setup_test_fan_with_extra_config") @@ -899,6 +1021,12 @@ async def test_on_off(hass: HomeAssistant, calls: list[ServiceCall]) -> None: "state": "{{ 'off' }}", }, ), + ( + ConfigurationStyle.TRIGGER, + { + "state": "{{ 'off' }}", + }, + ), ], ) @pytest.mark.usefixtures("setup_test_fan_with_extra_config") @@ -981,6 +1109,12 @@ async def test_on_with_extra_attributes( "state": "{{ 'on' }}", }, ), + ( + ConfigurationStyle.TRIGGER, + { + "state": "{{ 'on' }}", + }, + ), ], ) @pytest.mark.usefixtures("setup_test_fan_with_extra_config") @@ -1008,6 +1142,12 @@ async def test_set_invalid_direction_from_initial_stage(hass: HomeAssistant) -> "state": "{{ 'on' }}", }, ), + ( + ConfigurationStyle.TRIGGER, + { + "state": "{{ 'on' }}", + }, + ), ], ) @pytest.mark.usefixtures("setup_test_fan_with_extra_config") @@ -1045,6 +1185,12 @@ async def test_set_osc(hass: HomeAssistant, calls: list[ServiceCall]) -> None: "state": "{{ 'on' }}", }, ), + ( + ConfigurationStyle.TRIGGER, + { + "state": "{{ 'on' }}", + }, + ), ], ) @pytest.mark.usefixtures("setup_test_fan_with_extra_config") @@ -1082,6 +1228,12 @@ async def test_set_direction(hass: HomeAssistant, calls: list[ServiceCall]) -> N "state": "{{ 'on' }}", }, ), + ( + ConfigurationStyle.TRIGGER, + { + "state": "{{ 'on' }}", + }, + ), ], ) @pytest.mark.usefixtures("setup_test_fan_with_extra_config") @@ -1117,6 +1269,12 @@ async def test_set_invalid_direction( "state": "{{ 'on' }}", }, ), + ( + ConfigurationStyle.TRIGGER, + { + "state": "{{ 'on' }}", + }, + ), ], ) @pytest.mark.usefixtures("setup_test_fan_with_extra_config") @@ -1154,6 +1312,12 @@ async def test_preset_modes(hass: HomeAssistant, calls: list[ServiceCall]) -> No "state": "{{ 'on' }}", }, ), + ( + ConfigurationStyle.TRIGGER, + { + "state": "{{ 'on' }}", + }, + ), ], ) @pytest.mark.usefixtures("setup_test_fan_with_extra_config") @@ -1198,6 +1362,12 @@ async def test_set_percentage(hass: HomeAssistant, calls: list[ServiceCall]) -> "state": "{{ 'on' }}", }, ), + ( + ConfigurationStyle.TRIGGER, + { + "state": "{{ 'on' }}", + }, + ), ], ) @pytest.mark.usefixtures("setup_test_fan_with_extra_config") @@ -1236,7 +1406,7 @@ async def test_increase_decrease_speed( ) @pytest.mark.parametrize( "style", - [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN], + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_named_fan") async def test_optimistic_state(hass: HomeAssistant, calls: list[ServiceCall]) -> None: @@ -1307,7 +1477,8 @@ async def test_optimistic_state(hass: HomeAssistant, calls: list[ServiceCall]) - @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( ("extra_config", "attribute", "action", "verify_attr", "coro", "value"), @@ -1383,6 +1554,12 @@ async def test_optimistic_attributes( "state": "{{ 'on' }}", }, ), + ( + ConfigurationStyle.TRIGGER, + { + "state": "{{ 'on' }}", + }, + ), ], ) @pytest.mark.usefixtures("setup_test_fan_with_extra_config") @@ -1420,6 +1597,12 @@ async def test_increase_decrease_speed_default_speed_count( "state": "{{ 'on' }}", }, ), + ( + ConfigurationStyle.TRIGGER, + { + "state": "{{ 'on' }}", + }, + ), ], ) @pytest.mark.usefixtures("setup_test_fan_with_extra_config") @@ -1451,6 +1634,12 @@ async def test_set_invalid_osc_from_initial_state( "state": "{{ 'on' }}", }, ), + ( + ConfigurationStyle.TRIGGER, + { + "state": "{{ 'on' }}", + }, + ), ], ) @pytest.mark.usefixtures("setup_test_fan_with_extra_config") @@ -1474,24 +1663,37 @@ async def test_set_invalid_osc(hass: HomeAssistant, calls: list[ServiceCall]) -> [ ( { - "test_template_cover_01": UNIQUE_ID_CONFIG, - "test_template_cover_02": UNIQUE_ID_CONFIG, + "test_template_fan_01": UNIQUE_ID_CONFIG, + "test_template_fan_02": UNIQUE_ID_CONFIG, }, ConfigurationStyle.LEGACY, ), ( [ { - "name": "test_template_cover_01", + "name": "test_template_fan_01", **UNIQUE_ID_CONFIG, }, { - "name": "test_template_cover_02", + "name": "test_template_fan_02", **UNIQUE_ID_CONFIG, }, ], ConfigurationStyle.MODERN, ), + ( + [ + { + "name": "test_template_fan_01", + **UNIQUE_ID_CONFIG, + }, + { + "name": "test_template_fan_02", + **UNIQUE_ID_CONFIG, + }, + ], + ConfigurationStyle.TRIGGER, + ), ], ) @pytest.mark.usefixtures("setup_fan") @@ -1506,7 +1708,7 @@ async def test_unique_id(hass: HomeAssistant) -> None: ) @pytest.mark.parametrize( "style", - [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN], + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( ("fan_config", "percentage_step"), @@ -1529,7 +1731,7 @@ async def test_speed_percentage_step(hass: HomeAssistant, percentage_step) -> No ) @pytest.mark.parametrize( "style", - [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN], + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_named_fan") async def test_preset_mode_supported_features(hass: HomeAssistant) -> None: @@ -1541,25 +1743,12 @@ async def test_preset_mode_supported_features(hass: HomeAssistant) -> None: assert attributes.get("supported_features") & FanEntityFeature.PRESET_MODE -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("style", "fan_config"), - [ - ( - ConfigurationStyle.LEGACY, - { - "turn_on": [], - "turn_off": [], - }, - ), - ( - ConfigurationStyle.MODERN, - { - "turn_on": [], - "turn_off": [], - }, - ), - ], + ("count", "fan_config"), [(1, {"turn_on": [], "turn_off": []})] +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( ("extra_config", "supported_features"), @@ -1590,10 +1779,10 @@ async def test_preset_mode_supported_features(hass: HomeAssistant) -> None: ), ], ) +@pytest.mark.usefixtures("setup_test_fan_with_extra_config") async def test_empty_action_config( hass: HomeAssistant, supported_features: FanEntityFeature, - setup_test_fan_with_extra_config, ) -> None: """Test configuration with empty script.""" state = hass.states.get(TEST_ENTITY_ID) diff --git a/tests/components/template/test_vacuum.py b/tests/components/template/test_vacuum.py index 90ca0b56afb..ae65823309a 100644 --- a/tests/components/template/test_vacuum.py +++ b/tests/components/template/test_vacuum.py @@ -26,8 +26,26 @@ from tests.components.vacuum import common TEST_OBJECT_ID = "test_vacuum" TEST_ENTITY_ID = f"vacuum.{TEST_OBJECT_ID}" -STATE_INPUT_SELECT = "input_select.state" -BATTERY_LEVEL_INPUT_NUMBER = "input_number.battery_level" +TEST_STATE_SENSOR = "sensor.test_state" +TEST_SPEED_SENSOR = "sensor.test_fan_speed" +TEST_BATTERY_LEVEL_SENSOR = "sensor.test_battery_level" +TEST_AVAILABILITY_ENTITY = "availability_state.state" + +TEST_STATE_TRIGGER = { + "trigger": { + "trigger": "state", + "entity_id": [ + TEST_STATE_SENSOR, + TEST_SPEED_SENSOR, + TEST_BATTERY_LEVEL_SENSOR, + TEST_AVAILABILITY_ENTITY, + ], + }, + "variables": {"triggering_entity": "{{ trigger.entity_id }}"}, + "action": [ + {"event": "action_event", "event_data": {"what": "{{ triggering_entity }}"}} + ], +} START_ACTION = { "start": { @@ -140,6 +158,24 @@ async def async_setup_modern_format( await hass.async_block_till_done() +async def async_setup_trigger_format( + hass: HomeAssistant, count: int, vacuum_config: dict[str, Any] +) -> None: + """Do setup of vacuum integration via trigger format.""" + config = {"template": {"vacuum": vacuum_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_vacuum( hass: HomeAssistant, @@ -152,6 +188,8 @@ async def setup_vacuum( await async_setup_legacy_format(hass, count, vacuum_config) elif style == ConfigurationStyle.MODERN: await async_setup_modern_format(hass, count, vacuum_config) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format(hass, count, vacuum_config) @pytest.fixture @@ -171,6 +209,10 @@ async def setup_test_vacuum_with_extra_config( await async_setup_modern_format( hass, count, {"name": TEST_OBJECT_ID, **vacuum_config, **extra_config} ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, count, {"name": TEST_OBJECT_ID, **vacuum_config, **extra_config} + ) @pytest.fixture @@ -202,6 +244,16 @@ async def setup_state_vacuum( **TEMPLATE_VACUUM_ACTIONS, }, ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + "state": state_template, + **TEMPLATE_VACUUM_ACTIONS, + }, + ) @pytest.fixture @@ -236,6 +288,17 @@ async def setup_base_vacuum( **extra_config, }, ) + elif style == ConfigurationStyle.TRIGGER: + state_config = {"state": state_template} if state_template else {} + await async_setup_trigger_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + **state_config, + **extra_config, + }, + ) @pytest.fixture @@ -277,6 +340,19 @@ async def setup_single_attribute_state_vacuum( **extra_config, }, ) + elif style == ConfigurationStyle.TRIGGER: + state_config = {"state": state_template} if state_template else {} + await async_setup_trigger_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + **state_config, + **TEMPLATE_VACUUM_ACTIONS, + **extra, + **extra_config, + }, + ) @pytest.fixture @@ -313,6 +389,18 @@ async def setup_attributes_state_vacuum( **TEMPLATE_VACUUM_ACTIONS, }, ) + elif style == ConfigurationStyle.TRIGGER: + state_config = {"state": state_template} if state_template else {} + await async_setup_trigger_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + "attributes": attributes, + **state_config, + **TEMPLATE_VACUUM_ACTIONS, + }, + ) @pytest.mark.parametrize("count", [1]) @@ -333,6 +421,13 @@ async def setup_attributes_state_vacuum( STATE_UNKNOWN, None, ), + ( + ConfigurationStyle.TRIGGER, + None, + {"start": {"service": "script.vacuum_start"}}, + STATE_UNKNOWN, + None, + ), ( ConfigurationStyle.LEGACY, "{{ 'cleaning' }}", @@ -353,6 +448,16 @@ async def setup_attributes_state_vacuum( VacuumActivity.CLEANING, 100, ), + ( + ConfigurationStyle.TRIGGER, + "{{ 'cleaning' }}", + { + "battery_level": "{{ 100 }}", + "start": {"service": "script.vacuum_start"}, + }, + VacuumActivity.CLEANING, + 100, + ), ( ConfigurationStyle.LEGACY, "{{ 'abc' }}", @@ -373,6 +478,16 @@ async def setup_attributes_state_vacuum( STATE_UNKNOWN, None, ), + ( + ConfigurationStyle.TRIGGER, + "{{ 'abc' }}", + { + "battery_level": "{{ 101 }}", + "start": {"service": "script.vacuum_start"}, + }, + STATE_UNKNOWN, + None, + ), ( ConfigurationStyle.LEGACY, "{{ this_function_does_not_exist() }}", @@ -395,18 +510,35 @@ async def setup_attributes_state_vacuum( STATE_UNKNOWN, None, ), + ( + ConfigurationStyle.TRIGGER, + "{{ this_function_does_not_exist() }}", + { + "battery_level": "{{ this_function_does_not_exist() }}", + "fan_speed": "{{ this_function_does_not_exist() }}", + "start": {"service": "script.vacuum_start"}, + }, + STATE_UNAVAILABLE, + None, + ), ], ) @pytest.mark.usefixtures("setup_base_vacuum") async def test_valid_legacy_configs(hass: HomeAssistant, count, parm1, parm2) -> None: """Test: configs.""" + + # Ensure trigger entity templates are rendered + hass.states.async_set(TEST_STATE_SENSOR, None) + await hass.async_block_till_done() + assert len(hass.states.async_all("vacuum")) == count _verify(hass, parm1, parm2) @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"), @@ -423,13 +555,14 @@ async def test_invalid_configs(hass: HomeAssistant, count) -> None: @pytest.mark.parametrize( ("count", "state_template", "extra_config"), - [(1, "{{ states('input_select.state') }}", {})], + [(1, "{{ states('sensor.test_state') }}", {})], ) @pytest.mark.parametrize( ("style", "attribute"), [ (ConfigurationStyle.LEGACY, "battery_level_template"), (ConfigurationStyle.MODERN, "battery_level"), + (ConfigurationStyle.TRIGGER, "battery_level"), ], ) @pytest.mark.parametrize( @@ -447,6 +580,10 @@ async def test_battery_level_template( hass: HomeAssistant, expected: int | None ) -> None: """Test templates with values from other entities.""" + # Ensure trigger entity templates are rendered + hass.states.async_set(TEST_STATE_SENSOR, None) + await hass.async_block_till_done() + _verify(hass, STATE_UNKNOWN, expected) @@ -455,7 +592,7 @@ async def test_battery_level_template( [ ( 1, - "{{ states('input_select.state') }}", + "{{ states('sensor.test_state') }}", { "fan_speeds": ["low", "medium", "high"], }, @@ -467,6 +604,7 @@ async def test_battery_level_template( [ (ConfigurationStyle.LEGACY, "fan_speed_template"), (ConfigurationStyle.MODERN, "fan_speed"), + (ConfigurationStyle.TRIGGER, "fan_speed"), ], ) @pytest.mark.parametrize( @@ -481,33 +619,39 @@ async def test_battery_level_template( @pytest.mark.usefixtures("setup_single_attribute_state_vacuum") async def test_fan_speed_template(hass: HomeAssistant, expected: str | None) -> None: """Test templates with values from other entities.""" + # Ensure trigger entity templates are rendered + hass.states.async_set(TEST_STATE_SENSOR, None) + await hass.async_block_till_done() + _verify(hass, STATE_UNKNOWN, None, expected) @pytest.mark.parametrize( - ("count", "state_template", "attribute_template", "extra_config"), + ("count", "state_template", "attribute_template", "extra_config", "attribute"), [ ( 1, "{{ 'on' }}", - "{% if states.switch.test_state.state %}mdi:check{% endif %}", + "{% if states.sensor.test_state.state %}mdi:check{% endif %}", {}, + "icon", ) ], ) @pytest.mark.parametrize( - ("style", "attribute"), + ("style", "expected"), [ - (ConfigurationStyle.MODERN, "icon"), + (ConfigurationStyle.MODERN, ""), + (ConfigurationStyle.TRIGGER, None), ], ) @pytest.mark.usefixtures("setup_single_attribute_state_vacuum") -async def test_icon_template(hass: HomeAssistant) -> None: +async def test_icon_template(hass: HomeAssistant, expected: int) -> None: """Test icon template.""" state = hass.states.get(TEST_ENTITY_ID) - assert state.attributes.get("icon") in ("", None) + assert state.attributes.get("icon") == expected - hass.states.async_set("switch.test_state", STATE_ON) + hass.states.async_set(TEST_STATE_SENSOR, STATE_ON) await hass.async_block_till_done() state = hass.states.get(TEST_ENTITY_ID) @@ -515,29 +659,31 @@ async def test_icon_template(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - ("count", "state_template", "attribute_template", "extra_config"), + ("count", "state_template", "attribute_template", "extra_config", "attribute"), [ ( 1, "{{ 'on' }}", - "{% if states.switch.test_state.state %}local/vacuum.png{% endif %}", + "{% if states.sensor.test_state.state %}local/vacuum.png{% endif %}", {}, + "picture", ) ], ) @pytest.mark.parametrize( - ("style", "attribute"), + ("style", "expected"), [ - (ConfigurationStyle.MODERN, "picture"), + (ConfigurationStyle.MODERN, ""), + (ConfigurationStyle.TRIGGER, None), ], ) @pytest.mark.usefixtures("setup_single_attribute_state_vacuum") -async def test_picture_template(hass: HomeAssistant) -> None: +async def test_picture_template(hass: HomeAssistant, expected: int) -> None: """Test picture template.""" state = hass.states.get(TEST_ENTITY_ID) - assert state.attributes.get("entity_picture") in ("", None) + assert state.attributes.get("entity_picture") == expected - hass.states.async_set("switch.test_state", STATE_ON) + hass.states.async_set(TEST_STATE_SENSOR, STATE_ON) await hass.async_block_till_done() state = hass.states.get(TEST_ENTITY_ID) @@ -560,6 +706,7 @@ async def test_picture_template(hass: HomeAssistant) -> None: [ (ConfigurationStyle.LEGACY, "availability_template"), (ConfigurationStyle.MODERN, "availability"), + (ConfigurationStyle.TRIGGER, "availability"), ], ) @pytest.mark.usefixtures("setup_single_attribute_state_vacuum") @@ -567,14 +714,14 @@ 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, 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, STATE_OFF) await hass.async_block_till_done() # device state should be unavailable @@ -597,15 +744,22 @@ async def test_available_template_with_entities(hass: HomeAssistant) -> None: [ (ConfigurationStyle.LEGACY, "availability_template"), (ConfigurationStyle.MODERN, "availability"), + (ConfigurationStyle.TRIGGER, "availability"), ], ) @pytest.mark.usefixtures("setup_single_attribute_state_vacuum") 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.""" + + # Ensure state change triggers trigger entity. + hass.states.async_set(TEST_STATE_SENSOR, None) + await hass.async_block_till_done() + assert hass.states.get(TEST_ENTITY_ID) != 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( @@ -627,7 +781,7 @@ async def test_attribute_templates(hass: HomeAssistant) -> None: state = hass.states.get(TEST_ENTITY_ID) assert state.attributes["test_attribute"] == "It ." - hass.states.async_set("sensor.test_state", "Works") + hass.states.async_set(TEST_STATE_SENSOR, "Works") await hass.async_block_till_done() await async_update_entity(hass, TEST_ENTITY_ID) state = hass.states.get(TEST_ENTITY_ID) @@ -635,26 +789,31 @@ async def test_attribute_templates(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( ("count", "state_template", "attributes"), [ ( 1, - "{{ states('input_select.state') }}", + "{{ states('sensor.test_state') }}", {"test_attribute": "{{ this_function_does_not_exist() }}"}, ) ], ) @pytest.mark.usefixtures("setup_attributes_state_vacuum") async def test_invalid_attribute_template( - hass: HomeAssistant, caplog_setup_text + hass: HomeAssistant, caplog_setup_text, caplog: pytest.LogCaptureFixture ) -> None: """Test that errors are logged if rendering template fails.""" + + hass.states.async_set(TEST_STATE_SENSOR, "Works") + await hass.async_block_till_done() + assert len(hass.states.async_all("vacuum")) == 1 - assert "test_attribute" in caplog_setup_text - assert "TemplateError" in caplog_setup_text + err = "'this_function_does_not_exist' is undefined" + assert err in caplog_setup_text or err in caplog.text @pytest.mark.parametrize("count", [1]) @@ -689,6 +848,21 @@ async def test_invalid_attribute_template( }, ], ), + ( + ConfigurationStyle.TRIGGER, + [ + { + "name": "test_template_vacuum_01", + "state": "{{ true }}", + **UNIQUE_ID_CONFIG, + }, + { + "name": "test_template_vacuum_02", + "state": "{{ false }}", + **UNIQUE_ID_CONFIG, + }, + ], + ), ], ) @pytest.mark.usefixtures("setup_vacuum") @@ -701,7 +875,8 @@ async def test_unique_id(hass: HomeAssistant) -> None: ("count", "state_template", "extra_config"), [(1, None, START_ACTION)] ) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_base_vacuum") async def test_unused_services(hass: HomeAssistant) -> None: @@ -741,10 +916,11 @@ async def test_unused_services(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("count", "state_template"), - [(1, "{{ states('input_select.state') }}")], + [(1, "{{ states('sensor.test_state') }}")], ) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( "action", @@ -782,8 +958,8 @@ async def test_state_services( [ ( 1, - "{{ states('input_select.state') }}", - "{{ states('input_select.fan_speed') }}", + "{{ states('sensor.test_state') }}", + "{{ states('sensor.test_fan_speed') }}", { "fan_speeds": ["low", "medium", "high"], }, @@ -795,6 +971,7 @@ async def test_state_services( [ (ConfigurationStyle.LEGACY, "fan_speed_template"), (ConfigurationStyle.MODERN, "fan_speed"), + (ConfigurationStyle.TRIGGER, "fan_speed"), ], ) @pytest.mark.usefixtures("setup_single_attribute_state_vacuum") @@ -835,8 +1012,8 @@ async def test_set_fan_speed(hass: HomeAssistant, calls: list[ServiceCall]) -> N [ ( 1, - "{{ states('input_select.state') }}", - "{{ states('input_select.fan_speed') }}", + "{{ states('sensor.test_state') }}", + "{{ states('sensor.test_fan_speed') }}", ) ], ) @@ -845,6 +1022,7 @@ async def test_set_fan_speed(hass: HomeAssistant, calls: list[ServiceCall]) -> N [ (ConfigurationStyle.LEGACY, "fan_speed_template"), (ConfigurationStyle.MODERN, "fan_speed"), + (ConfigurationStyle.TRIGGER, "fan_speed"), ], ) @pytest.mark.usefixtures("setup_single_attribute_state_vacuum") @@ -918,7 +1096,8 @@ async def test_nested_unique_id( @pytest.mark.parametrize(("count", "vacuum_config"), [(1, {"start": []})]) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( ("extra_config", "supported_features"), diff --git a/tests/components/vegehub/__init__.py b/tests/components/vegehub/__init__.py new file mode 100644 index 00000000000..4b0a4f0f098 --- /dev/null +++ b/tests/components/vegehub/__init__.py @@ -0,0 +1,14 @@ +"""Tests for the Vegetronix VegeHub integration.""" + +from homeassistant.components.vegehub.coordinator import VegeHubConfigEntry +from homeassistant.core import HomeAssistant + + +async def init_integration( + hass: HomeAssistant, + config_entry: VegeHubConfigEntry, +) -> None: + """Load the VegeHub integration.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/vegehub/conftest.py b/tests/components/vegehub/conftest.py new file mode 100644 index 00000000000..6e48feb4271 --- /dev/null +++ b/tests/components/vegehub/conftest.py @@ -0,0 +1,82 @@ +"""Fixtures and test data for VegeHub test methods.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.const import ( + CONF_DEVICE, + CONF_HOST, + CONF_IP_ADDRESS, + CONF_MAC, + CONF_WEBHOOK_ID, +) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture + +TEST_IP = "192.168.0.100" +TEST_UNIQUE_ID = "aabbccddeeff" +TEST_SERVER = "http://example.com" +TEST_MAC = "A1:B2:C3:D4:E5:F6" +TEST_SIMPLE_MAC = "A1B2C3D4E5F6" +TEST_HOSTNAME = "VegeHub" +TEST_WEBHOOK_ID = "webhook_id" +HUB_DATA = { + "first_boot": False, + "page_updated": False, + "error_message": 0, + "num_channels": 2, + "num_actuators": 2, + "version": "3.4.5", + "agenda": 1, + "batt_v": 9.0, + "num_vsens": 0, + "is_ac": 0, + "has_sd": 0, + "on_ap": 0, +} + + +@pytest.fixture(autouse=True) +def mock_vegehub() -> Generator[Any, Any, Any]: + """Mock the VegeHub library.""" + with patch( + "homeassistant.components.vegehub.config_flow.VegeHub", autospec=True + ) as mock_vegehub_class: + mock_instance = mock_vegehub_class.return_value + # Simulate successful API calls + mock_instance.retrieve_mac_address = AsyncMock(return_value=True) + mock_instance.setup = AsyncMock(return_value=True) + + # Mock properties + mock_instance.ip_address = TEST_IP + mock_instance.mac_address = TEST_SIMPLE_MAC + mock_instance.unique_id = TEST_UNIQUE_ID + mock_instance.url = f"http://{TEST_IP}" + mock_instance.info = load_fixture("vegehub/info_hub.json") + mock_instance.num_sensors = 2 + mock_instance.num_actuators = 2 + mock_instance.sw_version = "3.4.5" + + yield mock_instance + + +@pytest.fixture(name="mocked_config_entry") +async def fixture_mocked_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create a mock VegeHub config entry.""" + return MockConfigEntry( + domain="vegehub", + data={ + CONF_MAC: TEST_SIMPLE_MAC, + CONF_IP_ADDRESS: TEST_IP, + CONF_HOST: TEST_HOSTNAME, + CONF_DEVICE: HUB_DATA, + CONF_WEBHOOK_ID: TEST_WEBHOOK_ID, + }, + unique_id=TEST_SIMPLE_MAC, + title="VegeHub", + entry_id="12345", + ) diff --git a/tests/components/vegehub/fixtures/info_hub.json b/tests/components/vegehub/fixtures/info_hub.json new file mode 100644 index 00000000000..f12731e881e --- /dev/null +++ b/tests/components/vegehub/fixtures/info_hub.json @@ -0,0 +1,24 @@ +{ + "hub": { + "first_boot": false, + "page_updated": false, + "error_message": 0, + "num_channels": 2, + "num_actuators": 2, + "version": "3.4.5", + "agenda": 1, + "batt_v": 9.0, + "num_vsens": 0, + "is_ac": 0, + "has_sd": 0, + "on_ap": 0 + }, + "wifi": { + "ssid": "YourWiFiName", + "strength": "-29", + "chan": "4", + "ip": "192.168.0.100", + "status": "3", + "mac_addr": "A1:B2:C3:D4:E5:F6" + } +} diff --git a/tests/components/vegehub/snapshots/test_sensor.ambr b/tests/components/vegehub/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..3a9a93dc03b --- /dev/null +++ b/tests/components/vegehub/snapshots/test_sensor.ambr @@ -0,0 +1,160 @@ +# serializer version: 1 +# name: test_sensor_entities[sensor.vegehub_battery_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vegehub_battery_voltage', + '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': 'Battery voltage', + 'platform': 'vegehub', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_volts', + 'unique_id': 'A1B2C3D4E5F6_battery', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities[sensor.vegehub_battery_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'VegeHub Battery voltage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.vegehub_battery_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.330000043', + }) +# --- +# name: test_sensor_entities[sensor.vegehub_input_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vegehub_input_1', + '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': 'Input 1', + 'platform': 'vegehub', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'analog_sensor', + 'unique_id': 'A1B2C3D4E5F6_analog_0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities[sensor.vegehub_input_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'VegeHub Input 1', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.vegehub_input_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.5', + }) +# --- +# name: test_sensor_entities[sensor.vegehub_input_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vegehub_input_2', + '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': 'Input 2', + 'platform': 'vegehub', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'analog_sensor', + 'unique_id': 'A1B2C3D4E5F6_analog_1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities[sensor.vegehub_input_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'VegeHub Input 2', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.vegehub_input_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.45599997', + }) +# --- diff --git a/tests/components/vegehub/test_config_flow.py b/tests/components/vegehub/test_config_flow.py new file mode 100644 index 00000000000..1cf3924f72f --- /dev/null +++ b/tests/components/vegehub/test_config_flow.py @@ -0,0 +1,385 @@ +"""Tests for VegeHub config flow.""" + +from collections.abc import Generator +from ipaddress import ip_address +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components import zeroconf +from homeassistant.components.vegehub.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import ( + CONF_DEVICE, + CONF_HOST, + CONF_IP_ADDRESS, + CONF_MAC, + CONF_WEBHOOK_ID, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import TEST_HOSTNAME, TEST_IP, TEST_SIMPLE_MAC + +from tests.common import MockConfigEntry + +DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( + ip_address=ip_address(TEST_IP), + ip_addresses=[ip_address(TEST_IP)], + port=80, + hostname=f"{TEST_HOSTNAME}.local.", + type="mock_type", + name="myVege", + properties={ + zeroconf.ATTR_PROPERTIES_ID: TEST_HOSTNAME, + "version": "5.1.1", + }, +) + + +@pytest.fixture(autouse=True) +def mock_setup_entry() -> Generator[Any, Any, Any]: + """Prevent the actual integration from being set up.""" + with ( + patch("homeassistant.components.vegehub.async_setup_entry", return_value=True), + ): + yield + + +# Tests for flows where the user manually inputs an IP address +async def test_user_flow_success(hass: HomeAssistant) -> None: + """Test the user flow with successful configuration.""" + + 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"], {CONF_IP_ADDRESS: TEST_IP} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_IP + assert result["data"][CONF_MAC] == TEST_SIMPLE_MAC + assert result["data"][CONF_IP_ADDRESS] == TEST_IP + assert result["data"][CONF_DEVICE] is not None + assert result["data"][CONF_WEBHOOK_ID] is not None + + # Since this is user flow, there is no hostname, so hostname should be the IP address + assert result["data"][CONF_HOST] == TEST_IP + assert result["result"].unique_id == TEST_SIMPLE_MAC + + # Confirm that the entry was created + entries = hass.config_entries.async_entries(domain=DOMAIN) + assert len(entries) == 1 + + +async def test_user_flow_cannot_connect( + hass: HomeAssistant, + mock_vegehub: MagicMock, +) -> None: + """Test the user flow with bad data.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + mock_vegehub.mac_address = "" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: TEST_IP} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "cannot_connect" + + mock_vegehub.mac_address = TEST_SIMPLE_MAC + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: TEST_IP} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (TimeoutError, "timeout_connect"), + (ConnectionError, "cannot_connect"), + ], +) +async def test_user_flow_device_bad_connection_then_success( + hass: HomeAssistant, + mock_vegehub: MagicMock, + side_effect: Exception, + expected_error: str, +) -> None: + """Test the user flow with a timeout.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + mock_vegehub.setup.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: TEST_IP} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert "errors" in result + assert result["errors"] == {"base": expected_error} + + mock_vegehub.setup.side_effect = None # Clear the error + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: TEST_IP} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_IP + assert result["data"][CONF_IP_ADDRESS] == TEST_IP + assert result["data"][CONF_MAC] == TEST_SIMPLE_MAC + + +async def test_user_flow_no_ip_entered(hass: HomeAssistant) -> None: + """Test the user flow with blank IP.""" + + 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"], {CONF_IP_ADDRESS: ""} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "invalid_ip" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: TEST_IP} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_user_flow_bad_ip_entered(hass: HomeAssistant) -> None: + """Test the user flow with badly formed IP.""" + + 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"], {CONF_IP_ADDRESS: "192.168.0"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "invalid_ip" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: TEST_IP} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_user_flow_duplicate_device( + hass: HomeAssistant, mocked_config_entry: MockConfigEntry +) -> None: + """Test when user flow gets the same device twice.""" + + mocked_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: TEST_IP} + ) + + assert result["type"] is FlowResultType.ABORT + + +# Tests for flows that start in zeroconf +async def test_zeroconf_flow_success(hass: HomeAssistant) -> None: + """Test the zeroconf discovery flow with successful configuration.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + + # Display the confirmation form + result = await hass.config_entries.flow.async_configure(result["flow_id"], None) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + + # Proceed to creating the entry + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_HOSTNAME + assert result["data"][CONF_HOST] == TEST_HOSTNAME + assert result["data"][CONF_MAC] == TEST_SIMPLE_MAC + assert result["result"].unique_id == TEST_SIMPLE_MAC + + +async def test_zeroconf_flow_abort_device_asleep( + hass: HomeAssistant, + mock_vegehub: MagicMock, +) -> None: + """Test when zeroconf tries to contact a device that is asleep.""" + + mock_vegehub.retrieve_mac_address.side_effect = TimeoutError + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "timeout_connect" + + +async def test_zeroconf_flow_abort_same_id( + hass: HomeAssistant, + mocked_config_entry: MockConfigEntry, +) -> None: + """Test when zeroconf gets the same device twice.""" + + mocked_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO + ) + + assert result["type"] is FlowResultType.ABORT + + +async def test_zeroconf_flow_abort_cannot_connect( + hass: HomeAssistant, + mock_vegehub: MagicMock, +) -> None: + """Test when zeroconf gets bad data.""" + + mock_vegehub.mac_address = "" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_zeroconf_flow_abort_cannot_connect_404( + hass: HomeAssistant, + mock_vegehub: MagicMock, +) -> None: + """Test when zeroconf gets bad responses.""" + + mock_vegehub.retrieve_mac_address.side_effect = ConnectionError + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (TimeoutError, "timeout_connect"), + (ConnectionError, "cannot_connect"), + ], +) +async def test_zeroconf_flow_device_error_response( + hass: HomeAssistant, + mock_vegehub: MagicMock, + side_effect: Exception, + expected_error: str, +) -> None: + """Test when zeroconf detects the device, but the communication fails at setup.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + + # Part way through the process, we simulate getting bad responses + mock_vegehub.setup.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == expected_error + + mock_vegehub.setup.side_effect = None + + # Proceed to creating the entry + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_zeroconf_flow_update_ip_hostname( + hass: HomeAssistant, + mocked_config_entry: MockConfigEntry, +) -> None: + """Test when zeroconf gets the same device with a new IP and hostname.""" + + mocked_config_entry.add_to_hass(hass) + + # Use the same discovery info, but change the IP and hostname + new_ip = "192.168.0.99" + new_hostname = "new_hostname" + new_discovery_info = zeroconf.ZeroconfServiceInfo( + ip_address=ip_address(new_ip), + ip_addresses=[ip_address(new_ip)], + port=DISCOVERY_INFO.port, + hostname=f"{new_hostname}.local.", + type=DISCOVERY_INFO.type, + name=DISCOVERY_INFO.name, + properties=DISCOVERY_INFO.properties, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=new_discovery_info, + ) + + assert result["type"] is FlowResultType.ABORT + + # Check if the original config entry has been updated + entries = hass.config_entries.async_entries(domain=DOMAIN) + assert len(entries) == 1 + assert mocked_config_entry.data[CONF_IP_ADDRESS] == new_ip + assert mocked_config_entry.data[CONF_HOST] == new_hostname diff --git a/tests/components/vegehub/test_sensor.py b/tests/components/vegehub/test_sensor.py new file mode 100644 index 00000000000..b6b4533c3b9 --- /dev/null +++ b/tests/components/vegehub/test_sensor.py @@ -0,0 +1,63 @@ +"""Unit tests for the VegeHub integration's sensor.py.""" + +from unittest.mock import patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration +from .conftest import TEST_SIMPLE_MAC, TEST_WEBHOOK_ID + +from tests.common import MockConfigEntry, snapshot_platform +from tests.typing import ClientSessionGenerator + +UPDATE_DATA = { + "api_key": "", + "mac": TEST_SIMPLE_MAC, + "error_code": 0, + "sensors": [ + {"slot": 1, "samples": [{"v": 1.5, "t": "2025-01-15T16:51:23Z"}]}, + {"slot": 2, "samples": [{"v": 1.45599997, "t": "2025-01-15T16:51:23Z"}]}, + {"slot": 3, "samples": [{"v": 1.330000043, "t": "2025-01-15T16:51:23Z"}]}, + {"slot": 4, "samples": [{"v": 0.075999998, "t": "2025-01-15T16:51:23Z"}]}, + {"slot": 5, "samples": [{"v": 9.314800262, "t": "2025-01-15T16:51:23Z"}]}, + ], + "send_time": 1736959883, + "wifi_str": -27, +} + + +async def test_sensor_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + hass_client_no_auth: ClientSessionGenerator, + entity_registry: er.EntityRegistry, + mocked_config_entry: MockConfigEntry, +) -> None: + """Test all entities.""" + + with patch("homeassistant.components.vegehub.PLATFORMS", [Platform.SENSOR]): + await init_integration(hass, mocked_config_entry) + + assert TEST_WEBHOOK_ID in hass.data["webhook"], "Webhook was not registered" + + # Verify the webhook handler + webhook_info = hass.data["webhook"][TEST_WEBHOOK_ID] + assert webhook_info["handler"], "Webhook handler is not set" + + client = await hass_client_no_auth() + resp = await client.post(f"/api/webhook/{TEST_WEBHOOK_ID}", json=UPDATE_DATA) + + # Send the same update again so that the coordinator modifies existing data + # instead of creating new data. + resp = await client.post(f"/api/webhook/{TEST_WEBHOOK_ID}", json=UPDATE_DATA) + + # Wait for remaining tasks to complete. + await hass.async_block_till_done() + assert resp.status == 200, f"Unexpected status code: {resp.status}" + await snapshot_platform( + hass, entity_registry, snapshot, mocked_config_entry.entry_id + ) diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 8e6e7643df3..15c6a4b7251 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -6295,6 +6295,40 @@ async def test_label_name( assert info.rate_limit is None +async def test_label_description( + hass: HomeAssistant, + label_registry: lr.LabelRegistry, +) -> None: + """Test label_description function.""" + # Test non existing label ID + info = render_to_info(hass, "{{ label_description('1234567890') }}") + assert_result_info(info, None) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ '1234567890' | label_description }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test wrong value type + info = render_to_info(hass, "{{ label_description(42) }}") + assert_result_info(info, None) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 42 | label_description }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test valid label ID + label = label_registry.async_create("choo choo", description="chugga chugga") + info = render_to_info(hass, f"{{{{ label_description('{label.label_id}') }}}}") + assert_result_info(info, label.description) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ '{label.label_id}' | label_description }}}}") + assert_result_info(info, label.description) + assert info.rate_limit is None + + async def test_label_entities( hass: HomeAssistant, entity_registry: er.EntityRegistry, diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index bb3d3af98c5..be8777a6d3c 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -1,7 +1,7 @@ """The tests for the trigger helper.""" import io -from unittest.mock import ANY, AsyncMock, MagicMock, call, patch +from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, patch import pytest from pytest_unordered import unordered @@ -10,22 +10,34 @@ import voluptuous as vol from homeassistant.components.sun import DOMAIN as DOMAIN_SUN from homeassistant.components.system_health import DOMAIN as DOMAIN_SYSTEM_HEALTH from homeassistant.components.tag import DOMAIN as DOMAIN_TAG -from homeassistant.core import Context, HomeAssistant, ServiceCall, callback +from homeassistant.core import ( + CALLBACK_TYPE, + Context, + HomeAssistant, + ServiceCall, + callback, +) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import trigger from homeassistant.helpers.trigger import ( DATA_PLUGGABLE_ACTIONS, PluggableAction, + Trigger, + TriggerActionType, + TriggerInfo, _async_get_trigger_platform, async_initialize_triggers, async_validate_trigger_config, ) +from homeassistant.helpers.typing import ConfigType from homeassistant.loader import Integration, async_get_integration from homeassistant.setup import async_setup_component from homeassistant.util.yaml.loader import parse_yaml from tests.common import MockModule, MockPlatform, mock_integration, mock_platform +from tests.common import MockModule, mock_integration, mock_platform + async def test_bad_trigger_platform(hass: HomeAssistant) -> None: """Test bad trigger platform.""" @@ -441,6 +453,85 @@ async def test_pluggable_action( assert not plug_2 +async def test_platform_multiple_triggers(hass: HomeAssistant) -> None: + """Test a trigger platform with multiple trigger.""" + + class MockTrigger(Trigger): + """Mock trigger.""" + + def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: + """Initialize trigger.""" + + @classmethod + async def async_validate_trigger_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config.""" + return config + + class MockTrigger1(MockTrigger): + """Mock trigger 1.""" + + async def async_attach_trigger( + self, + action: TriggerActionType, + trigger_info: TriggerInfo, + ) -> CALLBACK_TYPE: + """Attach a trigger.""" + action({"trigger": "test_trigger_1"}) + + class MockTrigger2(MockTrigger): + """Mock trigger 2.""" + + async def async_attach_trigger( + self, + action: TriggerActionType, + trigger_info: TriggerInfo, + ) -> CALLBACK_TYPE: + """Attach a trigger.""" + action({"trigger": "test_trigger_2"}) + + async def async_get_triggers( + hass: HomeAssistant, + ) -> dict[str, type[Trigger]]: + return { + "test": MockTrigger1, + "test.trig_2": MockTrigger2, + } + + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers)) + + config_1 = [{"platform": "test"}] + config_2 = [{"platform": "test.trig_2"}] + config_3 = [{"platform": "test.unknown_trig"}] + assert await async_validate_trigger_config(hass, config_1) == config_1 + assert await async_validate_trigger_config(hass, config_2) == config_2 + with pytest.raises( + vol.Invalid, match="Invalid trigger 'test.unknown_trig' specified" + ): + await async_validate_trigger_config(hass, config_3) + + log_cb = MagicMock() + + action_calls = [] + + @callback + def cb_action(*args): + action_calls.append([*args]) + + await async_initialize_triggers(hass, config_1, cb_action, "test", "", log_cb) + assert action_calls == [[{"trigger": "test_trigger_1"}]] + action_calls.clear() + + await async_initialize_triggers(hass, config_2, cb_action, "test", "", log_cb) + assert action_calls == [[{"trigger": "test_trigger_2"}]] + action_calls.clear() + + with pytest.raises(KeyError): + await async_initialize_triggers(hass, config_3, cb_action, "test", "", log_cb) + + @pytest.mark.parametrize( "sun_trigger_descriptions", [ diff --git a/tests/pylint/test_enforce_type_hints.py b/tests/pylint/test_enforce_type_hints.py index ae426b13fcb..41605bf2f2b 100644 --- a/tests/pylint/test_enforce_type_hints.py +++ b/tests/pylint/test_enforce_type_hints.py @@ -1167,6 +1167,10 @@ def test_vacuum_entity(linter: UnittestLinter, type_hint_checker: BaseChecker) - class MyVacuum( #@ StateVacuumEntity ): + @property + def activity(self) -> VacuumActivity | None: + pass + def send_command( self, command: str, diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 55b8434160e..45bb956b7a1 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -6497,9 +6497,7 @@ async def test_update_subentry_and_abort( err: Exception with mock_config_flow("comp", TestFlow): try: - result = await entry.start_subentry_reconfigure_flow( - hass, "test", subentry_id - ) + result = await entry.start_subentry_reconfigure_flow(hass, subentry_id) except Exception as ex: # noqa: BLE001 err = ex @@ -6556,7 +6554,7 @@ async def test_reconfigure_subentry_create_subentry(hass: HomeAssistant) -> None mock_config_flow("comp", TestFlow), pytest.raises(ValueError, match="Source is reconfigure, expected user"), ): - await entry.start_subentry_reconfigure_flow(hass, "test", subentry_id) + await entry.start_subentry_reconfigure_flow(hass, subentry_id) await hass.async_block_till_done() @@ -8079,7 +8077,7 @@ async def test_subentry_get_entry( # A reconfigure flow finds the config entry and subentry with mock_config_flow("test", TestFlow): - result = await entry.start_subentry_reconfigure_flow(hass, "test", subentry_id) + result = await entry.start_subentry_reconfigure_flow(hass, subentry_id) assert ( result["reason"] == "Found entry entry_title: mock_entry_id/Found subentry Test: mock_subentry_id"