diff --git a/.strict-typing b/.strict-typing index 14f1540c533703..91043cd4563def 100644 --- a/.strict-typing +++ b/.strict-typing @@ -278,6 +278,7 @@ homeassistant.components.imap.* homeassistant.components.imgw_pib.* homeassistant.components.immich.* homeassistant.components.incomfort.* +homeassistant.components.inels.* homeassistant.components.input_button.* homeassistant.components.input_select.* homeassistant.components.input_text.* diff --git a/CODEOWNERS b/CODEOWNERS index 27a93ee9094594..9cfa84d0e93684 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -741,6 +741,8 @@ build.json @home-assistant/supervisor /tests/components/improv_ble/ @emontnemery /homeassistant/components/incomfort/ @jbouwh /tests/components/incomfort/ @jbouwh +/homeassistant/components/inels/ @epdevlab +/tests/components/inels/ @epdevlab /homeassistant/components/influxdb/ @mdegat01 /tests/components/influxdb/ @mdegat01 /homeassistant/components/inkbird/ @bdraco diff --git a/build.yaml b/build.yaml index bb1d8898837e6d..e82fd192123b2a 100644 --- a/build.yaml +++ b/build.yaml @@ -5,9 +5,6 @@ build_from: armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.10.1 amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.10.1 i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.10.1 -codenotary: - signer: notary@home-assistant.io - base_image: notary@home-assistant.io cosign: base_identity: https://github.com/home-assistant/docker/.* identity: https://github.com/home-assistant/core/.* diff --git a/homeassistant/components/inels/__init__.py b/homeassistant/components/inels/__init__.py new file mode 100644 index 00000000000000..cdfa4e3ed203c8 --- /dev/null +++ b/homeassistant/components/inels/__init__.py @@ -0,0 +1,95 @@ +"""The iNELS integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from inelsmqtt import InelsMqtt +from inelsmqtt.devices import Device +from inelsmqtt.discovery import InelsDiscovery + +from homeassistant.components import mqtt as ha_mqtt +from homeassistant.components.mqtt import ( + ReceiveMessage, + async_prepare_subscribe_topics, + async_subscribe_topics, + async_unsubscribe_topics, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import LOGGER, PLATFORMS + +type InelsConfigEntry = ConfigEntry[InelsData] + + +@dataclass +class InelsData: + """Represents the data structure for INELS runtime data.""" + + mqtt: InelsMqtt + devices: list[Device] + + +async def async_setup_entry(hass: HomeAssistant, entry: InelsConfigEntry) -> bool: + """Set up iNELS from a config entry.""" + + async def mqtt_publish(topic: str, payload: str, qos: int, retain: bool) -> None: + """Publish an MQTT message using the Home Assistant MQTT client.""" + await ha_mqtt.async_publish(hass, topic, payload, qos, retain) + + async def mqtt_subscribe( + sub_state: dict[str, Any] | None, + topic: str, + callback_func: Callable[[str, str], None], + ) -> dict[str, Any]: + """Subscribe to MQTT topics using the Home Assistant MQTT client.""" + + @callback + def mqtt_message_received(msg: ReceiveMessage) -> None: + """Handle iNELS mqtt messages.""" + # Payload is always str at runtime since we don't set encoding=None + # HA uses UTF-8 by default + callback_func(msg.topic, msg.payload) # type: ignore[arg-type] + + topics = { + "inels_subscribe_topic": { + "topic": topic, + "msg_callback": mqtt_message_received, + } + } + + sub_state = async_prepare_subscribe_topics(hass, sub_state, topics) + await async_subscribe_topics(hass, sub_state) + return sub_state + + async def mqtt_unsubscribe(sub_state: dict[str, Any]) -> None: + async_unsubscribe_topics(hass, sub_state) + + if not await ha_mqtt.async_wait_for_mqtt_client(hass): + LOGGER.error("MQTT integration not available") + raise ConfigEntryNotReady("MQTT integration not available") + + inels_mqtt = InelsMqtt(mqtt_publish, mqtt_subscribe, mqtt_unsubscribe) + devices: list[Device] = await InelsDiscovery(inels_mqtt).start() + + # If no devices are discovered, continue with the setup + if not devices: + LOGGER.info("No devices discovered") + + entry.runtime_data = InelsData(mqtt=inels_mqtt, devices=devices) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: InelsConfigEntry) -> bool: + """Unload a config entry.""" + await entry.runtime_data.mqtt.unsubscribe_topics() + entry.runtime_data.mqtt.unsubscribe_listeners() + + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/inels/config_flow.py b/homeassistant/components/inels/config_flow.py new file mode 100644 index 00000000000000..73c953ff239b9a --- /dev/null +++ b/homeassistant/components/inels/config_flow.py @@ -0,0 +1,73 @@ +"""Config flow for iNELS.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components import mqtt +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers.service_info.mqtt import MqttServiceInfo + +from .const import DOMAIN, TITLE + + +class INelsConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle of iNELS config flow.""" + + VERSION = 1 + + async def async_step_mqtt( + self, discovery_info: MqttServiceInfo + ) -> ConfigFlowResult: + """Handle a flow initialized by MQTT discovery.""" + if self._async_in_progress(): + return self.async_abort(reason="already_in_progress") + + # Validate the message, abort if it fails. + if not discovery_info.topic.endswith("/gw"): + # Not an iNELS discovery message. + return self.async_abort(reason="invalid_discovery_info") + if not discovery_info.payload: + # Empty payload, unexpected payload. + return self.async_abort(reason="invalid_discovery_info") + + return await self.async_step_confirm_from_mqtt() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + try: + if not mqtt.is_connected(self.hass): + return self.async_abort(reason="mqtt_not_connected") + except KeyError: + return self.async_abort(reason="mqtt_not_configured") + + return await self.async_step_confirm_from_user() + + async def step_confirm( + self, step_id: str, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm the setup.""" + + if user_input is not None: + await self.async_set_unique_id(DOMAIN) + return self.async_create_entry(title=TITLE, data={}) + + return self.async_show_form(step_id=step_id) + + async def async_step_confirm_from_mqtt( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm the setup from MQTT discovered.""" + return await self.step_confirm( + step_id="confirm_from_mqtt", user_input=user_input + ) + + async def async_step_confirm_from_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm the setup from user add integration.""" + return await self.step_confirm( + step_id="confirm_from_user", user_input=user_input + ) diff --git a/homeassistant/components/inels/const.py b/homeassistant/components/inels/const.py new file mode 100644 index 00000000000000..2f407887d4d6b5 --- /dev/null +++ b/homeassistant/components/inels/const.py @@ -0,0 +1,14 @@ +"""Constants for the iNELS integration.""" + +import logging + +from homeassistant.const import Platform + +DOMAIN = "inels" +TITLE = "iNELS" + +PLATFORMS: list[Platform] = [ + Platform.SWITCH, +] + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/inels/entity.py b/homeassistant/components/inels/entity.py new file mode 100644 index 00000000000000..592782ca5b7e47 --- /dev/null +++ b/homeassistant/components/inels/entity.py @@ -0,0 +1,61 @@ +"""Base class for iNELS components.""" + +from __future__ import annotations + +from inelsmqtt.devices import Device + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + + +class InelsBaseEntity(Entity): + """Base iNELS entity.""" + + _attr_should_poll = False + _attr_has_entity_name = True + + def __init__( + self, + device: Device, + key: str, + index: int, + ) -> None: + """Init base entity.""" + self._device = device + self._device_id = device.unique_id + self._attr_unique_id = self._device_id + + # The referenced variable to read from + self._key = key + # The index of the variable list to read from. '-1' for no index + self._index = index + + info = device.info() + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.unique_id)}, + manufacturer=info.manufacturer, + model=info.model_number, + name=device.title, + sw_version=info.sw_version, + ) + + async def async_added_to_hass(self) -> None: + """Add subscription of the data listener.""" + # Register the HA callback + self._device.add_ha_callback(self._key, self._index, self._callback) + # Subscribe to MQTT updates + self._device.mqtt.subscribe_listener( + self._device.state_topic, self._device.unique_id, self._device.callback + ) + + def _callback(self) -> None: + """Get data from broker into the HA.""" + if hasattr(self, "hass"): + self.schedule_update_ha_state() + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self._device.is_available diff --git a/homeassistant/components/inels/icons.json b/homeassistant/components/inels/icons.json new file mode 100644 index 00000000000000..aa111c31f5293c --- /dev/null +++ b/homeassistant/components/inels/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "switch": { + "bit": { + "default": "mdi:power-socket-eu" + }, + "simple_relay": { + "default": "mdi:power-socket-eu" + }, + "relay": { + "default": "mdi:power-socket-eu" + } + } + } +} diff --git a/homeassistant/components/inels/manifest.json b/homeassistant/components/inels/manifest.json new file mode 100644 index 00000000000000..2764983d5b28bd --- /dev/null +++ b/homeassistant/components/inels/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "inels", + "name": "iNELS", + "codeowners": ["@epdevlab"], + "config_flow": true, + "dependencies": ["mqtt"], + "documentation": "https://www.home-assistant.io/integrations/inels", + "iot_class": "local_push", + "mqtt": ["inels/status/#"], + "quality_scale": "bronze", + "requirements": ["elkoep-aio-mqtt==0.1.0b4"], + "single_config_entry": true +} diff --git a/homeassistant/components/inels/quality_scale.yaml b/homeassistant/components/inels/quality_scale.yaml new file mode 100644 index 00000000000000..732ff47170770a --- /dev/null +++ b/homeassistant/components/inels/quality_scale.yaml @@ -0,0 +1,118 @@ +rules: + # Bronze + config-flow: done + test-before-configure: done + unique-config-entry: done + config-flow-test-coverage: done + runtime-data: done + test-before-setup: + status: done + comment: > + Raise "Invalid authentication" and "MQTT Broker is offline or + cannot be reached" otherwise, async_setup_entry returns False + appropriate-polling: + status: done + comment: | + Integration uses local_push. + entity-unique-id: + status: done + comment: | + {MAC}_{DEVICE_ID} is used, for example, 0e97f8b7d30_02E8. + has-entity-name: + status: done + comment: > + Almost all devices are multi-functional, which means that all functions + are equally important -> keep the descriptive name (not setting _attr_name to None). + entity-event-setup: + status: done + comment: | + Subscribe in async_added_to_hass & unsubscribe from async_unload_entry. + dependency-transparency: done + action-setup: + status: exempt + comment: | + No custom actions are defined. + common-modules: done + docs-high-level-description: done + docs-installation-instructions: + status: done + comment: | + A link to the wiki is provided. + docs-removal-instructions: done + docs-actions: + status: exempt + comment: | + No custom actions are defined. + brands: done + # Silver + config-entry-unloading: done + log-when-unavailable: todo + entity-unavailable: + status: done + comment: | + available property. + action-exceptions: + status: exempt + comment: | + No custom actions are defined. + reauthentication-flow: todo + parallel-updates: + status: todo + comment: | + For all platforms, add a constant PARALLEL_UPDATES = 0. + test-coverage: done + integration-owner: done + docs-installation-parameters: + status: done + comment: | + A link to the wiki is provided. + docs-configuration-parameters: + status: exempt + comment: > + There is the same options flow in the integration as there is in the + configuration. + + # Gold + entity-translations: done + entity-device-class: todo + devices: done + entity-category: todo + entity-disabled-by-default: todo + discovery: + status: todo + comment: | + Currently blocked by a hw limitation. + stale-devices: + status: todo + comment: > + Same as discovery. The async_remove_config_entry_device function should be + implemented at a minimum. + diagnostics: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + dynamic-devices: todo + discovery-update-info: + status: todo + comment: | + Same as discovery. + repair-issues: todo + docs-use-cases: todo + docs-supported-devices: + status: todo + comment: > + In regards to this and below doc requirements, I am not sure whether the + wiki link is acceptable. + docs-supported-functions: todo + docs-data-update: todo + docs-known-limitations: todo + docs-troubleshooting: todo + docs-examples: todo + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + The integration is not making any HTTP requests. + strict-typing: todo diff --git a/homeassistant/components/inels/strings.json b/homeassistant/components/inels/strings.json new file mode 100644 index 00000000000000..e7a81bf18689c5 --- /dev/null +++ b/homeassistant/components/inels/strings.json @@ -0,0 +1,30 @@ +{ + "config": { + "step": { + "confirm_from_user": { + "description": "iNELS devices must be connected to the same broker as the Home Assistant MQTT integration client. Continue setup?" + }, + "confirm_from_mqtt": { + "description": "Do you want to set up iNELS?" + } + }, + "abort": { + "mqtt_not_connected": "Home Assistant MQTT integration not connected to MQTT broker.", + "mqtt_not_configured": "Home Assistant MQTT integration not configured.", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + } + }, + "entity": { + "switch": { + "bit": { + "name": "Bit{addr}" + }, + "simple_relay": { + "name": "Simple relay{index}" + }, + "relay": { + "name": "Relay{index}" + } + } + } +} diff --git a/homeassistant/components/inels/switch.py b/homeassistant/components/inels/switch.py new file mode 100644 index 00000000000000..22932e2c62975f --- /dev/null +++ b/homeassistant/components/inels/switch.py @@ -0,0 +1,137 @@ +"""iNELS switch entity.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from inelsmqtt.devices import Device +from inelsmqtt.utils.common import Bit, Relay, SimpleRelay + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import InelsConfigEntry +from .entity import InelsBaseEntity + + +@dataclass(frozen=True, kw_only=True) +class InelsSwitchEntityDescription(SwitchEntityDescription): + """Class describing iNELS switch entities.""" + + get_state_fn: Callable[[Device, int], Bit | SimpleRelay | Relay] + alerts: list[str] | None = None + placeholder_fn: Callable[[Device, int, bool], dict[str, str]] + + +SWITCH_TYPES = [ + InelsSwitchEntityDescription( + key="bit", + translation_key="bit", + get_state_fn=lambda device, index: device.state.bit[index], + placeholder_fn=lambda device, index, indexed: { + "addr": f" {device.state.bit[index].addr}" + }, + ), + InelsSwitchEntityDescription( + key="simple_relay", + translation_key="simple_relay", + get_state_fn=lambda device, index: device.state.simple_relay[index], + placeholder_fn=lambda device, index, indexed: { + "index": f" {index + 1}" if indexed else "" + }, + ), + InelsSwitchEntityDescription( + key="relay", + translation_key="relay", + get_state_fn=lambda device, index: device.state.relay[index], + alerts=["overflow"], + placeholder_fn=lambda device, index, indexed: { + "index": f" {index + 1}" if indexed else "" + }, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: InelsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Load iNELS switch.""" + entities: list[InelsSwitch] = [] + + for device in entry.runtime_data.devices: + for description in SWITCH_TYPES: + if hasattr(device.state, description.key): + switch_count = len(getattr(device.state, description.key)) + entities.extend( + InelsSwitch( + device=device, + description=description, + index=idx, + switch_count=switch_count, + ) + for idx in range(switch_count) + ) + + async_add_entities(entities, False) + + +class InelsSwitch(InelsBaseEntity, SwitchEntity): + """The platform class required by Home Assistant.""" + + entity_description: InelsSwitchEntityDescription + + def __init__( + self, + device: Device, + description: InelsSwitchEntityDescription, + index: int = 0, + switch_count: int = 1, + ) -> None: + """Initialize the switch.""" + super().__init__(device=device, key=description.key, index=index) + self.entity_description = description + self._switch_count = switch_count + + # Include index in unique_id for devices with multiple switches + unique_key = f"{description.key}{index}" if index else description.key + + self._attr_unique_id = f"{self._attr_unique_id}_{unique_key}".lower() + + # Set translation placeholders + self._attr_translation_placeholders = self.entity_description.placeholder_fn( + self._device, self._index, self._switch_count > 1 + ) + + def _check_alerts(self, current_state: Bit | SimpleRelay | Relay) -> None: + """Check if there are active alerts and raise ServiceValidationError if found.""" + if self.entity_description.alerts and any( + getattr(current_state, alert_key, None) + for alert_key in self.entity_description.alerts + ): + raise ServiceValidationError("Cannot operate switch with active alerts") + + @property + def is_on(self) -> bool | None: + """Return if switch is on.""" + current_state = self.entity_description.get_state_fn(self._device, self._index) + return current_state.is_on + + async def async_turn_off(self, **kwargs: Any) -> None: + """Instruct the switch to turn off.""" + current_state = self.entity_description.get_state_fn(self._device, self._index) + self._check_alerts(current_state) + current_state.is_on = False + await self._device.set_ha_value(self._device.state) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Instruct the switch to turn on.""" + current_state = self.entity_description.get_state_fn(self._device, self._index) + self._check_alerts(current_state) + current_state.is_on = True + await self._device.set_ha_value(self._device.state) diff --git a/homeassistant/components/lcn/sensor.py b/homeassistant/components/lcn/sensor.py index da475e5000508d..3d3f946b3aacc0 100644 --- a/homeassistant/components/lcn/sensor.py +++ b/homeassistant/components/lcn/sensor.py @@ -18,6 +18,7 @@ CONF_SOURCE, CONF_UNIT_OF_MEASUREMENT, LIGHT_LUX, + PERCENTAGE, UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfSpeed, @@ -50,6 +51,7 @@ pypck.lcn_defs.VarUnit.VOLT: SensorDeviceClass.VOLTAGE, pypck.lcn_defs.VarUnit.AMPERE: SensorDeviceClass.CURRENT, pypck.lcn_defs.VarUnit.PPM: SensorDeviceClass.CO2, + pypck.lcn_defs.VarUnit.PERCENT: SensorDeviceClass.HUMIDITY, } UNIT_OF_MEASUREMENT_MAPPING = { @@ -62,6 +64,7 @@ pypck.lcn_defs.VarUnit.VOLT: UnitOfElectricPotential.VOLT, pypck.lcn_defs.VarUnit.AMPERE: UnitOfElectricCurrent.AMPERE, pypck.lcn_defs.VarUnit.PPM: CONCENTRATION_PARTS_PER_MILLION, + pypck.lcn_defs.VarUnit.PERCENT: PERCENTAGE, } diff --git a/homeassistant/components/nederlandse_spoorwegen/manifest.json b/homeassistant/components/nederlandse_spoorwegen/manifest.json index 1f415dc695d69e..cba85d0197c07c 100644 --- a/homeassistant/components/nederlandse_spoorwegen/manifest.json +++ b/homeassistant/components/nederlandse_spoorwegen/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "legacy", - "requirements": ["nsapi==3.1.2"] + "requirements": ["nsapi==3.1.3"] } diff --git a/homeassistant/components/nextdns/quality_scale.yaml b/homeassistant/components/nextdns/quality_scale.yaml index 375b1bc273acf7..24b5b43d6d114b 100644 --- a/homeassistant/components/nextdns/quality_scale.yaml +++ b/homeassistant/components/nextdns/quality_scale.yaml @@ -48,19 +48,17 @@ rules: discovery: status: exempt comment: The integration is a cloud service and thus does not support discovery. - docs-data-update: todo - docs-examples: todo - docs-known-limitations: - status: todo - comment: Add info that there are no known limitations. + docs-data-update: done + docs-examples: done + docs-known-limitations: done docs-supported-devices: status: exempt comment: This is a service, which doesn't integrate with any devices. - docs-supported-functions: todo + docs-supported-functions: done docs-troubleshooting: status: exempt comment: No known issues that could be resolved by the user. - docs-use-cases: todo + docs-use-cases: done dynamic-devices: status: exempt comment: This integration has a fixed single service. diff --git a/homeassistant/components/nina/__init__.py b/homeassistant/components/nina/__init__.py index f9b23faa23462c..4bb435ea1cecd1 100644 --- a/homeassistant/components/nina/__init__.py +++ b/homeassistant/components/nina/__init__.py @@ -2,13 +2,17 @@ from __future__ import annotations +from typing import Any + from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .const import ( + _LOGGER, ALL_MATCH_REGEX, CONF_AREA_FILTER, CONF_FILTER_CORONA, + CONF_FILTERS, CONF_HEADLINE_FILTER, NO_MATCH_REGEX, ) @@ -19,20 +23,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: NinaConfigEntry) -> bool: """Set up platform from a ConfigEntry.""" - if CONF_HEADLINE_FILTER not in entry.data: - filter_regex = NO_MATCH_REGEX - - if entry.data[CONF_FILTER_CORONA]: - filter_regex = ".*corona.*" - - new_data = {**entry.data, CONF_HEADLINE_FILTER: filter_regex} - new_data.pop(CONF_FILTER_CORONA, None) - hass.config_entries.async_update_entry(entry, data=new_data) - - if CONF_AREA_FILTER not in entry.data: - new_data = {**entry.data, CONF_AREA_FILTER: ALL_MATCH_REGEX} - hass.config_entries.async_update_entry(entry, data=new_data) - coordinator = NINADataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() @@ -47,3 +37,52 @@ async def async_setup_entry(hass: HomeAssistant, entry: NinaConfigEntry) -> bool async def async_unload_entry(hass: HomeAssistant, entry: NinaConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_entry(hass: HomeAssistant, entry: NinaConfigEntry) -> bool: + """Migrate the config to the new format.""" + + version = entry.version + minor_version = entry.minor_version + + _LOGGER.debug("Migrating from version %s.%s", version, minor_version) + if entry.version > 1: + # This means the user has downgraded from a future version + return False + + new_data: dict[str, Any] = {**entry.data, CONF_FILTERS: {}} + + if version == 1 and minor_version == 1: + if CONF_HEADLINE_FILTER not in entry.data: + filter_regex = NO_MATCH_REGEX + + if entry.data.get(CONF_FILTER_CORONA, None): + filter_regex = ".*corona.*" + + new_data[CONF_HEADLINE_FILTER] = filter_regex + new_data.pop(CONF_FILTER_CORONA, None) + + if CONF_AREA_FILTER not in entry.data: + new_data[CONF_AREA_FILTER] = ALL_MATCH_REGEX + + hass.config_entries.async_update_entry( + entry, + data=new_data, + minor_version=2, + ) + minor_version = 2 + + if version == 1 and minor_version == 2: + new_data[CONF_FILTERS][CONF_HEADLINE_FILTER] = entry.data[CONF_HEADLINE_FILTER] + new_data.pop(CONF_HEADLINE_FILTER, None) + + new_data[CONF_FILTERS][CONF_AREA_FILTER] = entry.data[CONF_AREA_FILTER] + new_data.pop(CONF_AREA_FILTER, None) + + hass.config_entries.async_update_entry( + entry, + data=new_data, + minor_version=3, + ) + + return True diff --git a/homeassistant/components/nina/config_flow.py b/homeassistant/components/nina/config_flow.py index f7bc09144811c4..de0b4a56b11dfe 100644 --- a/homeassistant/components/nina/config_flow.py +++ b/homeassistant/components/nina/config_flow.py @@ -14,13 +14,16 @@ OptionsFlowWithReload, ) from homeassistant.core import callback +from homeassistant.data_entry_flow import section from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import VolDictType from .const import ( _LOGGER, + ALL_MATCH_REGEX, CONF_AREA_FILTER, + CONF_FILTERS, CONF_HEADLINE_FILTER, CONF_MESSAGE_SLOTS, CONF_REGIONS, @@ -87,6 +90,7 @@ class NinaConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for NINA.""" VERSION: int = 1 + MINOR_VERSION: int = 3 def __init__(self) -> None: """Initialize.""" @@ -126,8 +130,8 @@ async def async_step_user( if group_input := user_input.get(group): user_input[CONF_REGIONS] += group_input - if not user_input[CONF_HEADLINE_FILTER]: - user_input[CONF_HEADLINE_FILTER] = NO_MATCH_REGEX + if not user_input[CONF_FILTERS][CONF_HEADLINE_FILTER]: + user_input[CONF_FILTERS][CONF_HEADLINE_FILTER] = NO_MATCH_REGEX if user_input[CONF_REGIONS]: return self.async_create_entry( @@ -150,7 +154,18 @@ async def async_step_user( vol.Required(CONF_MESSAGE_SLOTS, default=5): vol.All( int, vol.Range(min=1, max=20) ), - vol.Optional(CONF_HEADLINE_FILTER, default=""): cv.string, + vol.Required(CONF_FILTERS): section( + vol.Schema( + { + vol.Optional( + CONF_HEADLINE_FILTER, default=NO_MATCH_REGEX + ): cv.string, + vol.Optional( + CONF_AREA_FILTER, default=ALL_MATCH_REGEX + ): cv.string, + } + ) + ), } ), errors=errors, @@ -259,14 +274,20 @@ async def async_step_init( CONF_MESSAGE_SLOTS, default=self.data[CONF_MESSAGE_SLOTS], ): vol.All(int, vol.Range(min=1, max=20)), - vol.Optional( - CONF_HEADLINE_FILTER, - default=self.data[CONF_HEADLINE_FILTER], - ): cv.string, - vol.Optional( - CONF_AREA_FILTER, - default=self.data[CONF_AREA_FILTER], - ): cv.string, + vol.Required(CONF_FILTERS): section( + vol.Schema( + { + vol.Optional( + CONF_HEADLINE_FILTER, + default=self.data[CONF_FILTERS][CONF_HEADLINE_FILTER], + ): cv.string, + vol.Optional( + CONF_AREA_FILTER, + default=self.data[CONF_FILTERS][CONF_AREA_FILTER], + ): cv.string, + } + ) + ), } return self.async_show_form( diff --git a/homeassistant/components/nina/const.py b/homeassistant/components/nina/const.py index 47194c4c2dedfa..409658e4131574 100644 --- a/homeassistant/components/nina/const.py +++ b/homeassistant/components/nina/const.py @@ -17,6 +17,7 @@ CONF_REGIONS: str = "regions" CONF_MESSAGE_SLOTS: str = "slots" +CONF_FILTERS: str = "filters" CONF_FILTER_CORONA: str = "corona_filter" # deprecated CONF_HEADLINE_FILTER: str = "headline_filter" CONF_AREA_FILTER: str = "area_filter" diff --git a/homeassistant/components/nina/coordinator.py b/homeassistant/components/nina/coordinator.py index eb1ad3d629352c..7097b24e41f0ee 100644 --- a/homeassistant/components/nina/coordinator.py +++ b/homeassistant/components/nina/coordinator.py @@ -17,6 +17,7 @@ from .const import ( _LOGGER, CONF_AREA_FILTER, + CONF_FILTERS, CONF_HEADLINE_FILTER, CONF_REGIONS, DOMAIN, @@ -58,8 +59,10 @@ def __init__( ) -> None: """Initialize.""" self._nina: Nina = Nina(async_get_clientsession(hass)) - self.headline_filter: str = config_entry.data[CONF_HEADLINE_FILTER] - self.area_filter: str = config_entry.data[CONF_AREA_FILTER] + self.headline_filter: str = config_entry.data[CONF_FILTERS][ + CONF_HEADLINE_FILTER + ] + self.area_filter: str = config_entry.data[CONF_FILTERS][CONF_AREA_FILTER] regions: dict[str, str] = config_entry.data[CONF_REGIONS] for region in regions: diff --git a/homeassistant/components/nina/strings.json b/homeassistant/components/nina/strings.json index 99acc636bd6f91..ed8bf1f0700e4b 100644 --- a/homeassistant/components/nina/strings.json +++ b/homeassistant/components/nina/strings.json @@ -10,8 +10,21 @@ "_m_to_q": "City/county (M-Q)", "_r_to_u": "City/county (R-U)", "_v_to_z": "City/county (V-Z)", - "slots": "Maximum warnings per city/county", - "headline_filter": "Headline blocklist" + "slots": "Maximum warnings per city/county" + }, + "sections": { + "filters": { + "name": "Filters", + "description": "Filter warnings based on their attributes", + "data": { + "headline_filter": "Headline blocklist", + "area_filter": "Affected area filter" + }, + "data_description": { + "headline_filter": "Blacklist regex to filter warning based on headlines", + "area_filter": "Whitelist regex to filter warnings based on affected areas" + } + } } } }, @@ -32,9 +45,21 @@ "_m_to_q": "[%key:component::nina::config::step::user::data::_m_to_q%]", "_r_to_u": "[%key:component::nina::config::step::user::data::_r_to_u%]", "_v_to_z": "[%key:component::nina::config::step::user::data::_v_to_z%]", - "slots": "[%key:component::nina::config::step::user::data::slots%]", - "headline_filter": "[%key:component::nina::config::step::user::data::headline_filter%]", - "area_filter": "Affected area filter" + "slots": "[%key:component::nina::config::step::user::data::slots%]" + }, + "sections": { + "filters": { + "name": "[%key:component::nina::config::step::user::sections::filters::name%]", + "description": "[%key:component::nina::config::step::user::sections::filters::description%]", + "data": { + "headline_filter": "[%key:component::nina::config::step::user::sections::filters::data::headline_filter%]", + "area_filter": "[%key:component::nina::config::step::user::sections::filters::data::area_filter%]" + }, + "data_description": { + "headline_filter": "[%key:component::nina::config::step::user::sections::filters::data_description::headline_filter%]", + "area_filter": "[%key:component::nina::config::step::user::sections::filters::data_description::area_filter%]" + } + } } } }, diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index c4ee99faa9170d..8fc79e5b190816 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -30,6 +30,7 @@ UnitOfEnergy, UnitOfFrequency, UnitOfPower, + UnitOfPrecipitationDepth, UnitOfPressure, UnitOfTemperature, UnitOfTime, @@ -1491,6 +1492,27 @@ def __init__( device_class=SensorDeviceClass.ENUM, options=["dark", "twilight", "bright"], ), + "number_average_temperature": RpcSensorDescription( + key="number", + sub_key="value", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=1, + device_class=SensorDeviceClass.TEMPERATURE, + role="average_temperature", + removal_condition=lambda config, _s, _k: not config.get("service:0", {}).get( + "weather_api", False + ), + ), + "number_last_precipitation": RpcSensorDescription( + key="number", + sub_key="value", + native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, + device_class=SensorDeviceClass.PRECIPITATION, + role="last_precipitation", + removal_condition=lambda config, _s, _k: not config.get("service:0", {}).get( + "weather_api", False + ), + ), "number_current_humidity": RpcSensorDescription( key="number", sub_key="value", diff --git a/homeassistant/components/squeezebox/button.py b/homeassistant/components/squeezebox/button.py index 788eea8f1bc426..0d2057ae801b3e 100644 --- a/homeassistant/components/squeezebox/button.py +++ b/homeassistant/components/squeezebox/button.py @@ -15,6 +15,7 @@ from .const import SIGNAL_PLAYER_DISCOVERED from .coordinator import SqueezeBoxPlayerUpdateCoordinator from .entity import SqueezeboxEntity +from .util import safe_library_call _LOGGER = logging.getLogger(__name__) @@ -157,4 +158,10 @@ def __init__( async def async_press(self) -> None: """Execute the button action.""" - await self._player.async_query("button", self.entity_description.press_action) + await safe_library_call( + self._player.async_query, + "button", + self.entity_description.press_action, + translation_key="press_failed", + translation_placeholders={"action": self.entity_description.press_action}, + ) diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 1ac08f1b4339a0..c57d88e1cd01dd 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -70,6 +70,7 @@ ) from .coordinator import SqueezeBoxPlayerUpdateCoordinator from .entity import SqueezeboxEntity +from .util import safe_library_call if TYPE_CHECKING: from . import SqueezeboxConfigEntry @@ -433,58 +434,98 @@ def query_result(self) -> dict | bool: async def async_turn_off(self) -> None: """Turn off media player.""" - await self._player.async_set_power(False) + await safe_library_call( + self._player.async_set_power, False, translation_key="turn_off_failed" + ) await self.coordinator.async_refresh() async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" volume_percent = str(round(volume * 100)) - await self._player.async_set_volume(volume_percent) + await safe_library_call( + self._player.async_set_volume, + volume_percent, + translation_key="set_volume_failed", + translation_placeholders={"volume": volume_percent}, + ) await self.coordinator.async_refresh() async def async_mute_volume(self, mute: bool) -> None: """Mute (true) or unmute (false) media player.""" - await self._player.async_set_muting(mute) + await safe_library_call( + self._player.async_set_muting, + mute, + translation_key="set_mute_failed", + ) await self.coordinator.async_refresh() async def async_media_stop(self) -> None: """Send stop command to media player.""" - await self._player.async_stop() + await safe_library_call( + self._player.async_stop, + translation_key="stop_failed", + ) await self.coordinator.async_refresh() async def async_media_play_pause(self) -> None: - """Send pause command to media player.""" - await self._player.async_toggle_pause() + """Send pause/play toggle command to media player.""" + await safe_library_call( + self._player.async_toggle_pause, + translation_key="play_pause_failed", + ) await self.coordinator.async_refresh() async def async_media_play(self) -> None: """Send play command to media player.""" - await self._player.async_play() + await safe_library_call( + self._player.async_play, + translation_key="play_failed", + ) await self.coordinator.async_refresh() async def async_media_pause(self) -> None: """Send pause command to media player.""" - await self._player.async_pause() + await safe_library_call( + self._player.async_pause, + translation_key="pause_failed", + ) await self.coordinator.async_refresh() async def async_media_next_track(self) -> None: """Send next track command.""" - await self._player.async_index("+1") + await safe_library_call( + self._player.async_index, + "+1", + translation_key="next_track_failed", + ) await self.coordinator.async_refresh() async def async_media_previous_track(self) -> None: - """Send next track command.""" - await self._player.async_index("-1") + """Send previous track command.""" + await safe_library_call( + self._player.async_index, + "-1", + translation_key="previous_track_failed", + ) await self.coordinator.async_refresh() async def async_media_seek(self, position: float) -> None: """Send seek command.""" - await self._player.async_time(position) + await safe_library_call( + self._player.async_time, + position, + translation_key="seek_failed", + translation_placeholders={"position": position}, + ) await self.coordinator.async_refresh() async def async_turn_on(self) -> None: """Turn the media player on.""" - await self._player.async_set_power(True) + await safe_library_call( + self._player.async_set_power, + True, + translation_key="turn_on_failed", + ) await self.coordinator.async_refresh() async def async_play_media( @@ -523,9 +564,7 @@ async def async_play_media( raise ServiceValidationError( translation_domain=DOMAIN, translation_key="invalid_announce_media_type", - translation_placeholders={ - "media_type": str(media_type), - }, + translation_placeholders={"media_type": str(media_type)}, ) extra = kwargs.get(ATTR_MEDIA_EXTRA, {}) @@ -536,9 +575,7 @@ async def async_play_media( raise ServiceValidationError( translation_domain=DOMAIN, translation_key="invalid_announce_volume", - translation_placeholders={ - "announce_volume": ATTR_ANNOUNCE_VOLUME, - }, + translation_placeholders={"announce_volume": ATTR_ANNOUNCE_VOLUME}, ) from None else: self._player.set_announce_volume(announce_volume) @@ -550,7 +587,7 @@ async def async_play_media( translation_domain=DOMAIN, translation_key="invalid_announce_timeout", translation_placeholders={ - "announce_timeout": ATTR_ANNOUNCE_TIMEOUT, + "announce_timeout": ATTR_ANNOUNCE_TIMEOUT }, ) from None else: @@ -558,15 +595,19 @@ async def async_play_media( if media_type in MediaType.MUSIC: if not media_id.startswith(SQUEEZEBOX_SOURCE_STRINGS): - # do not process special squeezebox "source" media ids media_id = async_process_play_media_url(self.hass, media_id) - await self._player.async_load_url(media_id, cmd) + await safe_library_call( + self._player.async_load_url, + media_id, + cmd, + translation_key="load_url_failed", + translation_placeholders={"media_id": media_id, "cmd": cmd}, + ) return if media_type == MediaType.PLAYLIST: try: - # a saved playlist by number payload = { "search_id": media_id, "search_type": MediaType.PLAYLIST, @@ -575,7 +616,6 @@ async def async_play_media( self._player, payload, self.browse_limit, self._browse_data ) except BrowseError: - # a list of urls content = json.loads(media_id) playlist = content["urls"] index = content["index"] @@ -587,12 +627,19 @@ async def async_play_media( playlist = await generate_playlist( self._player, payload, self.browse_limit, self._browse_data ) - _LOGGER.debug("Generated playlist: %s", playlist) - await self._player.async_load_playlist(playlist, cmd) + await safe_library_call( + self._player.async_load_playlist, + playlist, + cmd, + translation_key="load_playlist_failed", + translation_placeholders={"cmd": cmd}, + ) + if index is not None: await self._player.async_index(index) + await self.coordinator.async_refresh() async def async_search_media( @@ -672,18 +719,29 @@ async def async_set_repeat(self, repeat: RepeatMode) -> None: else: repeat_mode = "none" - await self._player.async_set_repeat(repeat_mode) + await safe_library_call( + self._player.async_set_repeat, + repeat_mode, + translation_key="set_repeat_failed", + ) await self.coordinator.async_refresh() async def async_set_shuffle(self, shuffle: bool) -> None: - """Enable/disable shuffle mode.""" + """Enable or disable shuffle mode.""" shuffle_mode = "song" if shuffle else "none" - await self._player.async_set_shuffle(shuffle_mode) + await safe_library_call( + self._player.async_set_shuffle, + shuffle_mode, + translation_key="set_shuffle_failed", + ) await self.coordinator.async_refresh() async def async_clear_playlist(self) -> None: - """Send the media player the command for clear playlist.""" - await self._player.async_clear_playlist() + """Send the media player the command to clear the playlist.""" + await safe_library_call( + self._player.async_clear_playlist, + translation_key="clear_playlist_failed", + ) await self.coordinator.async_refresh() async def async_call_method( @@ -692,12 +750,18 @@ async def async_call_method( """Call Squeezebox JSON/RPC method. Additional parameters are added to the command to form the list of - positional parameters (p0, p1..., pN) passed to JSON/RPC server. + positional parameters (p0, p1..., pN) passed to JSON/RPC server. """ all_params = [command] if parameters: all_params.extend(parameters) - await self._player.async_query(*all_params) + + await safe_library_call( + self._player.async_query, + *all_params, + translation_key="call_method_failed", + translation_placeholders={"command": command}, + ) async def async_call_query( self, command: str, parameters: list[str] | None = None @@ -705,12 +769,18 @@ async def async_call_query( """Call Squeezebox JSON/RPC method where we care about the result. Additional parameters are added to the command to form the list of - positional parameters (p0, p1..., pN) passed to JSON/RPC server. + positional parameters (p0, p1..., pN) passed to JSON/RPC server. """ all_params = [command] if parameters: all_params.extend(parameters) - self._query_result = await self._player.async_query(*all_params) + + self._query_result = await safe_library_call( + self._player.async_query, + *all_params, + translation_key="call_query_failed", + translation_placeholders={"command": command}, + ) _LOGGER.debug("call_query got result %s", self._query_result) self.async_write_ha_state() @@ -744,7 +814,10 @@ async def async_join_players(self, group_members: list[str]) -> None: async def async_unjoin_player(self) -> None: """Unsync this Squeezebox player.""" - await self._player.async_unsync() + await safe_library_call( + self._player.async_unsync, + translation_key="unjoin_failed", + ) await self.coordinator.async_refresh() def get_synthetic_id_and_cache_url(self, url: str) -> str: @@ -808,14 +881,19 @@ async def async_get_browse_image( image_url = self._synthetic_media_browser_thumbnail_items.get( media_image_id ) - if image_url is None: _LOGGER.debug("Synthetic ID %s not found in cache", media_image_id) return (None, None) else: - image_url = self._player.generate_image_url_from_track_id(media_image_id) + image_url = await safe_library_call( + self._player.generate_image_url_from_track_id, + media_image_id, + translation_key="generate_image_url_failed", + translation_placeholders={"track_id": media_image_id}, + ) result = await self._async_fetch_image(image_url) if result == (None, None): _LOGGER.debug("Error retrieving proxied album art from %s", image_url) + return result diff --git a/homeassistant/components/squeezebox/strings.json b/homeassistant/components/squeezebox/strings.json index 59d426047de5c5..69e8c646338302 100644 --- a/homeassistant/components/squeezebox/strings.json +++ b/homeassistant/components/squeezebox/strings.json @@ -207,6 +207,69 @@ }, "invalid_search_media_content_type": { "message": "If specified, Media content type must be one of {media_content_type}" + }, + "turn_on_failed": { + "message": "Failed to turn on the player." + }, + "turn_off_failed": { + "message": "Failed to turn off the player." + }, + "set_shuffle_failed": { + "message": "Failed to set shuffle mode." + }, + "set_volume_failed": { + "message": "Failed to set volume to {volume}%." + }, + "set_mute_failed": { + "message": "Failed to mute/unmute the player." + }, + "stop_failed": { + "message": "Failed to stop playback." + }, + "play_pause_failed": { + "message": "Failed to toggle play/pause." + }, + "play_failed": { + "message": "Failed to start playback." + }, + "pause_failed": { + "message": "Failed to pause playback." + }, + "next_track_failed": { + "message": "Failed to skip to the next track." + }, + "previous_track_failed": { + "message": "Failed to return to the previous track." + }, + "seek_failed": { + "message": "Failed to seek to position {position} seconds." + }, + "set_repeat_failed": { + "message": "Failed to set repeat mode." + }, + "clear_playlist_failed": { + "message": "Failed to clear the playlist." + }, + "call_method_failed": { + "message": "Failed to call method {command}." + }, + "call_query_failed": { + "message": "Failed to query method {command}." + }, + "unjoin_failed": { + "message": "Failed to unsync the player." + }, + "press_failed": { + "message": "Failed to execute button action {action}." + }, + "load_url_failed": { + "message": "Failed to load media URL {media_id} with command {cmd}." + }, + "load_playlist_failed": { + "message": "Failed to load playlist with command {cmd}." + }, + "generate_image_url_failed": { + "message": "Failed to generate image URL for track ID {track_id}." } } } diff --git a/homeassistant/components/squeezebox/util.py b/homeassistant/components/squeezebox/util.py new file mode 100644 index 00000000000000..a93122c22c4866 --- /dev/null +++ b/homeassistant/components/squeezebox/util.py @@ -0,0 +1,33 @@ +"""Utility functions for Squeezebox integration.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import Any + +from homeassistant.exceptions import HomeAssistantError + +from .const import DOMAIN + + +async def safe_library_call( + method: Callable[..., Awaitable[Any]], + *args: Any, + translation_key: str, + translation_placeholders: dict[str, Any] | None = None, + **kwargs: Any, +) -> Any: + """Call a player method safely and raise HomeAssistantError on failure.""" + try: + result = await method(*args, **kwargs) + except ValueError: + result = None + + if result is False or result is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=translation_key, + translation_placeholders=translation_placeholders, + ) + + return result diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 7ef474bce63724..30799f93a93949 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -304,6 +304,7 @@ "immich", "improv_ble", "incomfort", + "inels", "inkbird", "insteon", "intellifire", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index adcbc4275a8e33..ee3c036109adc7 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3029,6 +3029,13 @@ "integration_type": "virtual", "supported_by": "opower" }, + "inels": { + "name": "iNELS", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push", + "single_config_entry": true + }, "influxdb": { "name": "InfluxDB", "integration_type": "hub", diff --git a/homeassistant/generated/mqtt.py b/homeassistant/generated/mqtt.py index c4eb8708b0e3bb..aca0a9293a29ee 100644 --- a/homeassistant/generated/mqtt.py +++ b/homeassistant/generated/mqtt.py @@ -16,6 +16,9 @@ "fully_kiosk": [ "fully/deviceInfo/+", ], + "inels": [ + "inels/status/#", + ], "pglab": [ "pglab/discovery/#", ], diff --git a/mypy.ini b/mypy.ini index 4ef7b2a826fc2c..3bdb21e27b5df1 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2536,6 +2536,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.inels.*] +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.input_button.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index d35a164b6fae15..6aafb505b7097e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -876,6 +876,9 @@ eliqonline==1.2.2 # homeassistant.components.elkm1 elkm1-lib==2.2.11 +# homeassistant.components.inels +elkoep-aio-mqtt==0.1.0b4 + # homeassistant.components.elmax elmax-api==0.0.6.4rc0 @@ -1572,7 +1575,7 @@ notifications-android-tv==0.1.5 notify-events==1.0.4 # homeassistant.components.nederlandse_spoorwegen -nsapi==3.1.2 +nsapi==3.1.3 # homeassistant.components.nsw_fuel_station nsw-fuel-api-client==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0f321b6f716d86..51b09c46457515 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -764,6 +764,9 @@ elgato==5.1.2 # homeassistant.components.elkm1 elkm1-lib==2.2.11 +# homeassistant.components.inels +elkoep-aio-mqtt==0.1.0b4 + # homeassistant.components.elmax elmax-api==0.0.6.4rc0 @@ -1349,7 +1352,7 @@ notifications-android-tv==0.1.5 notify-events==1.0.4 # homeassistant.components.nederlandse_spoorwegen -nsapi==3.1.2 +nsapi==3.1.3 # homeassistant.components.nsw_fuel_station nsw-fuel-api-client==1.1.0 diff --git a/tests/components/inels/__init__.py b/tests/components/inels/__init__.py new file mode 100644 index 00000000000000..e5f262d1d34ac1 --- /dev/null +++ b/tests/components/inels/__init__.py @@ -0,0 +1,3 @@ +"""Tests for the iNELS integration.""" + +HA_INELS_PATH = "homeassistant.components.inels" diff --git a/tests/components/inels/common.py b/tests/components/inels/common.py new file mode 100644 index 00000000000000..33714e4a610ccb --- /dev/null +++ b/tests/components/inels/common.py @@ -0,0 +1,133 @@ +"""Common methods used across tests.""" + +from homeassistant.components import inels +from homeassistant.components.inels.const import DOMAIN +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry + +__all__ = [ + "MockConfigEntry", + "get_entity_id", + "get_entity_state", + "inels", + "old_entity_and_device_removal", + "set_mock_mqtt", +] + +MAC_ADDRESS = "001122334455" +UNIQUE_ID = "C0FFEE" +CONNECTED_INELS_VALUE = b"on\n" +DISCONNECTED_INELS_VALUE = b"off\n" + + +def get_entity_id(entity_config: dict, index: int) -> str: + """Construct the entity_id based on the entity_config.""" + unique_id = entity_config["unique_id"].lower() + base_id = f"{entity_config['entity_type']}.{MAC_ADDRESS}_{unique_id}_{entity_config['device_type']}" + return f"{base_id}{f'_{index:03}'}" if index is not None else base_id + + +def get_entity_state( + hass: HomeAssistant, entity_config: dict, index: int +) -> State | None: + """Return the state of the entity from the state machine.""" + entity_id = get_entity_id(entity_config, index) + return hass.states.get(entity_id) + + +def set_mock_mqtt( + mqtt, + config: dict, + status_value: bytes, + device_available: bool = True, + gw_available: bool = True, + last_value=None, +): + """Set mock mqtt communication.""" + gw_connected_value = '{"status":true}' if gw_available else '{"status":false}' + device_connected_value = ( + CONNECTED_INELS_VALUE if device_available else DISCONNECTED_INELS_VALUE + ) + + mqtt.mock_messages = { + config["gw_connected_topic"]: gw_connected_value, + config["connected_topic"]: device_connected_value, + config["status_topic"]: status_value, + } + mqtt.mock_discovery_all = {config["base_topic"]: status_value} + + if last_value is not None: + mqtt.mock_last_value = {config["status_topic"]: last_value} + else: + mqtt.mock_last_value = {} + + +async def old_entity_and_device_removal( + hass: HomeAssistant, mock_mqtt, platform, entity_config, value_key, index +): + """Test that old entities are correctly identified and removed across different platforms.""" + + set_mock_mqtt( + mock_mqtt, + config=entity_config, + status_value=entity_config[value_key], + gw_available=True, + device_available=True, + ) + + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + title="iNELS", + ) + config_entry.add_to_hass(hass) + + # Create an old entity + entity_registry = er.async_get(hass) + old_entity = entity_registry.async_get_or_create( + domain=platform, + platform=DOMAIN, + unique_id=f"old_{entity_config['unique_id']}", + suggested_object_id=f"old_inels_{platform}_{entity_config['device_type']}", + config_entry=config_entry, + ) + + # Create a device and associate it with the old entity + device_registry = dr.async_get(hass) + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, f"old_{entity_config['unique_id']}")}, + name=f"iNELS {platform.capitalize()} {entity_config['device_type']}", + manufacturer="iNELS", + model=entity_config["device_type"], + ) + + # Associate the old entity with the device + entity_registry.async_update_entity(old_entity.entity_id, device_id=device.id) + + assert ( + device_registry.async_get_device({(DOMAIN, old_entity.unique_id)}) is not None + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # The device was discovered, and at this point, the async_remove_old_entities function was called + assert config_entry.runtime_data.devices + assert old_entity.entity_id not in config_entry.runtime_data.old_entities[platform] + + # Get the new entity + new_entity = entity_registry.async_get(get_entity_id(entity_config, index).lower()) + + assert new_entity is not None + + # Verify that the new entity is in the registry + assert entity_registry.async_get(new_entity.entity_id) is not None + + # Verify that the old entity is no longer in the registry + assert entity_registry.async_get(old_entity.entity_id) is None + + # Verify that the device no longer exists in the registry + assert device_registry.async_get_device({(DOMAIN, old_entity.unique_id)}) is None diff --git a/tests/components/inels/conftest.py b/tests/components/inels/conftest.py new file mode 100644 index 00000000000000..5c242321230e2c --- /dev/null +++ b/tests/components/inels/conftest.py @@ -0,0 +1,119 @@ +"""Test fixtures.""" + +from typing import Any +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from homeassistant.core import HomeAssistant + +from . import HA_INELS_PATH +from .common import DOMAIN, MockConfigEntry, get_entity_state, set_mock_mqtt + + +@pytest.fixture(name="mock_mqtt") +def mock_inelsmqtt_fixture(): + """Mock inels mqtt lib.""" + + def messages(): + """Return mocked messages.""" + return mqtt.mock_messages + + def last_value(topic): + """Mock last_value to return None if mock_last_value is empty or topic doesn't exist.""" + return mqtt.mock_last_value.get(topic) if mqtt.mock_last_value else None + + async def discovery_all(): + """Return mocked discovered devices.""" + return mqtt.mock_discovery_all + + async def subscribe(topic, qos=0, options=None, properties=None): + """Mock subscribe fnc.""" + if isinstance(topic, list): + return {t: mqtt.mock_messages.get(t) for t in topic} + return mqtt.mock_messages.get(topic) + + async def publish(topic, payload, qos=0, retain=True, properties=None): + """Mock publish to change value of the device.""" + mqtt.mock_messages[topic] = payload + + unsubscribe_topics = AsyncMock() + unsubscribe_listeners = Mock() + + mqtt = Mock( + messages=messages, + subscribe=subscribe, + publish=publish, + last_value=last_value, + discovery_all=discovery_all, + unsubscribe_topics=unsubscribe_topics, + unsubscribe_listeners=unsubscribe_listeners, + mock_last_value=dict[str, Any](), + mock_messages=dict[str, Any](), + mock_discovery_all=dict[str, Any](), + ) + + with ( + patch(f"{HA_INELS_PATH}.InelsMqtt", return_value=mqtt), + ): + yield mqtt + + +@pytest.fixture +def mock_reload_entry(): + """Mock the async_reload_entry function.""" + with patch(f"{HA_INELS_PATH}.async_reload_entry") as mock_reload: + yield mock_reload + + +@pytest.fixture +def setup_entity(hass: HomeAssistant, mock_mqtt): + """Set up an entity for testing with specified configuration and status.""" + + async def _setup( + entity_config, + status_value: bytes, + device_available: bool = True, + gw_available: bool = True, + last_value=None, + index: int | None = None, + ): + set_mock_mqtt( + mock_mqtt, + config=entity_config, + status_value=status_value, + gw_available=gw_available, + device_available=device_available, + last_value=last_value, + ) + await setup_inels_test_integration(hass) + return get_entity_state(hass, entity_config, index) + + return _setup + + +@pytest.fixture +def entity_config(request: pytest.FixtureRequest): + """Fixture to provide parameterized entity configuration.""" + # This fixture will be parameterized in each test file + return request.param + + +async def setup_inels_test_integration(hass: HomeAssistant): + """Load inels integration with mocked mqtt broker.""" + hass.config.components.add(DOMAIN) + + entry = MockConfigEntry( + data={}, + domain=DOMAIN, + title="iNELS", + ) + entry.add_to_hass(hass) + + with ( + patch(f"{HA_INELS_PATH}.ha_mqtt.async_wait_for_mqtt_client", return_value=True), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert DOMAIN in hass.config.components diff --git a/tests/components/inels/test_config_flow.py b/tests/components/inels/test_config_flow.py new file mode 100644 index 00000000000000..921d12b7d576c2 --- /dev/null +++ b/tests/components/inels/test_config_flow.py @@ -0,0 +1,170 @@ +"""Test the iNELS config flow.""" + +from homeassistant.components.inels.const import DOMAIN, TITLE +from homeassistant.components.mqtt import MQTT_CONNECTION_STATE +from homeassistant.config_entries import SOURCE_MQTT, SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.service_info.mqtt import MqttServiceInfo + +from tests.common import MockConfigEntry +from tests.typing import MqttMockHAClient + + +async def test_mqtt_config_single_instance( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """The MQTT test flow is aborted if an entry already exists.""" + + MockConfigEntry(domain=DOMAIN).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + +async def test_mqtt_setup(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: + """When an MQTT message is received on the discovery topic, it triggers a config flow.""" + discovery_info = MqttServiceInfo( + topic="inels/status/MAC_ADDRESS/gw", + payload='{"CUType":"CU3-08M","Status":"Runfast","FW":"02.97.18"}', + qos=0, + retain=False, + subscribed_topic="inels/status/#", + timestamp=None, + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery_info + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == TITLE + assert result["result"].data == {} + + +async def test_mqtt_abort_invalid_topic( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Check MQTT flow aborts if discovery topic is invalid.""" + discovery_info = MqttServiceInfo( + topic="inels/status/MAC_ADDRESS/wrong_topic", + payload='{"CUType":"CU3-08M","Status":"Runfast","FW":"02.97.18"}', + qos=0, + retain=False, + subscribed_topic="inels/status/#", + timestamp=None, + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery_info + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "invalid_discovery_info" + + +async def test_mqtt_abort_empty_payload( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Check MQTT flow aborts if discovery payload is empty.""" + discovery_info = MqttServiceInfo( + topic="inels/status/MAC_ADDRESS/gw", + payload="", + qos=0, + retain=False, + subscribed_topic="inels/status/#", + timestamp=None, + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery_info + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "invalid_discovery_info" + + +async def test_mqtt_abort_already_in_progress( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test that a second MQTT flow is aborted when one is already in progress.""" + discovery_info = MqttServiceInfo( + topic="inels/status/MAC_ADDRESS/gw", + payload='{"CUType":"CU3-08M","Status":"Runfast","FW":"02.97.18"}', + qos=0, + retain=False, + subscribed_topic="inels/status/#", + timestamp=None, + ) + + # Start first MQTT flow + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery_info + ) + assert result["type"] is FlowResultType.FORM + + # Try to start second MQTT flow while first is in progress + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery_info + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_in_progress" + + +async def test_user_setup(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: + """Test if the user can finish a config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TITLE + assert result["result"].data == {} + + +async def test_user_config_single_instance( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """The user test flow is aborted if an entry already exists.""" + MockConfigEntry(domain=DOMAIN).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + +async def test_user_setup_mqtt_not_connected( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """The user setup test flow is aborted when MQTT is not connected.""" + + mqtt_mock.connected = False + async_dispatcher_send(hass, MQTT_CONNECTION_STATE, False) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "mqtt_not_connected" + + +async def test_user_setup_mqtt_not_configured(hass: HomeAssistant) -> None: + """The user setup test flow is aborted when MQTT is not configured.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "mqtt_not_configured" diff --git a/tests/components/inels/test_init.py b/tests/components/inels/test_init.py new file mode 100644 index 00000000000000..e8b4e47687f1a3 --- /dev/null +++ b/tests/components/inels/test_init.py @@ -0,0 +1,102 @@ +"""Tests for iNELS integration.""" + +from unittest.mock import ANY, AsyncMock, patch + +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from . import HA_INELS_PATH +from .common import DOMAIN, inels + +from tests.common import MockConfigEntry +from tests.typing import MqttMockHAClient + + +async def test_ha_mqtt_publish( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test that MQTT publish function works correctly.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}) + config_entry.add_to_hass(hass) + + with ( + patch(f"{HA_INELS_PATH}.InelsDiscovery") as mock_discovery_class, + patch( + "homeassistant.config_entries.ConfigEntries.async_forward_entry_setups", + return_value=None, + ), + ): + mock_discovery = AsyncMock() + mock_discovery.start.return_value = [] + mock_discovery_class.return_value = mock_discovery + + await inels.async_setup_entry(hass, config_entry) + + topic, payload, qos, retain = "test/topic", "test_payload", 1, True + + await config_entry.runtime_data.mqtt.publish(topic, payload, qos, retain) + mqtt_mock.async_publish.assert_called_once_with(topic, payload, qos, retain) + + +async def test_ha_mqtt_subscribe( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test that MQTT subscribe function works correctly.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}) + config_entry.add_to_hass(hass) + + with ( + patch(f"{HA_INELS_PATH}.InelsDiscovery") as mock_discovery_class, + patch( + "homeassistant.config_entries.ConfigEntries.async_forward_entry_setups", + return_value=None, + ), + ): + mock_discovery = AsyncMock() + mock_discovery.start.return_value = [] + mock_discovery_class.return_value = mock_discovery + + await inels.async_setup_entry(hass, config_entry) + + topic = "test/topic" + + await config_entry.runtime_data.mqtt.subscribe(topic) + mqtt_mock.async_subscribe.assert_any_call(topic, ANY, 0, "utf-8", None) + + +async def test_ha_mqtt_not_available( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test that ConfigEntryNotReady is raised when MQTT is not available.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}) + config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.mqtt.async_wait_for_mqtt_client", + return_value=False, + ), + pytest.raises(ConfigEntryNotReady, match="MQTT integration not available"), + ): + await inels.async_setup_entry(hass, config_entry) + + +async def test_unload_entry(hass: HomeAssistant, mock_mqtt) -> None: + """Test unload entry.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}) + config_entry.add_to_hass(hass) + + config_entry.runtime_data = inels.InelsData(mqtt=mock_mqtt, devices=[]) + + with patch( + "homeassistant.config_entries.ConfigEntries.async_unload_platforms", + return_value=True, + ) as mock_unload_platforms: + result = await inels.async_unload_entry(hass, config_entry) + + assert result is True + mock_mqtt.unsubscribe_topics.assert_called_once() + mock_mqtt.unsubscribe_listeners.assert_called_once() + mock_unload_platforms.assert_called_once() diff --git a/tests/components/inels/test_switch.py b/tests/components/inels/test_switch.py new file mode 100644 index 00000000000000..14680012746363 --- /dev/null +++ b/tests/components/inels/test_switch.py @@ -0,0 +1,167 @@ +"""iNELS switch platform testing.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from .common import MAC_ADDRESS, UNIQUE_ID, get_entity_id + +DT_07 = "07" +DT_100 = "100" +DT_BITS = "bits" + + +@pytest.fixture(params=["simple_relay", "relay", "bit"]) +def entity_config(request: pytest.FixtureRequest): + """Fixture to provide parameterized entity configuration for switch tests.""" + configs = { + "simple_relay": { + "entity_type": "switch", + "device_type": "simple_relay", + "dev_type": DT_07, + "unique_id": UNIQUE_ID, + "gw_connected_topic": f"inels/connected/{MAC_ADDRESS}/gw", + "connected_topic": f"inels/connected/{MAC_ADDRESS}/{DT_07}/{UNIQUE_ID}", + "status_topic": f"inels/status/{MAC_ADDRESS}/{DT_07}/{UNIQUE_ID}", + "base_topic": f"{MAC_ADDRESS}/{DT_07}/{UNIQUE_ID}", + "switch_on_value": "07\n01\n92\n09\n", + "switch_off_value": "07\n00\n92\n09\n", + }, + "relay": { + "entity_type": "switch", + "device_type": "relay", + "dev_type": DT_100, + "unique_id": UNIQUE_ID, + "gw_connected_topic": f"inels/connected/{MAC_ADDRESS}/gw", + "connected_topic": f"inels/connected/{MAC_ADDRESS}/{DT_100}/{UNIQUE_ID}", + "status_topic": f"inels/status/{MAC_ADDRESS}/{DT_100}/{UNIQUE_ID}", + "base_topic": f"{MAC_ADDRESS}/{DT_100}/{UNIQUE_ID}", + "switch_on_value": "07\n00\n0A\n28\n00\n", + "switch_off_value": "06\n00\n0A\n28\n00\n", + "alerts": { + "overflow": "06\n00\n0A\n28\n01\n", + }, + }, + "bit": { + "entity_type": "switch", + "device_type": "bit", + "dev_type": DT_BITS, + "unique_id": UNIQUE_ID, + "gw_connected_topic": f"inels/connected/{MAC_ADDRESS}/gw", + "connected_topic": f"inels/connected/{MAC_ADDRESS}/{DT_BITS}/{UNIQUE_ID}", + "status_topic": f"inels/status/{MAC_ADDRESS}/{DT_BITS}/{UNIQUE_ID}", + "base_topic": f"{MAC_ADDRESS}/{DT_BITS}/{UNIQUE_ID}", + "switch_on_value": b'{"state":{"000":1,"001":1}}', + "switch_off_value": b'{"state":{"000":0,"001":0}}', + }, + } + return configs[request.param] + + +@pytest.mark.parametrize( + "entity_config", ["simple_relay", "relay", "bit"], indirect=True +) +@pytest.mark.parametrize( + ("gw_available", "device_available", "expected_state"), + [ + (True, False, STATE_UNAVAILABLE), + (False, True, STATE_UNAVAILABLE), + (True, True, STATE_ON), + ], +) +async def test_switch_availability( + hass: HomeAssistant, + setup_entity, + entity_config, + gw_available, + device_available, + expected_state, +) -> None: + """Test switch availability and state under different gateway and device availability conditions.""" + + switch_state = await setup_entity( + entity_config, + status_value=entity_config["switch_on_value"], + gw_available=gw_available, + device_available=device_available, + index=0 if entity_config["device_type"] == "bit" else None, + ) + + assert switch_state is not None + assert switch_state.state == expected_state + + +@pytest.mark.parametrize( + ("entity_config", "index"), + [ + ("simple_relay", None), + ("relay", None), + ("bit", 0), + ], + indirect=["entity_config"], +) +async def test_switch_turn_on( + hass: HomeAssistant, setup_entity, entity_config, index +) -> None: + """Test turning on a switch.""" + switch_state = await setup_entity( + entity_config, status_value=entity_config["switch_off_value"], index=index + ) + + assert switch_state is not None + assert switch_state.state == STATE_OFF + + with patch("inelsmqtt.devices.Device.set_ha_value") as mock_set_state: + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: get_entity_id(entity_config, index)}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once() + + ha_value = mock_set_state.call_args.args[0] + assert getattr(ha_value, entity_config["device_type"])[0].is_on is True + + +@pytest.mark.parametrize( + ("entity_config", "index"), + [ + ("simple_relay", None), + ("relay", None), + ("bit", 0), + ], + indirect=["entity_config"], +) +async def test_switch_turn_off( + hass: HomeAssistant, setup_entity, entity_config, index +) -> None: + """Test turning off a switch.""" + switch_state = await setup_entity( + entity_config, status_value=entity_config["switch_on_value"], index=index + ) + + assert switch_state is not None + assert switch_state.state == STATE_ON + + with patch("inelsmqtt.devices.Device.set_ha_value") as mock_set_state: + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: get_entity_id(entity_config, index)}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once() + + ha_value = mock_set_state.call_args.args[0] + assert getattr(ha_value, entity_config["device_type"])[0].is_on is False diff --git a/tests/components/nextdns/conftest.py b/tests/components/nextdns/conftest.py index 01eb34e9564ddd..9699ffe2f3071d 100644 --- a/tests/components/nextdns/conftest.py +++ b/tests/components/nextdns/conftest.py @@ -59,29 +59,31 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_nextdns_client() -> Generator[AsyncMock]: - """Mock a NextDNS client.""" - +def mock_nextdns() -> Generator[AsyncMock]: + """Mock the NextDns class.""" with ( - patch("homeassistant.components.nextdns.NextDns", autospec=True) as mock_client, - patch( - "homeassistant.components.nextdns.config_flow.NextDns", - new=mock_client, - ), + patch("homeassistant.components.nextdns.NextDns", autospec=True) as mock_class, + patch("homeassistant.components.nextdns.config_flow.NextDns", new=mock_class), ): - client = mock_client.create.return_value - client.clear_logs.return_value = True - client.connection_status.return_value = CONNECTION_STATUS - client.get_analytics_dnssec.return_value = ANALYTICS_DNSSEC - client.get_analytics_encryption.return_value = ANALYTICS_ENCRYPTION - client.get_analytics_ip_versions.return_value = ANALYTICS_IP_VERSIONS - client.get_analytics_protocols.return_value = ANALYTICS_PROTOCOLS - client.get_analytics_status.return_value = ANALYTICS_STATUS - client.get_profile_id = Mock(return_value="xyz12") - client.get_profile_name = Mock(return_value="Fake Profile") - client.get_profiles.return_value = PROFILES - client.get_settings.return_value = SETTINGS - client.set_setting.return_value = True - client.profiles = [ProfileInfo(**PROFILES[0])] + yield mock_class + + +@pytest.fixture +def mock_nextdns_client(mock_nextdns: AsyncMock) -> AsyncMock: + """Mock a NextDNS client instance.""" + client = mock_nextdns.create.return_value + client.clear_logs.return_value = True + client.connection_status.return_value = CONNECTION_STATUS + client.get_analytics_dnssec.return_value = ANALYTICS_DNSSEC + client.get_analytics_encryption.return_value = ANALYTICS_ENCRYPTION + client.get_analytics_ip_versions.return_value = ANALYTICS_IP_VERSIONS + client.get_analytics_protocols.return_value = ANALYTICS_PROTOCOLS + client.get_analytics_status.return_value = ANALYTICS_STATUS + client.get_profile_id = Mock(return_value="xyz12") + client.get_profile_name = Mock(return_value="Fake Profile") + client.get_profiles.return_value = PROFILES + client.get_settings.return_value = SETTINGS + client.set_setting.return_value = True + client.profiles = [ProfileInfo(**PROFILES[0])] - yield client + return client diff --git a/tests/components/nextdns/test_config_flow.py b/tests/components/nextdns/test_config_flow.py index 5ad76820241752..1ed5a59a5bf84d 100644 --- a/tests/components/nextdns/test_config_flow.py +++ b/tests/components/nextdns/test_config_flow.py @@ -1,6 +1,6 @@ """Define tests for the NextDNS config flow.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock from nextdns import ApiError, InvalidApiKeyError import pytest @@ -21,6 +21,7 @@ async def test_form_create_entry( hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_nextdns_client: AsyncMock, + mock_nextdns: AsyncMock, ) -> None: """Test that the user step works.""" result = await hass.config_entries.flow.async_init( @@ -64,6 +65,7 @@ async def test_form_errors( hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_nextdns_client: AsyncMock, + mock_nextdns: AsyncMock, exc: Exception, base_error: str, ) -> None: @@ -74,18 +76,18 @@ async def test_form_errors( assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with patch( - "homeassistant.components.nextdns.NextDns.create", - side_effect=exc, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_API_KEY: "fake_api_key"}, - ) + mock_nextdns.create.side_effect = exc + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "fake_api_key"}, + ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": base_error} + mock_nextdns.create.side_effect = None + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_API_KEY: "fake_api_key"}, @@ -110,6 +112,7 @@ async def test_form_already_configured( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_nextdns_client: AsyncMock, + mock_nextdns: AsyncMock, ) -> None: """Test that errors are shown when duplicates are added.""" await init_integration(hass, mock_config_entry) @@ -135,6 +138,7 @@ async def test_reauth_successful( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_nextdns_client: AsyncMock, + mock_nextdns: AsyncMock, ) -> None: """Test starting a reauthentication flow.""" await init_integration(hass, mock_config_entry) @@ -168,6 +172,7 @@ async def test_reauth_errors( base_error: str, mock_config_entry: MockConfigEntry, mock_nextdns_client: AsyncMock, + mock_nextdns: AsyncMock, ) -> None: """Test reauthentication flow with errors.""" await init_integration(hass, mock_config_entry) @@ -176,14 +181,17 @@ async def test_reauth_errors( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - with patch("homeassistant.components.nextdns.NextDns.create", side_effect=exc): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_API_KEY: "new_api_key"}, - ) + mock_nextdns.create.side_effect = exc + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "new_api_key"}, + ) assert result["errors"] == {"base": base_error} + mock_nextdns.create.side_effect = None + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_API_KEY: "new_api_key"}, diff --git a/tests/components/nextdns/test_init.py b/tests/components/nextdns/test_init.py index 9532f5a13e0fc6..1eaaeba3a52b27 100644 --- a/tests/components/nextdns/test_init.py +++ b/tests/components/nextdns/test_init.py @@ -1,6 +1,6 @@ """Test init of NextDNS integration.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock from nextdns import ApiError, InvalidApiKeyError import pytest @@ -36,15 +36,13 @@ async def test_async_setup_entry( async def test_config_not_ready( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_nextdns_client: AsyncMock, + mock_nextdns: AsyncMock, exc: Exception, ) -> None: """Test for setup failure if the connection to the service fails.""" - with patch( - "homeassistant.components.nextdns.NextDns.create", - side_effect=exc, - ): - await init_integration(hass, mock_config_entry) + mock_nextdns.create.side_effect = exc + + await init_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY @@ -53,6 +51,7 @@ async def test_unload_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_nextdns_client: AsyncMock, + mock_nextdns: AsyncMock, ) -> None: """Test successful unload of entry.""" await init_integration(hass, mock_config_entry) @@ -70,14 +69,12 @@ async def test_unload_entry( async def test_config_auth_failed( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_nextdns_client: AsyncMock, + mock_nextdns: AsyncMock, ) -> None: """Test for setup failure if the auth fails.""" - with patch( - "homeassistant.components.nextdns.NextDns.create", - side_effect=InvalidApiKeyError, - ): - await init_integration(hass, mock_config_entry) + mock_nextdns.create.side_effect = InvalidApiKeyError + + await init_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/nina/snapshots/test_diagnostics.ambr b/tests/components/nina/snapshots/test_diagnostics.ambr index aaf4247191227e..331323ff76c95d 100644 --- a/tests/components/nina/snapshots/test_diagnostics.ambr +++ b/tests/components/nina/snapshots/test_diagnostics.ambr @@ -34,8 +34,10 @@ ]), }), 'entry_data': dict({ - 'area_filter': '.*', - 'headline_filter': '.*corona.*', + 'filters': dict({ + 'area_filter': '.*', + 'headline_filter': '.*corona.*', + }), 'regions': dict({ '083350000000': 'Aach, Stadt', }), diff --git a/tests/components/nina/test_binary_sensor.py b/tests/components/nina/test_binary_sensor.py index d18b7562b53920..daaabade4230db 100644 --- a/tests/components/nina/test_binary_sensor.py +++ b/tests/components/nina/test_binary_sensor.py @@ -31,21 +31,29 @@ ENTRY_DATA: dict[str, Any] = { "slots": 5, - "corona_filter": True, "regions": {"083350000000": "Aach, Stadt"}, + "filters": { + "headline_filter": ".*corona.*", + "area_filter": ".*", + }, } ENTRY_DATA_NO_CORONA: dict[str, Any] = { "slots": 5, - "corona_filter": False, "regions": {"083350000000": "Aach, Stadt"}, + "filters": { + "headline_filter": "/(?!)/", + "area_filter": ".*", + }, } ENTRY_DATA_NO_AREA: dict[str, Any] = { "slots": 5, - "corona_filter": False, - "area_filter": ".*nagold.*", "regions": {"083350000000": "Aach, Stadt"}, + "filters": { + "headline_filter": "/(?!)/", + "area_filter": ".*nagold.*", + }, } @@ -57,7 +65,7 @@ async def test_sensors(hass: HomeAssistant, entity_registry: er.EntityRegistry) wraps=mocked_request_function, ): conf_entry: MockConfigEntry = MockConfigEntry( - domain=DOMAIN, title="NINA", data=ENTRY_DATA + domain=DOMAIN, title="NINA", data=ENTRY_DATA, version=1, minor_version=3 ) conf_entry.add_to_hass(hass) @@ -178,7 +186,11 @@ async def test_sensors_without_corona_filter( wraps=mocked_request_function, ): conf_entry: MockConfigEntry = MockConfigEntry( - domain=DOMAIN, title="NINA", data=ENTRY_DATA_NO_CORONA + domain=DOMAIN, + title="NINA", + data=ENTRY_DATA_NO_CORONA, + version=1, + minor_version=3, ) conf_entry.add_to_hass(hass) @@ -311,7 +323,11 @@ async def test_sensors_with_area_filter( wraps=mocked_request_function, ): conf_entry: MockConfigEntry = MockConfigEntry( - domain=DOMAIN, title="NINA", data=ENTRY_DATA_NO_AREA + domain=DOMAIN, + title="NINA", + data=ENTRY_DATA_NO_AREA, + version=1, + minor_version=3, ) conf_entry.add_to_hass(hass) diff --git a/tests/components/nina/test_config_flow.py b/tests/components/nina/test_config_flow.py index 06eb94d59d00c5..8132559dba9aae 100644 --- a/tests/components/nina/test_config_flow.py +++ b/tests/components/nina/test_config_flow.py @@ -11,6 +11,7 @@ from homeassistant.components.nina.const import ( CONF_AREA_FILTER, + CONF_FILTERS, CONF_HEADLINE_FILTER, CONF_MESSAGE_SLOTS, CONF_REGIONS, @@ -39,8 +40,10 @@ CONST_REGION_M_TO_Q: ["071380000000_0", "071380000000_1"], CONST_REGION_R_TO_U: ["072320000000_0", "072320000000_1"], CONST_REGION_V_TO_Z: ["081270000000_0", "081270000000_1"], - CONF_HEADLINE_FILTER: ".*corona.*", - CONF_AREA_FILTER: ".*", + CONF_FILTERS: { + CONF_HEADLINE_FILTER: ".*corona.*", + CONF_AREA_FILTER: ".*", + }, } DUMMY_RESPONSE_REGIONS: dict[str, Any] = json.loads( @@ -112,6 +115,13 @@ async def test_step_user(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "NINA" + assert result["data"] == deepcopy(DUMMY_DATA) | { + CONF_REGIONS: { + "095760000000": "Allersberg, M (Roth - Bayern) + Büchenbach (Roth - Bayern)" + } + } + assert result["version"] == 1 + assert result["minor_version"] == 3 async def test_step_user_no_selection(hass: HomeAssistant) -> None: @@ -121,7 +131,9 @@ async def test_step_user_no_selection(hass: HomeAssistant) -> None: wraps=mocked_request_function, ): result: dict[str, Any] = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={CONF_HEADLINE_FILTER: ""} + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_FILTERS: {CONF_HEADLINE_FILTER: ""}}, ) assert result["type"] is FlowResultType.FORM @@ -135,7 +147,7 @@ async def test_step_user_already_configured(hass: HomeAssistant) -> None: "pynina.baseApi.BaseAPI._makeRequest", wraps=mocked_request_function, ): - result: dict[str, Any] = await hass.config_entries.flow.async_init( + await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=deepcopy(DUMMY_DATA) ) @@ -153,12 +165,13 @@ async def test_options_flow_init(hass: HomeAssistant) -> None: domain=DOMAIN, title="NINA", data={ - CONF_HEADLINE_FILTER: deepcopy(DUMMY_DATA[CONF_HEADLINE_FILTER]), - CONF_AREA_FILTER: deepcopy(DUMMY_DATA[CONF_AREA_FILTER]), + CONF_FILTERS: deepcopy(DUMMY_DATA[CONF_FILTERS]), CONF_MESSAGE_SLOTS: deepcopy(DUMMY_DATA[CONF_MESSAGE_SLOTS]), CONST_REGION_A_TO_D: deepcopy(DUMMY_DATA[CONST_REGION_A_TO_D]), CONF_REGIONS: {"095760000000": "Aach"}, }, + version=1, + minor_version=3, ) config_entry.add_to_hass(hass) @@ -186,6 +199,7 @@ async def test_options_flow_init(hass: HomeAssistant) -> None: CONST_REGION_M_TO_Q: [], CONST_REGION_R_TO_U: [], CONST_REGION_V_TO_Z: [], + CONF_FILTERS: {}, }, ) @@ -193,8 +207,7 @@ async def test_options_flow_init(hass: HomeAssistant) -> None: assert result["data"] == {} assert dict(config_entry.data) == { - CONF_HEADLINE_FILTER: deepcopy(DUMMY_DATA[CONF_HEADLINE_FILTER]), - CONF_AREA_FILTER: deepcopy(DUMMY_DATA[CONF_AREA_FILTER]), + CONF_FILTERS: deepcopy(DUMMY_DATA[CONF_FILTERS]), CONF_MESSAGE_SLOTS: deepcopy(DUMMY_DATA[CONF_MESSAGE_SLOTS]), CONST_REGION_A_TO_D: ["072350000000_1"], CONST_REGION_E_TO_H: [], @@ -214,6 +227,8 @@ async def test_options_flow_with_no_selection(hass: HomeAssistant) -> None: domain=DOMAIN, title="NINA", data=deepcopy(DUMMY_DATA), + version=1, + minor_version=3, ) config_entry.add_to_hass(hass) @@ -241,7 +256,7 @@ async def test_options_flow_with_no_selection(hass: HomeAssistant) -> None: CONST_REGION_M_TO_Q: [], CONST_REGION_R_TO_U: [], CONST_REGION_V_TO_Z: [], - CONF_HEADLINE_FILTER: "", + CONF_FILTERS: {CONF_HEADLINE_FILTER: ""}, }, ) @@ -256,6 +271,8 @@ async def test_options_flow_connection_error(hass: HomeAssistant) -> None: domain=DOMAIN, title="NINA", data=deepcopy(DUMMY_DATA), + version=1, + minor_version=3, ) config_entry.add_to_hass(hass) @@ -284,6 +301,8 @@ async def test_options_flow_unexpected_exception(hass: HomeAssistant) -> None: domain=DOMAIN, title="NINA", data=deepcopy(DUMMY_DATA), + version=1, + minor_version=3, ) config_entry.add_to_hass(hass) @@ -315,6 +334,8 @@ async def test_options_flow_entity_removal( domain=DOMAIN, title="NINA", data=deepcopy(DUMMY_DATA) | {CONF_REGIONS: {"095760000000": "Aach"}}, + version=1, + minor_version=3, ) config_entry.add_to_hass(hass) @@ -339,6 +360,7 @@ async def test_options_flow_entity_removal( CONST_REGION_M_TO_Q: [], CONST_REGION_R_TO_U: [], CONST_REGION_V_TO_Z: [], + CONF_FILTERS: {}, }, ) diff --git a/tests/components/nina/test_diagnostics.py b/tests/components/nina/test_diagnostics.py index c0646b8d68c510..9a5538bacbbf9e 100644 --- a/tests/components/nina/test_diagnostics.py +++ b/tests/components/nina/test_diagnostics.py @@ -16,8 +16,11 @@ ENTRY_DATA: dict[str, Any] = { "slots": 5, - "corona_filter": True, "regions": {"083350000000": "Aach, Stadt"}, + "filters": { + "headline_filter": ".*corona.*", + "area_filter": ".*", + }, } @@ -33,7 +36,7 @@ async def test_diagnostics( wraps=mocked_request_function, ): config_entry: MockConfigEntry = MockConfigEntry( - domain=DOMAIN, title="NINA", data=ENTRY_DATA + domain=DOMAIN, title="NINA", data=ENTRY_DATA, version=1, minor_version=3 ) config_entry.add_to_hass(hass) diff --git a/tests/components/nina/test_init.py b/tests/components/nina/test_init.py index 620b01fdeb8529..feba6110f3fb3e 100644 --- a/tests/components/nina/test_init.py +++ b/tests/components/nina/test_init.py @@ -16,9 +16,11 @@ ENTRY_DATA: dict[str, Any] = { "slots": 5, - "headline_filter": ".*corona.*", - "area_filter": ".*", "regions": {"083350000000": "Aach, Stadt"}, + "filters": { + "headline_filter": ".*corona.*", + "area_filter": ".*", + }, } @@ -30,7 +32,7 @@ async def init_integration(hass: HomeAssistant) -> MockConfigEntry: wraps=mocked_request_function, ): entry: MockConfigEntry = MockConfigEntry( - domain=DOMAIN, title="NINA", data=ENTRY_DATA + domain=DOMAIN, title="NINA", data=ENTRY_DATA, version=1, minor_version=3 ) entry.add_to_hass(hass) @@ -39,9 +41,8 @@ async def init_integration(hass: HomeAssistant) -> MockConfigEntry: return entry -async def test_config_migration(hass: HomeAssistant) -> None: +async def test_config_migration_from1_1(hass: HomeAssistant) -> None: """Test the migration to a new configuration layout.""" - old_entry_data: dict[str, Any] = { "slots": 5, "corona_filter": True, @@ -49,15 +50,70 @@ async def test_config_migration(hass: HomeAssistant) -> None: } old_conf_entry: MockConfigEntry = MockConfigEntry( - domain=DOMAIN, title="NINA", data=old_entry_data + domain=DOMAIN, title="NINA", data=old_entry_data, version=1 ) - old_conf_entry.add_to_hass(hass) + with patch( + "pynina.baseApi.BaseAPI._makeRequest", + wraps=mocked_request_function, + ): + old_conf_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(old_conf_entry.entry_id) + await hass.async_block_till_done() + + assert dict(old_conf_entry.data) == ENTRY_DATA + assert old_conf_entry.state is ConfigEntryState.LOADED + assert old_conf_entry.version == 1 + assert old_conf_entry.minor_version == 3 - await hass.config_entries.async_setup(old_conf_entry.entry_id) - await hass.async_block_till_done() - assert dict(old_conf_entry.data) == ENTRY_DATA +async def test_config_migration_from1_2(hass: HomeAssistant) -> None: + """Test the migration to a new configuration layout with sections.""" + old_entry_data: dict[str, Any] = { + "slots": 5, + "headline_filter": ".*corona.*", + "area_filter": ".*", + "regions": {"083350000000": "Aach, Stadt"}, + } + + old_conf_entry: MockConfigEntry = MockConfigEntry( + domain=DOMAIN, title="NINA", data=old_entry_data, version=1, minor_version=2 + ) + + with patch( + "pynina.baseApi.BaseAPI._makeRequest", + wraps=mocked_request_function, + ): + old_conf_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(old_conf_entry.entry_id) + await hass.async_block_till_done() + + assert dict(old_conf_entry.data) == ENTRY_DATA + assert old_conf_entry.state is ConfigEntryState.LOADED + assert old_conf_entry.version == 1 + assert old_conf_entry.minor_version == 3 + + +async def test_config_migration_downgrade(hass: HomeAssistant) -> None: + """Test the migration to an old version.""" + + conf_entry: MockConfigEntry = MockConfigEntry( + domain=DOMAIN, title="NINA", data=ENTRY_DATA, version=2 + ) + + with patch( + "pynina.baseApi.BaseAPI._makeRequest", + wraps=mocked_request_function, + ): + conf_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(conf_entry.entry_id) + await hass.async_block_till_done() + + assert dict(conf_entry.data) == ENTRY_DATA + assert conf_entry.state is ConfigEntryState.MIGRATION_ERROR async def test_config_entry_not_ready(hass: HomeAssistant) -> None: @@ -74,7 +130,7 @@ async def test_sensors_connection_error(hass: HomeAssistant) -> None: side_effect=ApiError("Could not connect to Api"), ): conf_entry: MockConfigEntry = MockConfigEntry( - domain=DOMAIN, title="NINA", data=ENTRY_DATA + domain=DOMAIN, title="NINA", data=ENTRY_DATA, version=1, minor_version=3 ) conf_entry.add_to_hass(hass) diff --git a/tests/components/shelly/fixtures/fk-06x_gen3_irrigation.json b/tests/components/shelly/fixtures/fk-06x_gen3_irrigation.json new file mode 100644 index 00000000000000..0e2e7c835fbae8 --- /dev/null +++ b/tests/components/shelly/fixtures/fk-06x_gen3_irrigation.json @@ -0,0 +1,436 @@ +{ + "config": { + "ble": { + "enable": false, + "rpc": { + "enable": false + } + }, + "boolean:200": { + "access": "crw", + "default_value": false, + "id": 200, + "meta": { + "cloud": ["log"], + "svc": { + "dpId": 101 + }, + "ui": { + "view": "toggle" + } + }, + "name": "Zone 1", + "owner": "service:0", + "persisted": false, + "role": "zone0" + }, + "boolean:201": { + "access": "crw", + "default_value": false, + "id": 201, + "meta": { + "cloud": ["log"], + "svc": { + "dpId": 102 + }, + "ui": { + "view": "toggle" + } + }, + "name": "Zone 2", + "owner": "service:0", + "persisted": false, + "role": "zone1" + }, + "boolean:202": { + "access": "crw", + "default_value": false, + "id": 202, + "meta": { + "cloud": ["log"], + "svc": { + "dpId": 103 + }, + "ui": { + "view": "toggle" + } + }, + "name": "Zone 3", + "owner": "service:0", + "persisted": false, + "role": "zone2" + }, + "boolean:203": { + "access": "crw", + "default_value": false, + "id": 203, + "meta": { + "cloud": ["log"], + "svc": { + "dpId": 104 + }, + "ui": { + "view": "toggle" + } + }, + "name": "Zone 4", + "owner": "service:0", + "persisted": false, + "role": "zone3" + }, + "boolean:204": { + "access": "crw", + "default_value": false, + "id": 204, + "meta": { + "cloud": ["log"], + "svc": { + "dpId": 105 + }, + "ui": { + "view": "toggle" + } + }, + "name": "Zone 5", + "owner": "service:0", + "persisted": false, + "role": "zone4" + }, + "boolean:205": { + "access": "crw", + "default_value": false, + "id": 205, + "meta": { + "cloud": ["log"], + "svc": { + "dpId": 106 + }, + "ui": { + "view": "toggle" + } + }, + "name": "Zone 6", + "owner": "service:0", + "persisted": false, + "role": "zone5" + }, + "bthome": {}, + "cloud": { + "enable": false, + "server": "wss://repo.shelly.cloud:6022/jrpc" + }, + "enum:200": { + "access": "crw", + "default_value": "none", + "id": 200, + "meta": { + "ui": { + "titles": { + "none": "None", + "seq_0": "All" + }, + "view": "select" + } + }, + "name": "Active Sequence", + "options": ["none", "seq_0"], + "owner": "service:0", + "persisted": false, + "role": "active_sequence" + }, + "mqtt": { + "client_id": "irrigation-aabbccddeeff", + "enable": false, + "enable_control": true, + "enable_rpc": true, + "rpc_ntf": true, + "server": "mqtt.test.server", + "ssl_ca": null, + "status_ntf": false, + "topic_prefix": "irrigation-aabbccddeeff", + "use_client_cert": false, + "user": null + }, + "number:200": { + "access": "cr", + "default_value": 0, + "id": 200, + "max": 100, + "meta": { + "cloud": ["measurement", "log"], + "ui": { + "unit": "°C", + "view": "label" + } + }, + "min": -50, + "name": "Average Temperature", + "owner": "service:0", + "persisted": false, + "role": "average_temperature" + }, + "number:201": { + "access": "cr", + "default_value": 0, + "id": 201, + "max": 99999, + "meta": { + "cloud": ["measurement", "log"], + "ui": { + "unit": "mm/m2", + "view": "label" + } + }, + "min": 0, + "name": "Rainfall last 24h", + "owner": "service:0", + "persisted": false, + "role": "last_precipitation" + }, + "object:200": { + "access": "crw", + "id": 200, + "meta": null, + "name": "Zones status", + "owner": "service:0", + "role": "zones_status" + }, + "service:0": { + "base_temp": 20, + "duration_offset": 0, + "id": 0, + "limit_zones": 2, + "seq_rev": 11, + "weather_api": true, + "zones": [ + { + "duration": 10, + "name": "Zone Name 1", + "water_amount": 10 + }, + { + "duration": 11, + "name": "Zone Name 2", + "water_amount": 10 + }, + { + "duration": 10, + "name": "Zone Name 3", + "water_amount": 10 + }, + { + "duration": 10, + "name": null, + "water_amount": 10 + }, + { + "duration": 10, + "name": null, + "water_amount": 10 + }, + { + "duration": 10, + "name": null, + "water_amount": 10 + } + ] + }, + "sys": { + "cfg_rev": 46, + "debug": { + "file_level": null, + "level": 2, + "mqtt": { + "enable": false + }, + "udp": { + "addr": null + }, + "websocket": { + "enable": false + } + }, + "device": { + "discoverable": true, + "eco_mode": false, + "fw_id": "20241121-103618/1.4.99-xt-prod1-gc6448fb", + "mac": "AABBCCDDEEFF", + "name": "Test Name" + }, + "location": { + "lat": 32.1772, + "lon": 34.9039, + "tz": "Asia/Tel_Aviv" + }, + "rpc_udp": { + "dst_addr": null, + "listen_port": null + }, + "sntp": { + "server": "sntp.test.server" + }, + "ui_data": {} + }, + "wifi": { + "sta": { + "ssid": "Wifi-Network-Name", + "is_open": false, + "enable": true, + "ipv4mode": "dhcp", + "ip": null, + "netmask": null, + "gw": null, + "nameserver": null + } + }, + "ws": { + "enable": false, + "server": null, + "ssl_ca": "ca.pem" + } + }, + "shelly": { + "app": "XT1", + "auth_domain": null, + "auth_en": false, + "fw_id": "20241121-103618/1.4.99-xt-prod1-gc6448fb", + "gen": 3, + "id": "irrigation-aabbccddeeff", + "jti": "0000C100000A", + "jwt": { + "aud": "XT1", + "f": 1, + "iat": 1739274795, + "jti": "0000C100000A", + "n": "Irrigation controller FK-06X", + "p": "Irrigation", + "url": "https://frankever.com/", + "v": 1, + "xt1": { + "svc0": { + "type": "irrigation-controller" + } + } + }, + "mac": "AABBCCDDEEFF", + "model": "S3XT-0S", + "name": "Test Name", + "slot": 0, + "svc0": { + "build_id": "20250305-144715/c28f621", + "type": "irrigation-controller", + "ver": "0.7.0-irrigation-prod0" + }, + "ver": "1.4.99-xt-prod1" + }, + "status": { + "ble": {}, + "boolean:200": { + "value": false + }, + "boolean:201": { + "value": false + }, + "boolean:202": { + "value": false + }, + "boolean:203": { + "value": false + }, + "boolean:204": { + "value": false + }, + "boolean:205": { + "value": false + }, + "bthome": { + "errors": ["bluetooth_disabled"] + }, + "cloud": { + "connected": false + }, + "enum:200": { + "value": "none" + }, + "mqtt": { + "connected": false + }, + "number:200": { + "value": 0 + }, + "number:201": { + "value": 0 + }, + "object:200": { + "value": { + "zone0": { + "duration": 10, + "source": "rpc", + "started_at": 1757947186153.368 + }, + "zone1": { + "duration": 11, + "source": "rpc", + "started_at": 1757947188242.7039 + }, + "zone2": { + "duration": 10, + "source": "init", + "started_at": null + }, + "zone3": { + "duration": 10, + "source": "init", + "started_at": null + }, + "zone4": { + "duration": 10, + "source": "init", + "started_at": null + }, + "zone5": { + "duration": 10, + "source": "rpc", + "started_at": 1757947190878.225 + } + } + }, + "service:0": { + "etag": "56436ed1f373df7e91e8c79588ccbcb0", + "state": "running", + "stats": { + "mem": 1363, + "mem_peak": 1590 + } + }, + "sys": { + "available_updates": {}, + "btrelay_rev": 0, + "cfg_rev": 46, + "fs_free": 565248, + "fs_size": 1048576, + "kvs_rev": 1, + "last_sync_ts": 1757950062, + "mac": "AABBCCDDEEFF", + "ram_free": 103824, + "ram_min_free": 83204, + "ram_size": 252672, + "reset_reason": 1, + "restart_required": false, + "schedule_rev": 17, + "time": "18:32", + "unixtime": 1757950362, + "uptime": 1080367, + "webhook_rev": 1 + }, + "wifi": { + "rssi": -38, + "ssid": "Wifi-Network-Name", + "sta_ip": "192.168.2.24", + "status": "got ip" + }, + "ws": { + "connected": false + } + } +} diff --git a/tests/components/shelly/snapshots/test_sensor.ambr b/tests/components/shelly/snapshots/test_sensor.ambr index 93be3c9103e712..62d29d4b317987 100644 --- a/tests/components/shelly/snapshots/test_sensor.ambr +++ b/tests/components/shelly/snapshots/test_sensor.ambr @@ -536,6 +536,65 @@ 'state': '5.0', }) # --- +# name: test_rpc_switch_energy_sensors[sensor.test_name_test_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_test_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': 'test switch_0 energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:0-energy', + 'unit_of_measurement': , + }) +# --- +# name: test_rpc_switch_energy_sensors[sensor.test_name_test_switch_0_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name test switch_0 energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_test_switch_0_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1234.56789', + }) +# --- # name: test_rpc_switch_energy_sensors[sensor.test_name_test_switch_0_energy_consumed-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -654,14 +713,12 @@ 'state': '98.76543', }) # --- -# name: test_rpc_switch_energy_sensors[sensor.test_name_test_switch_0_energy-entry] +# name: test_shelly_irrigation_weather_sensors[sensor.test_name_average_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), + 'capabilities': None, 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -669,7 +726,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.test_name_test_switch_0_energy', + 'entity_id': 'sensor.test_name_average_temperature', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -679,37 +736,86 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 1, }), - 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Average Temperature', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-number:200-number_average_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_irrigation_weather_sensors[sensor.test_name_average_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test name Average Temperature', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_average_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_shelly_irrigation_weather_sensors[sensor.test_name_rainfall_last_24h-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.test_name_rainfall_last_24h', + '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_device_class': , 'original_icon': None, - 'original_name': 'test switch_0 energy', + 'original_name': 'Rainfall last 24h', 'platform': 'shelly', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456789ABC-switch:0-energy', - 'unit_of_measurement': , + 'unique_id': '123456789ABC-number:201-number_last_precipitation', + 'unit_of_measurement': , }) # --- -# name: test_rpc_switch_energy_sensors[sensor.test_name_test_switch_0_energy-state] +# name: test_shelly_irrigation_weather_sensors[sensor.test_name_rainfall_last_24h-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Test name test switch_0 energy', - 'state_class': , - 'unit_of_measurement': , + 'device_class': 'precipitation', + 'friendly_name': 'Test name Rainfall last 24h', + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.test_name_test_switch_0_energy', + 'entity_id': 'sensor.test_name_rainfall_last_24h', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1234.56789', + 'state': '0', }) # --- diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index 3aace4a88170dc..6c3afe1e70cab4 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -53,7 +53,11 @@ register_entity, ) -from tests.common import async_fire_time_changed, mock_restore_cache_with_extra_data +from tests.common import ( + async_fire_time_changed, + async_load_json_object_fixture, + mock_restore_cache_with_extra_data, +) RELAY_BLOCK_ID = 0 SENSOR_BLOCK_ID = 3 @@ -1994,3 +1998,39 @@ async def test_cury_sensor_entity( entry = entity_registry.async_get(entity_id) assert entry == snapshot(name=f"{entity_id}-entry") + + +async def test_shelly_irrigation_weather_sensors( + hass: HomeAssistant, + mock_rpc_device: Mock, + entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test Shelly Irrigation controller FK-06X weather sensors.""" + device_fixture = await async_load_json_object_fixture( + hass, "fk-06x_gen3_irrigation.json", DOMAIN + ) + monkeypatch.setattr(mock_rpc_device, "shelly", device_fixture["shelly"]) + monkeypatch.setattr(mock_rpc_device, "status", device_fixture["status"]) + monkeypatch.setattr(mock_rpc_device, "config", device_fixture["config"]) + + config_entry = await init_integration(hass, gen=3) + + for entity in ("average_temperature", "rainfall_last_24h"): + entity_id = f"{SENSOR_DOMAIN}.test_name_{entity}" + + state = hass.states.get(entity_id) + assert state == snapshot(name=f"{entity_id}-state") + + entry = entity_registry.async_get(entity_id) + assert entry == snapshot(name=f"{entity_id}-entry") + + # weather api disabled + monkeypatch.setitem(mock_rpc_device.config["service:0"], "weather_api", False) + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + for entity in ("average_temperature", "rainfall_last_24h"): + entity_id = f"{SENSOR_DOMAIN}.test_name_{entity}" + assert hass.states.get(entity_id) is None