diff --git a/.strict-typing b/.strict-typing index ba072005a3415f..2ab3c10bb01241 100644 --- a/.strict-typing +++ b/.strict-typing @@ -286,6 +286,7 @@ homeassistant.components.huawei_lte.* homeassistant.components.humidifier.* homeassistant.components.husqvarna_automower.* homeassistant.components.huum.* +homeassistant.components.hvv_departures.* homeassistant.components.hydrawise.* homeassistant.components.hyperion.* homeassistant.components.hypontech.* diff --git a/CODEOWNERS b/CODEOWNERS index 0802144fb666ee..dd321761fac1b7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -501,6 +501,8 @@ CLAUDE.md @home-assistant/core /homeassistant/components/enphase_envoy/ @bdraco @cgarwood @catsmanac /tests/components/enphase_envoy/ @bdraco @cgarwood @catsmanac /homeassistant/components/entur_public_transport/ @hfurubotten @SanderBlom +/homeassistant/components/envertech_evt800/ @daniel-bergmann-00 +/tests/components/envertech_evt800/ @daniel-bergmann-00 /homeassistant/components/environment_canada/ @gwww @michaeldavie /tests/components/environment_canada/ @gwww @michaeldavie /homeassistant/components/ephember/ @ttroy50 @roberty99 diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 2c39adf76e6bf2..69b63993fc1e8d 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "platinum", - "requirements": ["aioamazondevices==13.8.2"] + "requirements": ["aioamazondevices==14.0.0"] } diff --git a/homeassistant/components/apprise/manifest.json b/homeassistant/components/apprise/manifest.json index ebe27d424719bb..59b11c97ce81e4 100644 --- a/homeassistant/components/apprise/manifest.json +++ b/homeassistant/components/apprise/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_push", "loggers": ["apprise"], "quality_scale": "legacy", - "requirements": ["apprise==1.9.1"] + "requirements": ["apprise==1.11.0"] } diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 40629a05a16ee7..bc353271a27d1b 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": "entity", "quality_scale": "internal", - "requirements": ["hassil==3.5.0", "home-assistant-intents==2026.5.5"] + "requirements": ["hassil==3.5.0", "home-assistant-intents==2026.6.1"] } diff --git a/homeassistant/components/envertech_evt800/__init__.py b/homeassistant/components/envertech_evt800/__init__.py new file mode 100644 index 00000000000000..192d61c7e99a89 --- /dev/null +++ b/homeassistant/components/envertech_evt800/__init__.py @@ -0,0 +1,37 @@ +"""Envertech EVT800 integration.""" + +from pyenvertechevt800 import EnvertechEVT800 + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT +from homeassistant.core import HomeAssistant + +from .const import PLATFORMS +from .coordinator import EnvertechEVT800Coordinator + +type EnvertechEVT800ConfigEntry = ConfigEntry[EnvertechEVT800Coordinator] + + +async def async_setup_entry( + hass: HomeAssistant, entry: EnvertechEVT800ConfigEntry +) -> bool: + """Set up Envertech EVT800 from a config entry.""" + evt800 = EnvertechEVT800(entry.data[CONF_IP_ADDRESS], entry.data[CONF_PORT]) + evt800.start() + + coordinator = EnvertechEVT800Coordinator(hass, evt800, 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: EnvertechEVT800ConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/envertech_evt800/config_flow.py b/homeassistant/components/envertech_evt800/config_flow.py new file mode 100644 index 00000000000000..6e08ca9e0d9303 --- /dev/null +++ b/homeassistant/components/envertech_evt800/config_flow.py @@ -0,0 +1,60 @@ +"""Config flow for the ENVERTECH EVT800 integration.""" + +from typing import Any + +from pyenvertechevt800 import EnvertechEVT800 +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_TYPE +from homeassistant.helpers import config_validation as cv + +from .const import DEFAULT_PORT, DOMAIN, TYPE_TCP_SERVER_MODE + +SCHEMA_DEVICE = vol.Schema( + { + vol.Required(CONF_IP_ADDRESS): cv.string, + vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, + } +) + + +class EnvertechFlowHandler(ConfigFlow, domain=DOMAIN): + """Config flow for Envertech EVT800.""" + + VERSION = 1 + MINOR_VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """First step in config flow.""" + errors: dict[str, str] = {} + if user_input is not None: + ip_address = user_input[CONF_IP_ADDRESS] + port = user_input[CONF_PORT] + + self._async_abort_entries_match( + { + CONF_IP_ADDRESS: ip_address, + CONF_PORT: port, + } + ) + evt800 = EnvertechEVT800(ip_address, port) + + can_connect = await evt800.test_connection() + + if not can_connect: + errors["base"] = "cannot_connect" + + if not errors: + return self.async_create_entry( + title="Envertech EVT800", + data={CONF_TYPE: TYPE_TCP_SERVER_MODE, **user_input}, + ) + + return self.async_show_form( + step_id="user", + data_schema=SCHEMA_DEVICE, + errors=errors, + ) diff --git a/homeassistant/components/envertech_evt800/const.py b/homeassistant/components/envertech_evt800/const.py new file mode 100644 index 00000000000000..17d9187168d325 --- /dev/null +++ b/homeassistant/components/envertech_evt800/const.py @@ -0,0 +1,11 @@ +"""Constants for the ENVERTECH EVT800 integration.""" + +from homeassistant.const import Platform + +DOMAIN = "envertech_evt800" + +PLATFORMS = [Platform.SENSOR] + +DEFAULT_PORT = 14889 +TYPE_TCP_SERVER_MODE = ["TCP_SERVER"] +DEFAULT_SCAN_INTERVAL = 60 diff --git a/homeassistant/components/envertech_evt800/coordinator.py b/homeassistant/components/envertech_evt800/coordinator.py new file mode 100644 index 00000000000000..96d123371e74b4 --- /dev/null +++ b/homeassistant/components/envertech_evt800/coordinator.py @@ -0,0 +1,44 @@ +"""Coordinator for Envertech EVT800 integration.""" + +from datetime import timedelta +import logging +from typing import TYPE_CHECKING, Any + +import pyenvertechevt800 + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + +if TYPE_CHECKING: + from . import EnvertechEVT800ConfigEntry + +_LOGGER = logging.getLogger(__name__) + + +class EnvertechEVT800Coordinator(DataUpdateCoordinator[dict[str, Any]]): + """Data update coordinator for Envertech EVT800.""" + + config_entry: EnvertechEVT800ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + client: pyenvertechevt800.EnvertechEVT800, + config_entry: EnvertechEVT800ConfigEntry, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + logger=_LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), + config_entry=config_entry, + ) + self.client = client + client.set_data_listener(self.async_set_updated_data) + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data from the device.""" + return self.client.data diff --git a/homeassistant/components/envertech_evt800/entity.py b/homeassistant/components/envertech_evt800/entity.py new file mode 100644 index 00000000000000..a610a6f9b3dde7 --- /dev/null +++ b/homeassistant/components/envertech_evt800/entity.py @@ -0,0 +1,29 @@ +"""Envertech EVT800 entity.""" + +from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import EnvertechEVT800Coordinator + + +class EnvertechEVT800Entity(CoordinatorEntity[EnvertechEVT800Coordinator]): + """Envertech EVT800 entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: EnvertechEVT800Coordinator) -> None: + """Initialize Envertech EVT800 entity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + configuration_url=f"http://{coordinator.config_entry.data[CONF_IP_ADDRESS]}/", + manufacturer="Envertech", + model_id="EVT800", + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self.coordinator.client.online diff --git a/homeassistant/components/envertech_evt800/manifest.json b/homeassistant/components/envertech_evt800/manifest.json new file mode 100644 index 00000000000000..cff3f0c14c940f --- /dev/null +++ b/homeassistant/components/envertech_evt800/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "envertech_evt800", + "name": "ENVERTECH EVT800", + "codeowners": ["@daniel-bergmann-00"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/envertech_evt800", + "integration_type": "device", + "iot_class": "local_polling", + "loggers": ["pyenvertechevt800"], + "quality_scale": "bronze", + "requirements": ["pyenvertechevt800==0.2.4"] +} diff --git a/homeassistant/components/envertech_evt800/quality_scale.yaml b/homeassistant/components/envertech_evt800/quality_scale.yaml new file mode 100644 index 00000000000000..5263942a8f720d --- /dev/null +++ b/homeassistant/components/envertech_evt800/quality_scale.yaml @@ -0,0 +1,90 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + The integration does not provide any 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: | + The integration does not provide any additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: done + 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: | + The integration does not provide any actions. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: + status: todo + comment: | + The integration does not have any authentication. + test-coverage: done + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: 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: | + Integration connects to a single device + + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: + status: exempt + comment: | + The integration does not have any own exceptions. + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + The integration does not support repairing issues. + stale-devices: + status: exempt + comment: | + This integration connects to a single device per configuration entry. + + # Platinum + async-dependency: todo + inject-websession: + status: exempt + comment: | + No websession is used + strict-typing: todo diff --git a/homeassistant/components/envertech_evt800/sensor.py b/homeassistant/components/envertech_evt800/sensor.py new file mode 100644 index 00000000000000..68b3215c14eb2a --- /dev/null +++ b/homeassistant/components/envertech_evt800/sensor.py @@ -0,0 +1,185 @@ +"""Envertech EVT800 sensor.""" + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfFrequency, + UnitOfPower, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import EnvertechEVT800ConfigEntry +from .coordinator import EnvertechEVT800Coordinator +from .entity import EnvertechEVT800Entity + +SENSORS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="id_1", + entity_registry_enabled_default=False, + translation_key="mppt_id_1", + ), + SensorEntityDescription( + key="id_2", + entity_registry_enabled_default=False, + translation_key="mppt_id_2", + ), + SensorEntityDescription( + key="input_voltage_1", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + suggested_display_precision=2, + translation_key="input_voltage_1", + ), + SensorEntityDescription( + key="input_voltage_2", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + suggested_display_precision=2, + translation_key="input_voltage_2", + ), + SensorEntityDescription( + key="power_1", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + suggested_display_precision=0, + translation_key="power_1", + ), + SensorEntityDescription( + key="power_2", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + suggested_display_precision=0, + translation_key="power_2", + ), + SensorEntityDescription( + key="current_1", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + suggested_display_precision=2, + translation_key="current_1", + ), + SensorEntityDescription( + key="current_2", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + suggested_display_precision=2, + translation_key="current_2", + ), + SensorEntityDescription( + key="ac_frequency_1", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.FREQUENCY, + suggested_display_precision=1, + translation_key="ac_frequency_1", + ), + SensorEntityDescription( + key="ac_frequency_2", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.FREQUENCY, + suggested_display_precision=1, + translation_key="ac_frequency_2", + ), + SensorEntityDescription( + key="ac_voltage_1", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + suggested_display_precision=0, + translation_key="ac_voltage_1", + ), + SensorEntityDescription( + key="ac_voltage_2", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + suggested_display_precision=0, + translation_key="ac_voltage_2", + ), + SensorEntityDescription( + key="temperature_1", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + suggested_display_precision=1, + translation_key="temperature_1", + ), + SensorEntityDescription( + key="temperature_2", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + suggested_display_precision=1, + translation_key="temperature_2", + ), + SensorEntityDescription( + key="total_energy_1", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.ENERGY, + suggested_display_precision=2, + translation_key="total_energy_1", + ), + SensorEntityDescription( + key="total_energy_2", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.ENERGY, + suggested_display_precision=2, + translation_key="total_energy_2", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: EnvertechEVT800ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Envertech EVT800 sensors.""" + coordinator = config_entry.runtime_data + + async_add_entities( + EnvertechEVT800Sensor(coordinator, description) for description in SENSORS + ) + + +class EnvertechEVT800Sensor(EnvertechEVT800Entity, SensorEntity): + """Representation of an Envertech EVT800 sensor.""" + + def __init__( + self, + coordinator: EnvertechEVT800Coordinator, + description: SensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" + + @property + def native_value(self) -> StateType: + """Return the native value of the sensor.""" + return self.coordinator.client.data.get(self.entity_description.key) + + @property + def available(self) -> bool: + """Unavailable if evt800 isn't connected.""" + return super().available and self.native_value is not None diff --git a/homeassistant/components/envertech_evt800/strings.json b/homeassistant/components/envertech_evt800/strings.json new file mode 100644 index 00000000000000..2b62b18048a232 --- /dev/null +++ b/homeassistant/components/envertech_evt800/strings.json @@ -0,0 +1,76 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "step": { + "user": { + "data": { + "ip_address": "[%key:common::config_flow::data::ip%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "ip_address": "The IP address of your Envertech EVT800 device.", + "port": "The Port of your Envertech EVT800 device." + }, + "description": "Enter your EVT800 device information.", + "title": "Setup EVT800 device" + } + } + }, + "entity": { + "sensor": { + "ac_frequency_1": { + "name": "AC Frequency MPPT 1" + }, + "ac_frequency_2": { + "name": "AC Frequency MPPT 2" + }, + "ac_voltage_1": { + "name": "AC Voltage MPPT 1" + }, + "ac_voltage_2": { + "name": "AC Voltage MPPT 2" + }, + "current_1": { + "name": "DC Current MPPT 1" + }, + "current_2": { + "name": "DC Current MPPT 2" + }, + "input_voltage_1": { + "name": "DC Voltage MPPT 1" + }, + "input_voltage_2": { + "name": "DC Voltage MPPT 2" + }, + "mppt_id_1": { + "name": "MPPT ID 1" + }, + "mppt_id_2": { + "name": "MPPT ID 2" + }, + "power_1": { + "name": "DC Power MPPT 1" + }, + "power_2": { + "name": "DC Power MPPT 2" + }, + "temperature_1": { + "name": "Temperature MPPT 1" + }, + "temperature_2": { + "name": "Temperature MPPT 2" + }, + "total_energy_1": { + "name": "Total Energy MPPT 1" + }, + "total_energy_2": { + "name": "Total Energy MPPT 2" + } + } + } +} diff --git a/homeassistant/components/hvv_departures/binary_sensor.py b/homeassistant/components/hvv_departures/binary_sensor.py index 5ad2c7df39b590..f4917223a6d7a8 100644 --- a/homeassistant/components/hvv_departures/binary_sensor.py +++ b/homeassistant/components/hvv_departures/binary_sensor.py @@ -7,7 +7,13 @@ from aiohttp import ClientConnectorError from pygti.exceptions import GTIError -from pygti.models import ElevatorState, SDName, SDNameType, StationInformationRequest +from pygti.models import ( + ElevatorState, + SDName, + SDNameType, + StationInformationRequest, + StationInformationResponse, +) from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -39,8 +45,9 @@ async def async_setup_entry( station = entry.data[CONF_STATION] def get_elevator_entities_from_station_information( - station_name, station_information - ): + station_name: str, + station_information: StationInformationResponse | None, + ) -> dict[str, Any]: """Convert station information into a list of elevators.""" elevators = {} @@ -82,7 +89,7 @@ def get_elevator_entities_from_station_information( } return elevators - async def async_update_data(): + async def async_update_data() -> dict[str, Any]: """Fetch data from API endpoint. This is the place to pre-process the data to lookup tables @@ -132,7 +139,12 @@ class HvvDepartureBinarySensor(CoordinatorEntity, BinarySensorEntity): _attr_has_entity_name = True _attr_device_class = BinarySensorDeviceClass.PROBLEM - def __init__(self, coordinator, idx, config_entry): + def __init__( + self, + coordinator: DataUpdateCoordinator[dict[str, Any]], + idx: str, + config_entry: HVVConfigEntry, + ) -> None: """Initialize.""" super().__init__(coordinator) self.coordinator = coordinator @@ -143,7 +155,7 @@ def __init__(self, coordinator, idx, config_entry): self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={ - ( + ( # type: ignore[arg-type] DOMAIN, config_entry.entry_id, config_entry.data[CONF_STATION]["id"], @@ -157,7 +169,7 @@ def __init__(self, coordinator, idx, config_entry): @property def is_on(self) -> bool: """Return entity state.""" - return self.coordinator.data[self.idx]["state"] + return bool(self.coordinator.data[self.idx]["state"]) @property def available(self) -> bool: diff --git a/homeassistant/components/hvv_departures/hub.py b/homeassistant/components/hvv_departures/hub.py index 61b933ba2c7a9b..6bc83671d1aa16 100644 --- a/homeassistant/components/hvv_departures/hub.py +++ b/homeassistant/components/hvv_departures/hub.py @@ -1,7 +1,8 @@ """Hub.""" +from aiohttp import ClientSession from pygti.gti import GTI, Auth -from pygti.models import InitRequest +from pygti.models import InitRequest, InitResponse from homeassistant.config_entries import ConfigEntry @@ -11,7 +12,9 @@ class GTIHub: """GTI Hub.""" - def __init__(self, host, username, password, session): + def __init__( + self, host: str, username: str, password: str, session: ClientSession + ) -> None: """Initialize.""" self.host = host self.username = username @@ -19,7 +22,7 @@ def __init__(self, host, username, password, session): self.gti = GTI(Auth(session, self.username, self.password, self.host)) - async def authenticate(self): + async def authenticate(self) -> InitResponse: """Test if we can authenticate with the host.""" return await self.gti.init(InitRequest()) diff --git a/homeassistant/components/hvv_departures/sensor.py b/homeassistant/components/hvv_departures/sensor.py index 0f2efea3739036..ab18af38c69dd7 100644 --- a/homeassistant/components/hvv_departures/sensor.py +++ b/homeassistant/components/hvv_departures/sensor.py @@ -4,7 +4,7 @@ import logging from typing import Any -from aiohttp import ClientConnectorError +from aiohttp import ClientConnectorError, ClientSession from pygti.exceptions import GTIError, GTIUnauthorizedError from pygti.models import DLRequest, GTITime, SDName, SDNameType @@ -25,7 +25,7 @@ DOMAIN, MANUFACTURER, ) -from .hub import HVVConfigEntry +from .hub import GTIHub, HVVConfigEntry MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) MAX_LIST = 20 @@ -70,11 +70,17 @@ class HVVDepartureSensor(SensorEntity): _attr_has_entity_name = True _attr_available = False - def __init__(self, hass, config_entry, session, hub): + def __init__( + self, + hass: HomeAssistant, + config_entry: HVVConfigEntry, + session: ClientSession, + hub: GTIHub, + ) -> None: """Initialize.""" self.config_entry = config_entry self.station_name = self.config_entry.data[CONF_STATION]["name"] - self._last_error = None + self._last_error: type[Exception] | Exception | None = None self._attr_extra_state_attributes = {} self.gti = hub.gti @@ -85,7 +91,7 @@ def __init__(self, hass, config_entry, session, hub): self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={ - ( + ( # type: ignore[arg-type] DOMAIN, config_entry.entry_id, config_entry.data[CONF_STATION]["id"], diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 5cc4530e97a978..799e4ae9329d18 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -38,5 +38,5 @@ "iot_class": "cloud_push", "loggers": ["pysmartthings"], "quality_scale": "bronze", - "requirements": ["pysmartthings==3.7.3"] + "requirements": ["pysmartthings==4.0.0"] } diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 8e4bdebfb7621d..53193973503029 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -3,7 +3,9 @@ import datetime from functools import partial import logging +import os from typing import TYPE_CHECKING, Any +from urllib.parse import urlparse from soco import SoCo, alarms from soco.core import ( @@ -90,6 +92,7 @@ UPNP_ERRORS_TO_IGNORE = ["701", "711", "712"] ANNOUNCE_NOT_SUPPORTED_ERRORS: list[str] = ["globalError"] +ANNOUNCE_AUDIOCLIP_SUPPORTED_FORMATS: frozenset[str] = frozenset({".mp3", ".wav"}) async def async_setup_entry( @@ -460,6 +463,15 @@ async def async_play_media( if kwargs.get(ATTR_MEDIA_ANNOUNCE): volume = kwargs.get("extra", {}).get("volume") + ext = os.path.splitext(urlparse(media_id).path)[1].lower() + if ext and ext not in ANNOUNCE_AUDIOCLIP_SUPPORTED_FORMATS: + _LOGGER.warning( + "Sonos AudioClip announce only supports MP3 and WAV; " + "%s has extension %s and will be attempted as a clip anyway on %s", + media_id, + ext, + self.speaker.zone_name, + ) _LOGGER.debug("Playing %s using websocket audioclip", media_id) try: assert self.speaker.websocket diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index 9a2d8f4a47bc10..3b28c3d6dcee20 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -61,7 +61,11 @@ Platform.SENSOR, Platform.SELECT, ], - SupportedModels.CONTACT.value: [Platform.BINARY_SENSOR, Platform.SENSOR], + SupportedModels.CONTACT.value: [ + Platform.BINARY_SENSOR, + Platform.EVENT, + Platform.SENSOR, + ], SupportedModels.MOTION.value: [Platform.BINARY_SENSOR, Platform.SENSOR], SupportedModels.PRESENCE_SENSOR.value: [Platform.BINARY_SENSOR, Platform.SENSOR], SupportedModels.HUMIDIFIER.value: [Platform.HUMIDIFIER, Platform.SENSOR], diff --git a/homeassistant/components/switchbot/event.py b/homeassistant/components/switchbot/event.py index e5a4e0598fac16..d0935c719dac77 100644 --- a/homeassistant/components/switchbot/event.py +++ b/homeassistant/components/switchbot/event.py @@ -1,5 +1,7 @@ """Support for SwitchBot event entities.""" +from dataclasses import dataclass + from homeassistant.components.event import ( EventDeviceClass, EventEntity, @@ -13,13 +15,31 @@ PARALLEL_UPDATES = 0 -EVENT_TYPES = { - "doorbell": EventEntityDescription( + +@dataclass(frozen=True, kw_only=True) +class SwitchbotEventEntityDescription(EventEntityDescription): + """Describes a Switchbot event entity.""" + + counter_key: str + fire_event: str + + +EVENT_DESCRIPTIONS: tuple[SwitchbotEventEntityDescription, ...] = ( + SwitchbotEventEntityDescription( key="doorbell", device_class=EventDeviceClass.DOORBELL, event_types=["ring"], + counter_key="doorbell_seq", + fire_event="ring", + ), + SwitchbotEventEntityDescription( + key="button", + device_class=EventDeviceClass.BUTTON, + event_types=["press"], + counter_key="button_count", + fire_event="press", ), -} +) async def async_setup_entry( @@ -30,34 +50,34 @@ async def async_setup_entry( """Set up the SwitchBot event platform.""" coordinator = config_entry.runtime_data async_add_entities( - SwitchbotEventEntity(coordinator, event, description) - for event, description in EVENT_TYPES.items() - if event in coordinator.device.parsed_data + SwitchbotEventEntity(coordinator, description) + for description in EVENT_DESCRIPTIONS + if description.counter_key in coordinator.device.parsed_data ) class SwitchbotEventEntity(SwitchbotEntity, EventEntity): """Representation of a SwitchBot event.""" + entity_description: SwitchbotEventEntityDescription + def __init__( self, coordinator: SwitchbotDataUpdateCoordinator, - event: str, - description: EventEntityDescription, + description: SwitchbotEventEntityDescription, ) -> None: """Initialize the SwitchBot event.""" super().__init__(coordinator) - self._event = event self.entity_description = description - self._attr_unique_id = f"{coordinator.base_unique_id}-{event}" - self._previous_doorbell_seq = int( - coordinator.device.parsed_data.get("doorbell_seq", 0) + self._attr_unique_id = f"{coordinator.base_unique_id}-{description.key}" + self._previous_counter = int( + coordinator.device.parsed_data.get(description.counter_key, 0) ) @callback def _async_update_attrs(self) -> None: """Update the entity attributes.""" - seq = int(self.parsed_data.get("doorbell_seq", 0)) - if seq not in (0, self._previous_doorbell_seq): - self._trigger_event("ring") - self._previous_doorbell_seq = seq + counter = int(self.parsed_data.get(self.entity_description.counter_key, 0)) + if counter not in (0, self._previous_counter): + self._trigger_event(self.entity_description.fire_event) + self._previous_counter = counter diff --git a/homeassistant/components/wallbox/__init__.py b/homeassistant/components/wallbox/__init__.py index 87aab42e797d8d..44d714769441f6 100644 --- a/homeassistant/components/wallbox/__init__.py +++ b/homeassistant/components/wallbox/__init__.py @@ -15,6 +15,7 @@ from .coordinator import WallboxConfigEntry, WallboxCoordinator, check_token_validity PLATFORMS = [ + Platform.BUTTON, Platform.LOCK, Platform.NUMBER, Platform.SELECT, diff --git a/homeassistant/components/wallbox/button.py b/homeassistant/components/wallbox/button.py new file mode 100644 index 00000000000000..24c7b5f4e026dd --- /dev/null +++ b/homeassistant/components/wallbox/button.py @@ -0,0 +1,71 @@ +"""Home Assistant component for accessing the Wallbox Portal API button.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import ( + CHARGER_DATA_KEY, + CHARGER_RESUME_SCHEDULE_KEY, + CHARGER_SERIAL_NUMBER_KEY, +) +from .coordinator import WallboxConfigEntry, WallboxCoordinator +from .entity import WallboxEntity + + +@dataclass(frozen=True, kw_only=True) +class WallboxButtonEntityDescription(ButtonEntityDescription): + """Describes Wallbox button entity.""" + + press_fn: Callable[[WallboxCoordinator], Awaitable[None]] + + +BUTTON_TYPES: dict[str, WallboxButtonEntityDescription] = { + CHARGER_RESUME_SCHEDULE_KEY: WallboxButtonEntityDescription( + key=CHARGER_RESUME_SCHEDULE_KEY, + translation_key=CHARGER_RESUME_SCHEDULE_KEY, + press_fn=lambda coordinator: coordinator.async_resume_schedule(), + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: WallboxConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Create wallbox button entities in HASS.""" + coordinator: WallboxCoordinator = entry.runtime_data + async_add_entities( + [WallboxButton(coordinator, BUTTON_TYPES[CHARGER_RESUME_SCHEDULE_KEY])] + ) + + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +class WallboxButton(WallboxEntity, ButtonEntity): + """Representation of the Wallbox portal.""" + + entity_description: WallboxButtonEntityDescription + + def __init__( + self, + coordinator: WallboxCoordinator, + description: WallboxButtonEntityDescription, + ) -> None: + """Initialize a Wallbox button.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = ( + f"{description.key}-" + f"{coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY]}" + ) + + async def async_press(self) -> None: + """Resume schedule and EcoSmart mode after a manual stop.""" + await self.entity_description.press_fn(self.coordinator) diff --git a/homeassistant/components/wallbox/const.py b/homeassistant/components/wallbox/const.py index e0289b57ad727f..0cdb1670efadf9 100644 --- a/homeassistant/components/wallbox/const.py +++ b/homeassistant/components/wallbox/const.py @@ -38,6 +38,7 @@ CHARGER_MAX_ICP_CURRENT_KEY = "icp_max_current" CHARGER_MAX_ICP_CURRENT_POST_KEY = "maxAvailableCurrent" CHARGER_PAUSE_RESUME_KEY = "paused" +CHARGER_RESUME_SCHEDULE_KEY = "resume_schedule" CHARGER_LOCKED_UNLOCKED_KEY = "locked" CHARGER_NAME_KEY = "name" CHARGER_STATE_OF_CHARGE_KEY = "state_of_charge" diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index 05881be6cfa99e..74df6efd57ad6a 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -390,6 +390,31 @@ async def async_pause_charger(self, pause: bool) -> None: await self.hass.async_add_executor_job(self._pause_charger, pause) await self.async_request_refresh() + def _resume_schedule(self) -> None: + """Resume schedule and EcoSmart mode after a manual stop.""" + try: + self._wallbox.resumeSchedule(self._station) + except requests.exceptions.HTTPError as wallbox_connection_error: + if wallbox_connection_error.response.status_code == 403: + raise InsufficientRights( + translation_domain=DOMAIN, + translation_key="insufficient_rights", + hass=self.hass, + ) from wallbox_connection_error + if wallbox_connection_error.response.status_code == 429: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="too_many_requests" + ) from wallbox_connection_error + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="api_failed" + ) from wallbox_connection_error + + @_require_authentication + async def async_resume_schedule(self) -> None: + """Resume schedule and EcoSmart mode after a manual stop.""" + await self.hass.async_add_executor_job(self._resume_schedule) + await self.async_request_refresh() + def _set_eco_smart(self, option: str) -> None: """Set wallbox solar charging mode.""" try: diff --git a/homeassistant/components/wallbox/strings.json b/homeassistant/components/wallbox/strings.json index 8c3ffc458ebde7..63c12c7efd07e0 100644 --- a/homeassistant/components/wallbox/strings.json +++ b/homeassistant/components/wallbox/strings.json @@ -32,6 +32,11 @@ } }, "entity": { + "button": { + "resume_schedule": { + "name": "Resume schedule" + } + }, "lock": { "lock": { "name": "[%key:component::lock::title%]" diff --git a/homeassistant/components/wmspro/manifest.json b/homeassistant/components/wmspro/manifest.json index a93963725bf640..bcdc71e2b336c1 100644 --- a/homeassistant/components/wmspro/manifest.json +++ b/homeassistant/components/wmspro/manifest.json @@ -14,5 +14,5 @@ "documentation": "https://www.home-assistant.io/integrations/wmspro", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["pywmspro==0.3.5"] + "requirements": ["pywmspro==0.4.0"] } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b5e0330d3ae172..d750973a20a432 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -207,6 +207,7 @@ "enigma2", "enocean", "enphase_envoy", + "envertech_evt800", "environment_canada", "epic_games_store", "epion", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index f4fa6aee06c655..427d40cf7343e2 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1861,6 +1861,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "envertech_evt800": { + "name": "ENVERTECH EVT800", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "environment_canada": { "name": "Environment Canada", "integration_type": "service", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 13aa25986bc794..fce028b8bc1b94 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ aiodns==4.0.4 aiogithubapi==26.0.0 aiohttp-asyncmdnsresolver==0.2.0 aiohttp-fast-zlib==0.3.0 -aiohttp==3.13.5 +aiohttp==3.14.0 aiohttp_cors==0.8.1 aiousbwatcher==1.1.2 aiozoneinfo==0.2.3 @@ -40,7 +40,7 @@ hass-nabucasa==2.2.0 hassil==3.5.0 home-assistant-bluetooth==2.0.0 home-assistant-frontend==20260527.2 -home-assistant-intents==2026.5.5 +home-assistant-intents==2026.6.1 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.6 diff --git a/mypy.ini b/mypy.ini index 6871bd7c882417..2fae3bcc1da4a5 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2617,6 +2617,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.hvv_departures.*] +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.hydrawise.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pyproject.toml b/pyproject.toml index 000fdab5550cfe..d42943fa311258 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # module level in `bootstrap.py` and its requirements thus need to be in # requirements.txt to ensure they are always installed "aiogithubapi==26.0.0", - "aiohttp==3.13.5", + "aiohttp==3.14.0", "aiohttp_cors==0.8.1", "aiohttp-fast-zlib==0.3.0", "aiohttp-asyncmdnsresolver==0.2.0", diff --git a/requirements.txt b/requirements.txt index 822a8a77247fbe..c5f03efe1ddcd2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ aiodns==4.0.4 aiogithubapi==26.0.0 aiohttp-asyncmdnsresolver==0.2.0 aiohttp-fast-zlib==0.3.0 -aiohttp==3.13.5 +aiohttp==3.14.0 aiohttp_cors==0.8.1 aiozoneinfo==0.2.3 annotatedyaml==1.0.2 @@ -27,7 +27,7 @@ ha-ffmpeg==3.2.2 hass-nabucasa==2.2.0 hassil==3.5.0 home-assistant-bluetooth==2.0.0 -home-assistant-intents==2026.5.5 +home-assistant-intents==2026.6.1 httpx==0.28.1 ifaddr==0.2.0 infrared-protocols==5.8.0 diff --git a/requirements_all.txt b/requirements_all.txt index ceed25876ed7a5..e3565e47de1ead 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -190,7 +190,7 @@ aioairzone-cloud==0.7.2 aioairzone==1.0.5 # homeassistant.components.alexa_devices -aioamazondevices==13.8.2 +aioamazondevices==14.0.0 # homeassistant.components.ambient_network # homeassistant.components.ambient_station @@ -528,7 +528,7 @@ anyio==4.13.0 apple_weatherkit==1.1.3 # homeassistant.components.apprise -apprise==1.9.1 +apprise==1.11.0 # homeassistant.components.aprs aprslib==0.7.2 @@ -1272,7 +1272,7 @@ holidays==0.97 home-assistant-frontend==20260527.2 # homeassistant.components.conversation -home-assistant-intents==2026.5.5 +home-assistant-intents==2026.6.1 # homeassistant.components.homekit homekit-audio-proxy==1.2.1 @@ -2144,6 +2144,9 @@ pyemoncms==0.1.3 # homeassistant.components.enphase_envoy pyenphase==2.4.8 +# homeassistant.components.envertech_evt800 +pyenvertechevt800==0.2.4 + # homeassistant.components.envisalink pyenvisalink==4.9 @@ -2546,7 +2549,7 @@ pysmappee==0.2.29 pysmarlaapi==1.0.2 # homeassistant.components.smartthings -pysmartthings==3.7.3 +pysmartthings==4.0.0 # homeassistant.components.smarty pysmarty2==0.10.3 @@ -2821,7 +2824,7 @@ pywilight==0.0.74 pywizlight==0.6.3 # homeassistant.components.wmspro -pywmspro==0.3.5 +pywmspro==0.4.0 # homeassistant.components.ws66i pyws66i==1.1 diff --git a/tests/components/alexa_devices/const.py b/tests/components/alexa_devices/const.py index 8a5772f6af6bc8..959f7762a366c3 100644 --- a/tests/components/alexa_devices/const.py +++ b/tests/components/alexa_devices/const.py @@ -113,7 +113,7 @@ TEST_VOCAL_RECORD_INITIAL = AmazonVocalRecord( timestamp=1000, - utterance_type="WAKE_WORD_UTTERANCE", + history_type="WAKE_WORD_UTTERANCE", intent="PlayMusicIntent", title="Play some music", sub_title="Echo Test", @@ -121,7 +121,7 @@ TEST_VOCAL_RECORD_EVENT = AmazonVocalRecord( timestamp=1234567890, - utterance_type="WAKE_WORD_UTTERANCE", + history_type="WAKE_WORD_UTTERANCE", intent="PlayMusicIntent", title="Play some music", sub_title="Echo Test", diff --git a/tests/components/duco/test_sensor.py b/tests/components/duco/test_sensor.py index 3753380ee10240..9511ae54706250 100644 --- a/tests/components/duco/test_sensor.py +++ b/tests/components/duco/test_sensor.py @@ -83,33 +83,22 @@ async def test_diagnostic_sensor_entities_disabled_by_default( @pytest.mark.usefixtures("init_integration") -async def test_coordinator_update_marks_unavailable( - hass: HomeAssistant, - mock_duco_client: AsyncMock, - freezer: FrozenDateTimeFactory, -) -> None: - """Test that sensor entities become unavailable when the coordinator fails.""" - mock_duco_client.async_get_nodes = AsyncMock( - side_effect=DucoConnectionError("offline") - ) - - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) - - state = hass.states.get("sensor.office_co2_carbon_dioxide") - assert state is not None - assert state.state == STATE_UNAVAILABLE - - -@pytest.mark.usefixtures("init_integration") -async def test_coordinator_update_duco_error_marks_unavailable( +@pytest.mark.parametrize( + ("exception_type", "exception_message"), + [ + pytest.param(DucoConnectionError, "offline", id="connection_error"), + pytest.param(DucoError, "api error", id="duco_error"), + ], +) +async def test_coordinator_update_failure_marks_unavailable( hass: HomeAssistant, mock_duco_client: AsyncMock, freezer: FrozenDateTimeFactory, + exception_type: type[DucoError], + exception_message: str, ) -> None: - """Test sensor entities become unavailable when async_get_nodes raises DucoError.""" - mock_duco_client.async_get_nodes = AsyncMock(side_effect=DucoError("api error")) + """Test sensor entities become unavailable when the coordinator update fails.""" + mock_duco_client.async_get_nodes.side_effect = exception_type(exception_message) freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) @@ -198,10 +187,9 @@ async def test_deregistered_node_removes_device( mock_sensor_nodes: list[Node], mock_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, + device_registry: dr.DeviceRegistry, ) -> None: """Test a node disappearing from the API removes its device from the registry.""" - device_registry = dr.async_get(hass) - # Verify node 2 (UCCO2 RF sensor) device exists before deregistration. device = device_registry.async_get_device( identifiers={(DOMAIN, f"{mock_config_entry.unique_id}_2")} diff --git a/tests/components/envertech_evt800/__init__.py b/tests/components/envertech_evt800/__init__.py new file mode 100644 index 00000000000000..5c10582777f51e --- /dev/null +++ b/tests/components/envertech_evt800/__init__.py @@ -0,0 +1,29 @@ +"""Tests for the Envertech EVT800 integration.""" + +from homeassistant.components.envertech_evt800.const import TYPE_TCP_SERVER_MODE +from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_TYPE +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +MOCK_DEVICE = { + "manufacturer": "Envertech", + "name": "EVT800", + "type": "Inverter", + "serial": 123456789, + "sw_version": "1.0.0", +} + +MOCK_USER_INPUT = { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_PORT: 1234, + CONF_TYPE: TYPE_TCP_SERVER_MODE, +} + + +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/envertech_evt800/conftest.py b/tests/components/envertech_evt800/conftest.py new file mode 100644 index 00000000000000..e17f570e6b34ef --- /dev/null +++ b/tests/components/envertech_evt800/conftest.py @@ -0,0 +1,73 @@ +"""Fixtures for Envertech EVT800 tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.envertech_evt800.const import DOMAIN + +from . import MOCK_DEVICE, MOCK_USER_INPUT + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + + return MockConfigEntry( + domain=DOMAIN, + title=MOCK_DEVICE["name"], + data=MOCK_USER_INPUT, + minor_version=1, + entry_id="evt800_entry_123", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock the setup entry.""" + with patch( + "homeassistant.components.envertech_evt800.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_evt800_client() -> Generator[MagicMock]: + """Mock the EVT800 client.""" + with ( + patch( + "homeassistant.components.envertech_evt800.config_flow.EnvertechEVT800", + autospec=True, + ) as client, + patch("homeassistant.components.envertech_evt800.EnvertechEVT800", new=client), + ): + mock_instance = client.return_value + mock_instance.test_connection.return_value = True + mock_instance.online = True + mock_instance.start = MagicMock() + mock_instance.set_data_listener = MagicMock() + mock_instance.data = { + "id_1": 39828832, + "id_2": 39828833, + "sw_version": "7A.7A", + "input_voltage_1": 32.34375, + "input_voltage_2": 24.595703125, + "power_1": 182.09375, + "power_2": 5.921875, + "ac_voltage_1": 241.140625, + "ac_voltage_2": 241.140625, + "ac_frequency_1": 49.9921875, + "ac_frequency_2": 49.9921875, + "temperature_1": 53.09375, + "temperature_2": 45.3984375, + "total_energy_1": 5.8431396484375, + "total_energy_2": 0.446533203125, + "current_1": 0.7551351001101536, + "current_2": 0.024557765826475734, + } + + yield mock_instance diff --git a/tests/components/envertech_evt800/snapshots/test_sensor.ambr b/tests/components/envertech_evt800/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..a2b1079da0ae50 --- /dev/null +++ b/tests/components/envertech_evt800/snapshots/test_sensor.ambr @@ -0,0 +1,913 @@ +# serializer version: 1 +# name: test_sensors[sensor.evt800_ac_frequency_mppt_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.evt800_ac_frequency_mppt_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AC Frequency MPPT 1', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC Frequency MPPT 1', + 'platform': 'envertech_evt800', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_frequency_1', + 'unique_id': 'evt800_entry_123_ac_frequency_1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.evt800_ac_frequency_mppt_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'EVT800 AC Frequency MPPT 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evt800_ac_frequency_mppt_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '49.9921875', + }) +# --- +# name: test_sensors[sensor.evt800_ac_frequency_mppt_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.evt800_ac_frequency_mppt_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AC Frequency MPPT 2', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC Frequency MPPT 2', + 'platform': 'envertech_evt800', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_frequency_2', + 'unique_id': 'evt800_entry_123_ac_frequency_2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.evt800_ac_frequency_mppt_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'EVT800 AC Frequency MPPT 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evt800_ac_frequency_mppt_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '49.9921875', + }) +# --- +# name: test_sensors[sensor.evt800_ac_voltage_mppt_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.evt800_ac_voltage_mppt_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AC Voltage MPPT 1', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC Voltage MPPT 1', + 'platform': 'envertech_evt800', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_voltage_1', + 'unique_id': 'evt800_entry_123_ac_voltage_1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.evt800_ac_voltage_mppt_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'EVT800 AC Voltage MPPT 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evt800_ac_voltage_mppt_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '241.140625', + }) +# --- +# name: test_sensors[sensor.evt800_ac_voltage_mppt_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.evt800_ac_voltage_mppt_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AC Voltage MPPT 2', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC Voltage MPPT 2', + 'platform': 'envertech_evt800', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_voltage_2', + 'unique_id': 'evt800_entry_123_ac_voltage_2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.evt800_ac_voltage_mppt_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'EVT800 AC Voltage MPPT 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evt800_ac_voltage_mppt_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '241.140625', + }) +# --- +# name: test_sensors[sensor.evt800_dc_current_mppt_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.evt800_dc_current_mppt_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'DC Current MPPT 1', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC Current MPPT 1', + 'platform': 'envertech_evt800', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_1', + 'unique_id': 'evt800_entry_123_current_1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.evt800_dc_current_mppt_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'EVT800 DC Current MPPT 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evt800_dc_current_mppt_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.755135100110154', + }) +# --- +# name: test_sensors[sensor.evt800_dc_current_mppt_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.evt800_dc_current_mppt_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'DC Current MPPT 2', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC Current MPPT 2', + 'platform': 'envertech_evt800', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_2', + 'unique_id': 'evt800_entry_123_current_2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.evt800_dc_current_mppt_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'EVT800 DC Current MPPT 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evt800_dc_current_mppt_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0245577658264757', + }) +# --- +# name: test_sensors[sensor.evt800_dc_power_mppt_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.evt800_dc_power_mppt_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'DC Power MPPT 1', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC Power MPPT 1', + 'platform': 'envertech_evt800', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_1', + 'unique_id': 'evt800_entry_123_power_1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.evt800_dc_power_mppt_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'EVT800 DC Power MPPT 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evt800_dc_power_mppt_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '182.09375', + }) +# --- +# name: test_sensors[sensor.evt800_dc_power_mppt_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.evt800_dc_power_mppt_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'DC Power MPPT 2', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC Power MPPT 2', + 'platform': 'envertech_evt800', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_2', + 'unique_id': 'evt800_entry_123_power_2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.evt800_dc_power_mppt_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'EVT800 DC Power MPPT 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evt800_dc_power_mppt_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.921875', + }) +# --- +# name: test_sensors[sensor.evt800_dc_voltage_mppt_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.evt800_dc_voltage_mppt_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'DC Voltage MPPT 1', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC Voltage MPPT 1', + 'platform': 'envertech_evt800', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'input_voltage_1', + 'unique_id': 'evt800_entry_123_input_voltage_1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.evt800_dc_voltage_mppt_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'EVT800 DC Voltage MPPT 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evt800_dc_voltage_mppt_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '32.34375', + }) +# --- +# name: test_sensors[sensor.evt800_dc_voltage_mppt_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.evt800_dc_voltage_mppt_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'DC Voltage MPPT 2', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC Voltage MPPT 2', + 'platform': 'envertech_evt800', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'input_voltage_2', + 'unique_id': 'evt800_entry_123_input_voltage_2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.evt800_dc_voltage_mppt_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'EVT800 DC Voltage MPPT 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evt800_dc_voltage_mppt_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24.595703125', + }) +# --- +# name: test_sensors[sensor.evt800_mppt_id_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.evt800_mppt_id_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'MPPT ID 1', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'MPPT ID 1', + 'platform': 'envertech_evt800', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'mppt_id_1', + 'unique_id': 'evt800_entry_123_id_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.evt800_mppt_id_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'EVT800 MPPT ID 1', + }), + 'context': , + 'entity_id': 'sensor.evt800_mppt_id_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '39828832', + }) +# --- +# name: test_sensors[sensor.evt800_mppt_id_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.evt800_mppt_id_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'MPPT ID 2', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'MPPT ID 2', + 'platform': 'envertech_evt800', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'mppt_id_2', + 'unique_id': 'evt800_entry_123_id_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.evt800_mppt_id_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'EVT800 MPPT ID 2', + }), + 'context': , + 'entity_id': 'sensor.evt800_mppt_id_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '39828833', + }) +# --- +# name: test_sensors[sensor.evt800_temperature_mppt_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.evt800_temperature_mppt_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature MPPT 1', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature MPPT 1', + 'platform': 'envertech_evt800', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_1', + 'unique_id': 'evt800_entry_123_temperature_1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.evt800_temperature_mppt_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'EVT800 Temperature MPPT 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evt800_temperature_mppt_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '53.09375', + }) +# --- +# name: test_sensors[sensor.evt800_temperature_mppt_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.evt800_temperature_mppt_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature MPPT 2', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature MPPT 2', + 'platform': 'envertech_evt800', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_2', + 'unique_id': 'evt800_entry_123_temperature_2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.evt800_temperature_mppt_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'EVT800 Temperature MPPT 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evt800_temperature_mppt_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '45.3984375', + }) +# --- +# name: test_sensors[sensor.evt800_total_energy_mppt_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.evt800_total_energy_mppt_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Total Energy MPPT 1', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total Energy MPPT 1', + 'platform': 'envertech_evt800', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_1', + 'unique_id': 'evt800_entry_123_total_energy_1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.evt800_total_energy_mppt_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'EVT800 Total Energy MPPT 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evt800_total_energy_mppt_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.8431396484375', + }) +# --- +# name: test_sensors[sensor.evt800_total_energy_mppt_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.evt800_total_energy_mppt_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Total Energy MPPT 2', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total Energy MPPT 2', + 'platform': 'envertech_evt800', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_2', + 'unique_id': 'evt800_entry_123_total_energy_2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.evt800_total_energy_mppt_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'EVT800 Total Energy MPPT 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evt800_total_energy_mppt_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.446533203125', + }) +# --- diff --git a/tests/components/envertech_evt800/test_config_flow.py b/tests/components/envertech_evt800/test_config_flow.py new file mode 100644 index 00000000000000..84303ff853b08f --- /dev/null +++ b/tests/components/envertech_evt800/test_config_flow.py @@ -0,0 +1,100 @@ +"""Test the Envertech EVT800 config flow.""" + +from unittest.mock import AsyncMock + +from homeassistant.components.envertech_evt800.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import MOCK_USER_INPUT + +from tests.conftest import MockConfigEntry + + +async def test_full_flow( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_evt800_client: AsyncMock +) -> None: + """Test completing a full flow.""" + + 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_IP_ADDRESS: MOCK_USER_INPUT[CONF_IP_ADDRESS], + CONF_PORT: MOCK_USER_INPUT[CONF_PORT], + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Envertech EVT800" + assert result["data"] == MOCK_USER_INPUT + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_errors( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_evt800_client: AsyncMock +) -> None: + """Test encountering errors when configuring the integration.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + mock_evt800_client.test_connection.return_value = False + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_IP_ADDRESS: MOCK_USER_INPUT[CONF_IP_ADDRESS], + CONF_PORT: MOCK_USER_INPUT[CONF_PORT], + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + mock_evt800_client.test_connection.return_value = True + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_IP_ADDRESS: MOCK_USER_INPUT[CONF_IP_ADDRESS], + CONF_PORT: MOCK_USER_INPUT[CONF_PORT], + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_form_already_configured( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_evt800_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test starting a flow by user when already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_IP_ADDRESS: MOCK_USER_INPUT[CONF_IP_ADDRESS], + CONF_PORT: MOCK_USER_INPUT[CONF_PORT], + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/envertech_evt800/test_sensor.py b/tests/components/envertech_evt800/test_sensor.py new file mode 100644 index 00000000000000..e04e04f72edd0d --- /dev/null +++ b/tests/components/envertech_evt800/test_sensor.py @@ -0,0 +1,26 @@ +"""Tests for the ENVERTECH EVT800 sensors.""" + +from unittest.mock import AsyncMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + mock_evt800_client: AsyncMock, +) -> None: + """Test sensor entity state and registry data.""" + 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) diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index ff56bba933c22f..326519f64b8675 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -1,6 +1,7 @@ """Tests for the Sonos Media Player platform.""" from collections.abc import Generator +import logging from typing import Any from unittest.mock import MagicMock, patch @@ -1347,6 +1348,61 @@ async def test_play_media_announce( soco.play_uri.assert_called_with(content_id, force_radio=False) +@pytest.mark.parametrize( + ("content_id", "expect_warning"), + [ + pytest.param( + "http://10.0.0.1:8123/api/tts_proxy/abc123.mp3", + False, + id="mp3_no_warning", + ), + pytest.param( + "http://10.0.0.1:8123/api/tts_proxy/abc123.wav", + False, + id="wav_no_warning", + ), + pytest.param( + "http://10.0.0.1:8123/api/tts_proxy/abc123.flac", + True, + id="flac_warns_and_plays", + ), + pytest.param( + "http://10.0.0.1:8123/api/tts_proxy/abc123", + False, + id="no_extension_no_warning", + ), + ], +) +async def test_play_media_announce_format_warning( + hass: HomeAssistant, + soco: MockSoCo, + async_autosetup_sonos, + sonos_websocket, + content_id: str, + expect_warning: bool, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that announce logs a warning for unsupported file formats.""" + caplog.clear() + caplog.set_level( + logging.WARNING, logger="homeassistant.components.sonos.media_player" + ) + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.zone_a", + ATTR_MEDIA_CONTENT_TYPE: "music", + ATTR_MEDIA_CONTENT_ID: content_id, + ATTR_MEDIA_ANNOUNCE: True, + }, + blocking=True, + ) + assert sonos_websocket.play_clip.call_count == 1 + warning_logged = "only supports MP3 and WAV" in caplog.text + assert warning_logged == expect_warning + + async def test_media_get_queue( hass: HomeAssistant, soco: MockSoCo, diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index 3227fb65476d48..a411fdc320112e 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -1509,3 +1509,26 @@ def make_advertisement( connectable=True, tx_power=-127, ) + + +CONTACT_SENSOR_SERVICE_INFO = BluetoothServiceInfoBleak( + name="WoContact", + manufacturer_data={2409: b"\xaa\xbb\xcc\xdd\xee\xff\x00\x00\x00\x00\x00\x00\x00"}, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"d\x00\x64"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="WoContact", + manufacturer_data={ + 2409: b"\xaa\xbb\xcc\xdd\xee\xff\x00\x00\x00\x00\x00\x00\x00" + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"d\x00\x64"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "WoContact"), + time=0, + connectable=False, + tx_power=-127, +) diff --git a/tests/components/switchbot/test_event.py b/tests/components/switchbot/test_event.py index d7d4aa2e107d5b..2f6cff8ff239aa 100644 --- a/tests/components/switchbot/test_event.py +++ b/tests/components/switchbot/test_event.py @@ -14,7 +14,7 @@ from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component -from . import KEYPAD_VISION_PRO_INFO +from . import CONTACT_SENSOR_SERVICE_INFO, KEYPAD_VISION_PRO_INFO from tests.common import MockConfigEntry from tests.components.bluetooth import ( @@ -52,6 +52,34 @@ def _with_doorbell_seq( ) +def _with_contact_button_count( + info: BluetoothServiceInfoBleak, count: int +) -> BluetoothServiceInfoBleak: + """Return a BLE service info with the contact sensor button_count bits set.""" + mfr_data = bytearray(info.manufacturer_data[2409]) + mfr_data[12] = (mfr_data[12] & 0b11110000) | (count & 0b00001111) + updated_mfr_data = {2409: bytes(mfr_data)} + return BluetoothServiceInfoBleak( + name=info.name, + manufacturer_data=updated_mfr_data, + service_data=info.service_data, + service_uuids=info.service_uuids, + address=info.address, + rssi=info.rssi, + source=info.source, + advertisement=generate_advertisement_data( + local_name=info.name, + manufacturer_data=updated_mfr_data, + service_data=info.service_data, + service_uuids=info.service_uuids, + ), + device=generate_ble_device(info.address, info.name), + time=info.time, + connectable=info.connectable, + tx_power=info.tx_power, + ) + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_keypad_vision_pro_doorbell_event( hass: HomeAssistant, @@ -135,3 +163,81 @@ def _track_ring(event: Event[EventStateChangedData]) -> None: ) await hass.async_block_till_done() assert len(ring_states) == 4 + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_contact_sensor_button_event( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], +) -> None: + """Test contact sensor button event fires on button_count changes and wrap-around.""" + # Make each press's timestamp distinct so the entity's state value (which is + # the iso-formatted timestamp at millisecond resolution) differs between + # presses, ensuring state_changed fires for each. We avoid the `freezer` + # fixture here because it patches time.monotonic, which warps loop.time + # forward and causes the bluetooth manager's pre-scheduled unavailability + # check to fire immediately and mark the injected device unavailable. + # The same workaround is used in test_keypad_vision_pro_doorbell_event. + timestamps = ( + datetime(2026, 1, 1, tzinfo=UTC) + timedelta(seconds=i) for i in count() + ) + + await async_setup_component(hass, DOMAIN, {}) + inject_bluetooth_service_info(hass, CONTACT_SENSOR_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="contact") + entry.add_to_hass(hass) + + entity_id = "event.test_name_button" + press_states: list[str] = [] + + @callback + def _track_press(event: Event[EventStateChangedData]) -> None: + new_state = event.data["new_state"] + if new_state and new_state.attributes.get("event_type") == "press": + press_states.append(new_state.state) + + with patch( + "homeassistant.components.event.dt_util.utcnow", + side_effect=lambda: next(timestamps), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + async_track_state_change_event(hass, entity_id, _track_press) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNKNOWN + + inject_bluetooth_service_info( + hass, _with_contact_button_count(CONTACT_SENSOR_SERVICE_INFO, 1) + ) + await hass.async_block_till_done() + assert len(press_states) == 1 + + # Same count repeated, no new press fires. + inject_bluetooth_service_info( + hass, _with_contact_button_count(CONTACT_SENSOR_SERVICE_INFO, 1) + ) + await hass.async_block_till_done() + assert len(press_states) == 1 + + inject_bluetooth_service_info( + hass, _with_contact_button_count(CONTACT_SENSOR_SERVICE_INFO, 2) + ) + await hass.async_block_till_done() + assert len(press_states) == 2 + + # Counter rolls over from 15 back to 1; still a press. + inject_bluetooth_service_info( + hass, _with_contact_button_count(CONTACT_SENSOR_SERVICE_INFO, 15) + ) + await hass.async_block_till_done() + assert len(press_states) == 3 + + inject_bluetooth_service_info( + hass, _with_contact_button_count(CONTACT_SENSOR_SERVICE_INFO, 1) + ) + await hass.async_block_till_done() + assert len(press_states) == 4 diff --git a/tests/components/wallbox/conftest.py b/tests/components/wallbox/conftest.py index 2f643ba4645ef3..1a23e71ecc12aa 100644 --- a/tests/components/wallbox/conftest.py +++ b/tests/components/wallbox/conftest.py @@ -94,6 +94,7 @@ def mock_wallbox(): } ) wallbox.setIcpMaxCurrent = Mock(return_value={CHARGER_MAX_ICP_CURRENT_KEY: 25}) + wallbox.resumeSchedule = Mock(return_value={}) wallbox.getChargerStatus = Mock(return_value=WALLBOX_STATUS_RESPONSE) wallbox.jwtToken = "test_token" wallbox.jwtRefreshToken = "test_refresh_token" diff --git a/tests/components/wallbox/const.py b/tests/components/wallbox/const.py index 9650f9d3c61318..c31e1cb459c055 100644 --- a/tests/components/wallbox/const.py +++ b/tests/components/wallbox/const.py @@ -196,6 +196,7 @@ } +MOCK_BUTTON_RESUME_SCHEDULE_ID = "button.wallbox_wallboxname_resume_schedule" MOCK_NUMBER_ENTITY_ID = "number.wallbox_wallboxname_maximum_charging_current" MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID = "number.wallbox_wallboxname_energy_price" MOCK_NUMBER_ENTITY_ICP_CURRENT_ID = "number.wallbox_wallboxname_maximum_icp_current" diff --git a/tests/components/wallbox/test_button.py b/tests/components/wallbox/test_button.py new file mode 100644 index 00000000000000..754db0051d2a77 --- /dev/null +++ b/tests/components/wallbox/test_button.py @@ -0,0 +1,74 @@ +"""Test Wallbox Button component.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.wallbox.coordinator import InsufficientRights +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .conftest import http_403_error, http_404_error, http_429_error, setup_integration +from .const import MOCK_BUTTON_RESUME_SCHEDULE_ID + +from tests.common import MockConfigEntry + + +async def test_wallbox_button_resume_schedule( + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox: MagicMock +) -> None: + """Test pressing the resume schedule button calls the Wallbox API once.""" + + await setup_integration(hass, entry) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: MOCK_BUTTON_RESUME_SCHEDULE_ID}, + blocking=True, + ) + + mock_wallbox.resumeSchedule.assert_called_once() + + +async def test_wallbox_button_resume_schedule_error_handling( + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox: MagicMock +) -> None: + """Test button error handling for 403, 429 and other HTTP errors.""" + + await setup_integration(hass, entry) + + with ( + patch.object(mock_wallbox, "resumeSchedule", side_effect=http_403_error), + pytest.raises(InsufficientRights), + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: MOCK_BUTTON_RESUME_SCHEDULE_ID}, + blocking=True, + ) + + with ( + patch.object(mock_wallbox, "resumeSchedule", side_effect=http_429_error), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: MOCK_BUTTON_RESUME_SCHEDULE_ID}, + blocking=True, + ) + + with ( + patch.object(mock_wallbox, "resumeSchedule", side_effect=http_404_error), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: MOCK_BUTTON_RESUME_SCHEDULE_ID}, + blocking=True, + )