From ee1b6a60a048a343cc31797fce01eca90dfe4965 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 23 Aug 2023 19:13:24 +0200 Subject: [PATCH 001/124] Deduplicate group preview tests (#98883) --- tests/components/group/test_config_flow.py | 239 +++++++-------------- 1 file changed, 79 insertions(+), 160 deletions(-) diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index ce4bad2ac8a..ad084786366 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Switch config flow.""" +from typing import Any from unittest.mock import patch import pytest @@ -449,13 +450,32 @@ async def test_options_flow_hides_members( assert registry.async_get(f"{group_type}.three").hidden_by == hidden_by -async def test_config_flow_binary_sensor_preview( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator +@pytest.mark.parametrize( + ("domain", "extra_user_input", "input_states", "group_state", "extra_attributes"), + [ + ("binary_sensor", {"all": True}, ["on", "off"], "off", [{}, {}]), + ( + "sensor", + {"type": "max"}, + ["10", "20"], + "20.0", + [{"icon": "mdi:calculator"}, {"max_entity_id": "sensor.input_two"}], + ), + ], +) +async def test_config_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + domain: str, + extra_user_input: dict[str, Any], + input_states: list[str], + group_state: str, + extra_attributes: list[dict[str, Any]], ) -> None: """Test the config flow preview.""" client = await hass_ws_client(hass) - input_entities = ["binary_sensor.input_one", "binary_sensor.input_two"] + input_entities = [f"{domain}.input_one", f"{domain}.input_two"] result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -464,24 +484,21 @@ async def test_config_flow_binary_sensor_preview( result = await hass.config_entries.flow.async_configure( result["flow_id"], - {"next_step_id": "binary_sensor"}, + {"next_step_id": domain}, ) await hass.async_block_till_done() assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "binary_sensor" + assert result["step_id"] == domain assert result["errors"] is None - assert result["preview"] == "group_binary_sensor" + assert result["preview"] == f"group_{domain}" await client.send_json_auto_id( { - "type": "group/binary_sensor/start_preview", + "type": f"group/{domain}/start_preview", "flow_id": result["flow_id"], "flow_type": "config_flow", - "user_input": { - "name": "My binary sensor group", - "entities": input_entities, - "all": True, - }, + "user_input": {"name": "My group", "entities": input_entities} + | extra_user_input, } ) msg = await client.receive_json() @@ -490,151 +507,60 @@ async def test_config_flow_binary_sensor_preview( msg = await client.receive_json() assert msg["event"] == { - "attributes": {"friendly_name": "My binary sensor group"}, + "attributes": {"friendly_name": "My group"} | extra_attributes[0], "state": "unavailable", } - hass.states.async_set("binary_sensor.input_one", "on") - hass.states.async_set("binary_sensor.input_two", "off") - - msg = await client.receive_json() - assert msg["event"] == { - "attributes": { - "entity_id": ["binary_sensor.input_one", "binary_sensor.input_two"], - "friendly_name": "My binary sensor group", - }, - "state": "off", - } - - -async def test_option_flow_binary_sensor_preview( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator -) -> None: - """Test the option flow preview.""" - client = await hass_ws_client(hass) - - input_entities = ["binary_sensor.input_one", "binary_sensor.input_two"] - - # Setup the config entry - config_entry = MockConfigEntry( - data={}, - domain=DOMAIN, - options={ - "all": True, - "entities": input_entities, - "group_type": "binary_sensor", - "hide_members": False, - "name": "My group", - }, - title="My min_max", - ) - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == FlowResultType.FORM - assert result["errors"] is None - assert result["preview"] == "group_binary_sensor" - - hass.states.async_set("binary_sensor.input_one", "on") - hass.states.async_set("binary_sensor.input_two", "off") - - await client.send_json_auto_id( - { - "type": "group/binary_sensor/start_preview", - "flow_id": result["flow_id"], - "flow_type": "options_flow", - "user_input": { - "entities": input_entities, - "all": False, - }, - } - ) - msg = await client.receive_json() - assert msg["success"] - assert msg["result"] is None + hass.states.async_set(input_entities[0], input_states[0]) + hass.states.async_set(input_entities[1], input_states[1]) msg = await client.receive_json() assert msg["event"] == { "attributes": { "entity_id": input_entities, "friendly_name": "My group", - }, - "state": "on", - } - - -async def test_config_flow_sensor_preview( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator -) -> None: - """Test the config flow preview.""" - client = await hass_ws_client(hass) - - input_entities = ["sensor.input_one", "sensor.input_two"] - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == FlowResultType.MENU - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"next_step_id": "sensor"}, - ) - await hass.async_block_till_done() - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "sensor" - assert result["errors"] is None - assert result["preview"] == "group_sensor" - - await client.send_json_auto_id( - { - "type": "group/sensor/start_preview", - "flow_id": result["flow_id"], - "flow_type": "config_flow", - "user_input": { - "name": "My sensor group", - "entities": input_entities, - "type": "max", - }, } - ) - msg = await client.receive_json() - assert msg["success"] - assert msg["result"] is None - - msg = await client.receive_json() - assert msg["event"] == { - "attributes": { - "friendly_name": "My sensor group", - "icon": "mdi:calculator", - }, - "state": "unavailable", - } - - hass.states.async_set("sensor.input_one", "10") - hass.states.async_set("sensor.input_two", "20") - - msg = await client.receive_json() - assert msg["event"] == { - "attributes": { - "entity_id": input_entities, - "friendly_name": "My sensor group", - "icon": "mdi:calculator", - "max_entity_id": "sensor.input_two", - }, - "state": "20.0", + | extra_attributes[0] + | extra_attributes[1], + "state": group_state, } -async def test_option_flow_sensor_preview( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator +@pytest.mark.parametrize( + ( + "domain", + "extra_config_flow_data", + "extra_user_input", + "input_states", + "group_state", + "extra_attributes", + ), + [ + ("binary_sensor", {"all": True}, {"all": False}, ["on", "off"], "on", {}), + ( + "sensor", + {"type": "min"}, + {"type": "max"}, + ["10", "20"], + "20.0", + {"icon": "mdi:calculator", "max_entity_id": "sensor.input_two"}, + ), + ], +) +async def test_option_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + domain: str, + extra_config_flow_data: dict[str, Any], + extra_user_input: dict[str, Any], + input_states: list[str], + group_state: str, + extra_attributes: dict[str, Any], ) -> None: """Test the option flow preview.""" client = await hass_ws_client(hass) - input_entities = ["sensor.input_one", "sensor.input_two"] + input_entities = [f"{domain}.input_one", f"{domain}.input_two"] # Setup the config entry config_entry = MockConfigEntry( @@ -642,12 +568,12 @@ async def test_option_flow_sensor_preview( domain=DOMAIN, options={ "entities": input_entities, - "group_type": "sensor", + "group_type": domain, "hide_members": False, - "name": "My sensor group", - "type": "min", - }, - title="My min_max", + "name": "My group", + } + | extra_config_flow_data, + title="My group", ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -656,20 +582,17 @@ async def test_option_flow_sensor_preview( result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] == FlowResultType.FORM assert result["errors"] is None - assert result["preview"] == "group_sensor" + assert result["preview"] == f"group_{domain}" - hass.states.async_set("sensor.input_one", "10") - hass.states.async_set("sensor.input_two", "20") + hass.states.async_set(input_entities[0], input_states[0]) + hass.states.async_set(input_entities[1], input_states[1]) await client.send_json_auto_id( { - "type": "group/sensor/start_preview", + "type": f"group/{domain}/start_preview", "flow_id": result["flow_id"], "flow_type": "options_flow", - "user_input": { - "entities": input_entities, - "type": "min", - }, + "user_input": {"entities": input_entities} | extra_user_input, } ) msg = await client.receive_json() @@ -678,13 +601,9 @@ async def test_option_flow_sensor_preview( msg = await client.receive_json() assert msg["event"] == { - "attributes": { - "entity_id": input_entities, - "friendly_name": "My sensor group", - "icon": "mdi:calculator", - "min_entity_id": "sensor.input_one", - }, - "state": "10.0", + "attributes": {"entity_id": input_entities, "friendly_name": "My group"} + | extra_attributes, + "state": group_state, } From 3c10d0e1f7b30cb3b7473bb3f9f665cd195d10db Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 23 Aug 2023 19:20:58 +0200 Subject: [PATCH 002/124] Deduplicate entities derived from GroupEntity (#98893) --- homeassistant/components/group/__init__.py | 62 ++++++++++++++++++- .../components/group/binary_sensor.py | 50 +-------------- homeassistant/components/group/cover.py | 45 ++------------ homeassistant/components/group/fan.py | 41 ++---------- homeassistant/components/group/light.py | 25 +------- homeassistant/components/group/lock.py | 25 +------- homeassistant/components/group/sensor.py | 54 +--------------- homeassistant/components/group/switch.py | 25 +------- 8 files changed, 78 insertions(+), 249 deletions(-) diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 33df9822ac2..ef011c4308a 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from abc import abstractmethod import asyncio -from collections.abc import Collection, Iterable +from collections.abc import Callable, Collection, Iterable, Mapping from contextvars import ContextVar import logging from typing import Any, Protocol, cast @@ -473,9 +473,60 @@ class GroupEntity(Entity): """Representation of a Group of entities.""" _attr_should_poll = False + _entity_ids: list[str] + + @callback + def async_start_preview( + self, + preview_callback: Callable[[str, Mapping[str, Any]], None], + ) -> CALLBACK_TYPE: + """Render a preview.""" + + for entity_id in self._entity_ids: + if (state := self.hass.states.get(entity_id)) is None: + continue + self.async_update_supported_features(entity_id, state) + + @callback + def async_state_changed_listener( + event: EventType[EventStateChangedData] | None, + ) -> None: + """Handle child updates.""" + self.async_update_group_state() + if event: + self.async_update_supported_features( + event.data["entity_id"], event.data["new_state"] + ) + preview_callback(*self._async_generate_attributes()) + + async_state_changed_listener(None) + return async_track_state_change_event( + self.hass, self._entity_ids, async_state_changed_listener + ) async def async_added_to_hass(self) -> None: """Register listeners.""" + for entity_id in self._entity_ids: + if (state := self.hass.states.get(entity_id)) is None: + continue + self.async_update_supported_features(entity_id, state) + + @callback + def async_state_changed_listener( + event: EventType[EventStateChangedData], + ) -> None: + """Handle child updates.""" + self.async_set_context(event.context) + self.async_update_supported_features( + event.data["entity_id"], event.data["new_state"] + ) + self.async_defer_or_update_ha_state() + + self.async_on_remove( + async_track_state_change_event( + self.hass, self._entity_ids, async_state_changed_listener + ) + ) async def _update_at_start(_: HomeAssistant) -> None: self.async_update_group_state() @@ -493,9 +544,18 @@ class GroupEntity(Entity): self.async_write_ha_state() @abstractmethod + @callback def async_update_group_state(self) -> None: """Abstract method to update the entity.""" + @callback + def async_update_supported_features( + self, + entity_id: str, + new_state: State | None, + ) -> None: + """Update dictionaries with supported features.""" + class Group(Entity): """Track a group of entity ids.""" diff --git a/homeassistant/components/group/binary_sensor.py b/homeassistant/components/group/binary_sensor.py index 105b1b95b1d..53bf1affe00 100644 --- a/homeassistant/components/group/binary_sensor.py +++ b/homeassistant/components/group/binary_sensor.py @@ -1,9 +1,6 @@ """Platform allowing several binary sensor to be grouped into one binary sensor.""" from __future__ import annotations -from collections.abc import Callable, Mapping -from typing import Any - import voluptuous as vol from homeassistant.components.binary_sensor import ( @@ -24,14 +21,10 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import ( - EventStateChangedData, - async_track_state_change_event, -) -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import GroupEntity @@ -116,45 +109,6 @@ class BinarySensorGroup(GroupEntity, BinarySensorEntity): if mode: self.mode = all - @callback - def async_start_preview( - self, - preview_callback: Callable[[str, Mapping[str, Any]], None], - ) -> CALLBACK_TYPE: - """Render a preview.""" - - @callback - def async_state_changed_listener( - event: EventType[EventStateChangedData] | None, - ) -> None: - """Handle child updates.""" - self.async_update_group_state() - preview_callback(*self._async_generate_attributes()) - - async_state_changed_listener(None) - return async_track_state_change_event( - self.hass, self._entity_ids, async_state_changed_listener - ) - - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - - @callback - def async_state_changed_listener( - event: EventType[EventStateChangedData], - ) -> None: - """Handle child updates.""" - self.async_set_context(event.context) - self.async_defer_or_update_ha_state() - - self.async_on_remove( - async_track_state_change_event( - self.hass, self._entity_ids, async_state_changed_listener - ) - ) - - await super().async_added_to_hass() - @callback def async_update_group_state(self) -> None: """Query all members and determine the binary sensor group state.""" diff --git a/homeassistant/components/group/cover.py b/homeassistant/components/group/cover.py index 784ac9a94af..0fe67a9bccd 100644 --- a/homeassistant/components/group/cover.py +++ b/homeassistant/components/group/cover.py @@ -41,11 +41,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import ( - EventStateChangedData, - async_track_state_change_event, -) -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import GroupEntity from .util import attribute_equal, reduce_attribute @@ -112,7 +108,7 @@ class CoverGroup(GroupEntity, CoverEntity): def __init__(self, unique_id: str | None, name: str, entities: list[str]) -> None: """Initialize a CoverGroup entity.""" - self._entities = entities + self._entity_ids = entities self._covers: dict[str, set[str]] = { KEY_OPEN_CLOSE: set(), KEY_STOP: set(), @@ -128,21 +124,11 @@ class CoverGroup(GroupEntity, CoverEntity): self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entities} self._attr_unique_id = unique_id - @callback - def _update_supported_features_event( - self, event: EventType[EventStateChangedData] - ) -> None: - self.async_set_context(event.context) - self.async_update_supported_features( - event.data["entity_id"], event.data["new_state"] - ) - @callback def async_update_supported_features( self, entity_id: str, new_state: State | None, - update_state: bool = True, ) -> None: """Update dictionaries with supported features.""" if not new_state: @@ -150,8 +136,6 @@ class CoverGroup(GroupEntity, CoverEntity): values.discard(entity_id) for values in self._tilts.values(): values.discard(entity_id) - if update_state: - self.async_defer_or_update_ha_state() return features = new_state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) @@ -182,25 +166,6 @@ class CoverGroup(GroupEntity, CoverEntity): else: self._tilts[KEY_POSITION].discard(entity_id) - if update_state: - self.async_defer_or_update_ha_state() - - async def async_added_to_hass(self) -> None: - """Register listeners.""" - for entity_id in self._entities: - if (new_state := self.hass.states.get(entity_id)) is None: - continue - self.async_update_supported_features( - entity_id, new_state, update_state=False - ) - self.async_on_remove( - async_track_state_change_event( - self.hass, self._entities, self._update_supported_features_event - ) - ) - - await super().async_added_to_hass() - async def async_open_cover(self, **kwargs: Any) -> None: """Move the covers up.""" data = {ATTR_ENTITY_ID: self._covers[KEY_OPEN_CLOSE]} @@ -278,7 +243,7 @@ class CoverGroup(GroupEntity, CoverEntity): states = [ state.state - for entity_id in self._entities + for entity_id in self._entity_ids if (state := self.hass.states.get(entity_id)) is not None ] @@ -292,7 +257,7 @@ class CoverGroup(GroupEntity, CoverEntity): self._attr_is_closed = True self._attr_is_closing = False self._attr_is_opening = False - for entity_id in self._entities: + for entity_id in self._entity_ids: if not (state := self.hass.states.get(entity_id)): continue if state.state == STATE_OPEN: @@ -347,7 +312,7 @@ class CoverGroup(GroupEntity, CoverEntity): self._attr_supported_features = supported_features if not self._attr_assumed_state: - for entity_id in self._entities: + for entity_id in self._entity_ids: if (state := self.hass.states.get(entity_id)) is None: continue if state and state.attributes.get(ATTR_ASSUMED_STATE): diff --git a/homeassistant/components/group/fan.py b/homeassistant/components/group/fan.py index 1fcb859f926..79ce6fe0d87 100644 --- a/homeassistant/components/group/fan.py +++ b/homeassistant/components/group/fan.py @@ -38,11 +38,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import ( - EventStateChangedData, - async_track_state_change_event, -) -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import GroupEntity from .util import ( @@ -108,7 +104,7 @@ class FanGroup(GroupEntity, FanEntity): def __init__(self, unique_id: str | None, name: str, entities: list[str]) -> None: """Initialize a FanGroup entity.""" - self._entities = entities + self._entity_ids = entities self._fans: dict[int, set[str]] = {flag: set() for flag in SUPPORTED_FLAGS} self._percentage = None self._oscillating = None @@ -144,21 +140,11 @@ class FanGroup(GroupEntity, FanEntity): """Return whether or not the fan is currently oscillating.""" return self._oscillating - @callback - def _update_supported_features_event( - self, event: EventType[EventStateChangedData] - ) -> None: - self.async_set_context(event.context) - self.async_update_supported_features( - event.data["entity_id"], event.data["new_state"] - ) - @callback def async_update_supported_features( self, entity_id: str, new_state: State | None, - update_state: bool = True, ) -> None: """Update dictionaries with supported features.""" if not new_state: @@ -172,25 +158,6 @@ class FanGroup(GroupEntity, FanEntity): else: self._fans[feature].discard(entity_id) - if update_state: - self.async_defer_or_update_ha_state() - - async def async_added_to_hass(self) -> None: - """Register listeners.""" - for entity_id in self._entities: - if (new_state := self.hass.states.get(entity_id)) is None: - continue - self.async_update_supported_features( - entity_id, new_state, update_state=False - ) - self.async_on_remove( - async_track_state_change_event( - self.hass, self._entities, self._update_supported_features_event - ) - ) - - await super().async_added_to_hass() - async def async_set_percentage(self, percentage: int) -> None: """Set the speed of the fan, as a percentage.""" if percentage == 0: @@ -250,7 +217,7 @@ class FanGroup(GroupEntity, FanEntity): await self.hass.services.async_call( DOMAIN, service, - {ATTR_ENTITY_ID: self._entities}, + {ATTR_ENTITY_ID: self._entity_ids}, blocking=True, context=self._context, ) @@ -275,7 +242,7 @@ class FanGroup(GroupEntity, FanEntity): states = [ state - for entity_id in self._entities + for entity_id in self._entity_ids if (state := self.hass.states.get(entity_id)) is not None ] self._attr_assumed_state |= not states_equal(states) diff --git a/homeassistant/components/group/light.py b/homeassistant/components/group/light.py index e0f7974631b..c6369d876a4 100644 --- a/homeassistant/components/group/light.py +++ b/homeassistant/components/group/light.py @@ -47,11 +47,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import ( - EventStateChangedData, - async_track_state_change_event, -) -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import GroupEntity from .util import find_state_attributes, mean_tuple, reduce_attribute @@ -153,25 +149,6 @@ class LightGroup(GroupEntity, LightEntity): if mode: self.mode = all - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - - @callback - def async_state_changed_listener( - event: EventType[EventStateChangedData], - ) -> None: - """Handle child updates.""" - self.async_set_context(event.context) - self.async_defer_or_update_ha_state() - - self.async_on_remove( - async_track_state_change_event( - self.hass, self._entity_ids, async_state_changed_listener - ) - ) - - await super().async_added_to_hass() - async def async_turn_on(self, **kwargs: Any) -> None: """Forward the turn_on command to all lights in the light group.""" data = { diff --git a/homeassistant/components/group/lock.py b/homeassistant/components/group/lock.py index 233d1155c53..ec0ff13ee15 100644 --- a/homeassistant/components/group/lock.py +++ b/homeassistant/components/group/lock.py @@ -31,11 +31,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import ( - EventStateChangedData, - async_track_state_change_event, -) -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import GroupEntity @@ -114,25 +110,6 @@ class LockGroup(GroupEntity, LockEntity): self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids} self._attr_unique_id = unique_id - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - - @callback - def async_state_changed_listener( - event: EventType[EventStateChangedData], - ) -> None: - """Handle child updates.""" - self.async_set_context(event.context) - self.async_defer_or_update_ha_state() - - self.async_on_remove( - async_track_state_change_event( - self.hass, self._entity_ids, async_state_changed_listener - ) - ) - - await super().async_added_to_hass() - async def async_lock(self, **kwargs: Any) -> None: """Forward the lock command to all locks in the group.""" data = {ATTR_ENTITY_ID: self._entity_ids} diff --git a/homeassistant/components/group/sensor.py b/homeassistant/components/group/sensor.py index 48175b55358..57ada314707 100644 --- a/homeassistant/components/group/sensor.py +++ b/homeassistant/components/group/sensor.py @@ -1,7 +1,7 @@ """Platform allowing several sensors to be grouped into one sensor to provide numeric combinations.""" from __future__ import annotations -from collections.abc import Callable, Mapping +from collections.abc import Callable from datetime import datetime import logging import statistics @@ -33,19 +33,10 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback +from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import ( - EventStateChangedData, - async_track_state_change_event, -) -from homeassistant.helpers.typing import ( - ConfigType, - DiscoveryInfoType, - EventType, - StateType, -) +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType from . import GroupEntity from .const import CONF_IGNORE_NON_NUMERIC @@ -303,45 +294,6 @@ class SensorGroup(GroupEntity, SensorEntity): self._state_incorrect: set[str] = set() self._extra_state_attribute: dict[str, Any] = {} - @callback - def async_start_preview( - self, - preview_callback: Callable[[str, Mapping[str, Any]], None], - ) -> CALLBACK_TYPE: - """Render a preview.""" - - @callback - def async_state_changed_listener( - event: EventType[EventStateChangedData] | None, - ) -> None: - """Handle child updates.""" - self.async_update_group_state() - preview_callback(*self._async_generate_attributes()) - - async_state_changed_listener(None) - return async_track_state_change_event( - self.hass, self._entity_ids, async_state_changed_listener - ) - - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - - @callback - def async_state_changed_listener( - event: EventType[EventStateChangedData], - ) -> None: - """Handle child updates.""" - self.async_set_context(event.context) - self.async_defer_or_update_ha_state() - - self.async_on_remove( - async_track_state_change_event( - self.hass, self._entity_ids, async_state_changed_listener - ) - ) - - await super().async_added_to_hass() - @callback def async_update_group_state(self) -> None: """Query all members and determine the sensor group state.""" diff --git a/homeassistant/components/group/switch.py b/homeassistant/components/group/switch.py index f62c805ba1d..bef42824d86 100644 --- a/homeassistant/components/group/switch.py +++ b/homeassistant/components/group/switch.py @@ -22,11 +22,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import ( - EventStateChangedData, - async_track_state_change_event, -) -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import GroupEntity @@ -112,25 +108,6 @@ class SwitchGroup(GroupEntity, SwitchEntity): if mode: self.mode = all - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - - @callback - def async_state_changed_listener( - event: EventType[EventStateChangedData], - ) -> None: - """Handle child updates.""" - self.async_set_context(event.context) - self.async_defer_or_update_ha_state() - - self.async_on_remove( - async_track_state_change_event( - self.hass, self._entity_ids, async_state_changed_listener - ) - ) - - await super().async_added_to_hass() - async def async_turn_on(self, **kwargs: Any) -> None: """Forward the turn_on command to all switches in the group.""" data = {ATTR_ENTITY_ID: self._entity_ids} From 22c1ddef713754b56d8cfa7ee53bdf0898faa592 Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Wed, 23 Aug 2023 12:45:49 -0500 Subject: [PATCH 003/124] Enable strict typing for ipp (#98792) enable strict typing for ipp --- .strict-typing | 1 + homeassistant/components/ipp/config_flow.py | 4 ++-- mypy.ini | 10 ++++++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.strict-typing b/.strict-typing index 5ecdc54826b..41138c812ec 100644 --- a/.strict-typing +++ b/.strict-typing @@ -183,6 +183,7 @@ homeassistant.components.imap.* homeassistant.components.input_button.* homeassistant.components.input_select.* homeassistant.components.integration.* +homeassistant.components.ipp.* homeassistant.components.iqvia.* homeassistant.components.isy994.* homeassistant.components.jellyfin.* diff --git a/homeassistant/components/ipp/config_flow.py b/homeassistant/components/ipp/config_flow.py index a00190eebce..8d1da6eca91 100644 --- a/homeassistant/components/ipp/config_flow.py +++ b/homeassistant/components/ipp/config_flow.py @@ -59,9 +59,9 @@ class IPPFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Set up the instance.""" - self.discovery_info = {} + self.discovery_info: dict[str, Any] = {} async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/mypy.ini b/mypy.ini index 883a5ec2f26..a4bf83dbf27 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1592,6 +1592,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.ipp.*] +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.iqvia.*] check_untyped_defs = true disallow_incomplete_defs = true From 39992c2ccc6cb31f4e971f18cfab5835b3880a52 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 23 Aug 2023 20:20:08 +0200 Subject: [PATCH 004/124] Migrate BSB-Lan diagnostics test to snapshot assertion (#98899) Migrate bsblan diagnostics test to snapshot assertion --- .../bsblan/fixtures/diagnostics.json | 75 ------------------ .../bsblan/snapshots/test_diagnostics.ambr | 78 +++++++++++++++++++ tests/components/bsblan/test_diagnostics.py | 10 +-- 3 files changed, 83 insertions(+), 80 deletions(-) delete mode 100644 tests/components/bsblan/fixtures/diagnostics.json create mode 100644 tests/components/bsblan/snapshots/test_diagnostics.ambr diff --git a/tests/components/bsblan/fixtures/diagnostics.json b/tests/components/bsblan/fixtures/diagnostics.json deleted file mode 100644 index bd05aca56d5..00000000000 --- a/tests/components/bsblan/fixtures/diagnostics.json +++ /dev/null @@ -1,75 +0,0 @@ -{ - "info": { - "device_identification": { - "name": "Gerte-Identifikation", - "unit": "", - "desc": "", - "value": "RVS21.831F/127", - "dataType": 7 - }, - "controller_family": { - "name": "Device family", - "unit": "", - "desc": "", - "value": "211", - "dataType": 0 - }, - "controller_variant": { - "name": "Device variant", - "unit": "", - "desc": "", - "value": "127", - "dataType": 0 - } - }, - "device": { - "name": "BSB-LAN", - "version": "1.0.38-20200730234859", - "MAC": "00:80:41:19:69:90", - "uptime": 969402857 - }, - "state": { - "hvac_mode": { - "name": "Operating mode", - "unit": "", - "desc": "Komfort", - "value": "heat", - "dataType": 1 - }, - "hvac_mode2": { - "name": "Operating mode", - "unit": "", - "desc": "Reduziert", - "value": "2", - "dataType": 1 - }, - "target_temperature": { - "name": "Room temperature Comfort setpoint", - "unit": "°C", - "desc": "", - "value": "18.5", - "dataType": 0 - }, - "hvac_action": { - "name": "Status heating circuit 1", - "unit": "", - "desc": "Raumtemp\u2019begrenzung", - "value": "122", - "dataType": 1 - }, - "current_temperature": { - "name": "Room temp 1 actual value", - "unit": "°C", - "desc": "", - "value": "18.6", - "dataType": 0 - }, - "room1_thermostat_mode": { - "name": "Raumthermostat 1", - "unit": "", - "desc": "Kein Bedarf", - "value": "0", - "dataType": 1 - } - } -} diff --git a/tests/components/bsblan/snapshots/test_diagnostics.ambr b/tests/components/bsblan/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..2fff33de046 --- /dev/null +++ b/tests/components/bsblan/snapshots/test_diagnostics.ambr @@ -0,0 +1,78 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'device': dict({ + 'MAC': '00:80:41:19:69:90', + 'name': 'BSB-LAN', + 'uptime': 969402857, + 'version': '1.0.38-20200730234859', + }), + 'info': dict({ + 'controller_family': dict({ + 'dataType': 0, + 'desc': '', + 'name': 'Device family', + 'unit': '', + 'value': '211', + }), + 'controller_variant': dict({ + 'dataType': 0, + 'desc': '', + 'name': 'Device variant', + 'unit': '', + 'value': '127', + }), + 'device_identification': dict({ + 'dataType': 7, + 'desc': '', + 'name': 'Gerte-Identifikation', + 'unit': '', + 'value': 'RVS21.831F/127', + }), + }), + 'state': dict({ + 'current_temperature': dict({ + 'dataType': 0, + 'desc': '', + 'name': 'Room temp 1 actual value', + 'unit': '°C', + 'value': '18.6', + }), + 'hvac_action': dict({ + 'dataType': 1, + 'desc': 'Raumtemp’begrenzung', + 'name': 'Status heating circuit 1', + 'unit': '', + 'value': '122', + }), + 'hvac_mode': dict({ + 'dataType': 1, + 'desc': 'Komfort', + 'name': 'Operating mode', + 'unit': '', + 'value': 'heat', + }), + 'hvac_mode2': dict({ + 'dataType': 1, + 'desc': 'Reduziert', + 'name': 'Operating mode', + 'unit': '', + 'value': '2', + }), + 'room1_thermostat_mode': dict({ + 'dataType': 1, + 'desc': 'Kein Bedarf', + 'name': 'Raumthermostat 1', + 'unit': '', + 'value': '0', + }), + 'target_temperature': dict({ + 'dataType': 0, + 'desc': '', + 'name': 'Room temperature Comfort setpoint', + 'unit': '°C', + 'value': '18.5', + }), + }), + }) +# --- diff --git a/tests/components/bsblan/test_diagnostics.py b/tests/components/bsblan/test_diagnostics.py index b2b5d201b93..316296df78a 100644 --- a/tests/components/bsblan/test_diagnostics.py +++ b/tests/components/bsblan/test_diagnostics.py @@ -1,9 +1,10 @@ """Tests for the diagnostics data provided by the BSBLan integration.""" -import json + +from syrupy import SnapshotAssertion from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -12,12 +13,11 @@ async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - diagnostics_fixture = json.loads(load_fixture("bsblan/diagnostics.json")) - assert ( await get_diagnostics_for_config_entry(hass, hass_client, init_integration) - == diagnostics_fixture + == snapshot ) From e96ce3f5204246d497f98a4396484c40782dace4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Fran=C3=A7ois=20Roy?= Date: Wed, 23 Aug 2023 11:34:38 -0700 Subject: [PATCH 005/124] baf: Raise ConfigEntryNotReady when the device has a mismatched UUID (#98898) --- homeassistant/components/baf/__init__.py | 5 +++ homeassistant/components/baf/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/baf/test_init.py | 43 ++++++++++++++++++++++ 5 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 tests/components/baf/test_init.py diff --git a/homeassistant/components/baf/__init__.py b/homeassistant/components/baf/__init__.py index dd784b214f7..fcc648f4001 100644 --- a/homeassistant/components/baf/__init__.py +++ b/homeassistant/components/baf/__init__.py @@ -6,6 +6,7 @@ from asyncio import timeout from aiobafi6 import Device, Service from aiobafi6.discovery import PORT +from aiobafi6.exceptions import DeviceUUIDMismatchError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, Platform @@ -37,6 +38,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: async with timeout(RUN_TIMEOUT): await device.async_wait_available() + except DeviceUUIDMismatchError as ex: + raise ConfigEntryNotReady( + f"Unexpected device found at {ip_address}; expected {entry.unique_id}, found {device.dns_sd_uuid}" + ) from ex except asyncio.TimeoutError as ex: run_future.cancel() raise ConfigEntryNotReady(f"Timed out connecting to {ip_address}") from ex diff --git a/homeassistant/components/baf/manifest.json b/homeassistant/components/baf/manifest.json index 37fd5cee7c6..497b3638fce 100644 --- a/homeassistant/components/baf/manifest.json +++ b/homeassistant/components/baf/manifest.json @@ -5,7 +5,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/baf", "iot_class": "local_push", - "requirements": ["aiobafi6==0.8.2"], + "requirements": ["aiobafi6==0.9.0"], "zeroconf": [ { "type": "_api._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 813e3af0ce7..f7cb1f9d60a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -203,7 +203,7 @@ aioasuswrt==1.4.0 aioazuredevops==1.3.5 # homeassistant.components.baf -aiobafi6==0.8.2 +aiobafi6==0.9.0 # homeassistant.components.aws aiobotocore==2.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 681755137b3..fbae222770c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -184,7 +184,7 @@ aioasuswrt==1.4.0 aioazuredevops==1.3.5 # homeassistant.components.baf -aiobafi6==0.8.2 +aiobafi6==0.9.0 # homeassistant.components.aws aiobotocore==2.6.0 diff --git a/tests/components/baf/test_init.py b/tests/components/baf/test_init.py new file mode 100644 index 00000000000..c87237892ad --- /dev/null +++ b/tests/components/baf/test_init.py @@ -0,0 +1,43 @@ +"""Test the baf init flow.""" +from unittest.mock import patch + +from aiobafi6.exceptions import DeviceUUIDMismatchError +import pytest + +from homeassistant.components.baf.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import MOCK_UUID, MockBAFDevice + +from tests.common import MockConfigEntry + + +def _patch_device_init(side_effect=None): + """Mock out the BAF Device object.""" + + def _create_mock_baf(*args, **kwargs): + return MockBAFDevice(side_effect) + + return patch("homeassistant.components.baf.Device", _create_mock_baf) + + +async def test_config_entry_wrong_uuid( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test config entry enters setup retry when uuid mismatches.""" + mismatched_uuid = MOCK_UUID + "0" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_IP_ADDRESS: "127.0.0.1"}, unique_id=mismatched_uuid + ) + already_migrated_config_entry.add_to_hass(hass) + with _patch_device_init(DeviceUUIDMismatchError): + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + assert already_migrated_config_entry.state == ConfigEntryState.SETUP_RETRY + assert ( + "Unexpected device found at 127.0.0.1; expected 12340, found 1234" + in caplog.text + ) From 4aa7fb0e352fa7986ce952ae56da61a4f5c9f66e Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 23 Aug 2023 21:02:11 +0200 Subject: [PATCH 006/124] Use snapshot assertion for Discovergy diagnostics test (#98871) Add snapshot assertion to Discovergy --- .../components/discovergy/diagnostics.py | 4 -- .../snapshots/test_diagnostics.ambr | 47 ++++++++++++++ .../components/discovergy/test_diagnostics.py | 62 ++----------------- 3 files changed, 51 insertions(+), 62 deletions(-) create mode 100644 tests/components/discovergy/snapshots/test_diagnostics.ambr diff --git a/homeassistant/components/discovergy/diagnostics.py b/homeassistant/components/discovergy/diagnostics.py index 5d4a34b07dd..e0a9e47e6fd 100644 --- a/homeassistant/components/discovergy/diagnostics.py +++ b/homeassistant/components/discovergy/diagnostics.py @@ -8,14 +8,11 @@ from pydiscovergy.models import Meter from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant from . import DiscovergyData from .const import DOMAIN -TO_REDACT_CONFIG_ENTRY = {CONF_EMAIL, CONF_PASSWORD, CONF_UNIQUE_ID, "title"} - TO_REDACT_METER = { "serial_number", "full_serial_number", @@ -44,7 +41,6 @@ async def async_get_config_entry_diagnostics( last_readings[meter.meter_id] = asdict(coordinator.data) return { - "entry": async_redact_data(entry.as_dict(), TO_REDACT_CONFIG_ENTRY), "meters": flattened_meter, "readings": last_readings, } diff --git a/tests/components/discovergy/snapshots/test_diagnostics.ambr b/tests/components/discovergy/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..d02f57c7540 --- /dev/null +++ b/tests/components/discovergy/snapshots/test_diagnostics.ambr @@ -0,0 +1,47 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'meters': list([ + dict({ + 'additional': dict({ + 'administration_number': '**REDACTED**', + 'current_scaling_factor': 1, + 'first_measurement_time': 1517569090926, + 'internal_meters': 1, + 'last_measurement_time': 1678430543742, + 'manufacturer_id': 'TST', + 'printed_full_serial_number': '**REDACTED**', + 'scaling_factor': 1, + 'voltage_scaling_factor': 1, + }), + 'full_serial_number': '**REDACTED**', + 'load_profile_type': 'SLP', + 'location': '**REDACTED**', + 'measurement_type': 'ELECTRICITY', + 'meter_id': 'f8d610b7a8cc4e73939fa33b990ded54', + 'serial_number': '**REDACTED**', + 'type': 'TST', + }), + ]), + 'readings': dict({ + 'f8d610b7a8cc4e73939fa33b990ded54': dict({ + 'time': '2023-03-10T07:32:06.702000', + 'values': dict({ + 'energy': 119348699715000.0, + 'energy1': 2254180000.0, + 'energy2': 119346445534000.0, + 'energyOut': 55048723044000.0, + 'energyOut1': 0.0, + 'energyOut2': 0.0, + 'power': 531750.0, + 'power1': 142680.0, + 'power2': 138010.0, + 'power3': 251060.0, + 'voltage1': 239800.0, + 'voltage2': 239700.0, + 'voltage3': 239000.0, + }), + }), + }), + }) +# --- diff --git a/tests/components/discovergy/test_diagnostics.py b/tests/components/discovergy/test_diagnostics.py index b9da2bb7e6f..d7565e3f0c4 100644 --- a/tests/components/discovergy/test_diagnostics.py +++ b/tests/components/discovergy/test_diagnostics.py @@ -1,7 +1,8 @@ """Test Discovergy diagnostics.""" from unittest.mock import patch -from homeassistant.components.diagnostics import REDACTED +from syrupy import SnapshotAssertion + from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -14,6 +15,7 @@ async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" with patch("pydiscovergy.Discovergy.meters", return_value=GET_METERS), patch( @@ -26,60 +28,4 @@ async def test_entry_diagnostics( hass, hass_client, mock_config_entry ) - assert result["entry"] == { - "entry_id": mock_config_entry.entry_id, - "version": 1, - "domain": "discovergy", - "title": REDACTED, - "data": {"email": REDACTED, "password": REDACTED}, - "options": {}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "unique_id": REDACTED, - "disabled_by": None, - } - - assert result["meters"] == [ - { - "additional": { - "administration_number": REDACTED, - "current_scaling_factor": 1, - "first_measurement_time": 1517569090926, - "internal_meters": 1, - "last_measurement_time": 1678430543742, - "manufacturer_id": "TST", - "printed_full_serial_number": REDACTED, - "scaling_factor": 1, - "voltage_scaling_factor": 1, - }, - "full_serial_number": REDACTED, - "load_profile_type": "SLP", - "location": REDACTED, - "measurement_type": "ELECTRICITY", - "meter_id": "f8d610b7a8cc4e73939fa33b990ded54", - "serial_number": REDACTED, - "type": "TST", - } - ] - - assert result["readings"] == { - "f8d610b7a8cc4e73939fa33b990ded54": { - "time": "2023-03-10T07:32:06.702000", - "values": { - "energy": 119348699715000.0, - "energy1": 2254180000.0, - "energy2": 119346445534000.0, - "energyOut": 55048723044000.0, - "energyOut1": 0.0, - "energyOut2": 0.0, - "power": 531750.0, - "power1": 142680.0, - "power2": 138010.0, - "power3": 251060.0, - "voltage1": 239800.0, - "voltage2": 239700.0, - "voltage3": 239000.0, - }, - } - } + assert result == snapshot From e1db3ecf52ac93ef70ecba0823410c344dd44cff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Aug 2023 14:21:18 -0500 Subject: [PATCH 007/124] Retry rainmachine setup later if the wrong device is found (#98888) --- homeassistant/components/rainmachine/__init__.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index ef2713cc192..c29154a941c 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -219,10 +219,11 @@ async def async_setup_entry( # noqa: C901 """Set up RainMachine as config entry.""" websession = aiohttp_client.async_get_clientsession(hass) client = Client(session=websession) + ip_address = entry.data[CONF_IP_ADDRESS] try: await client.load_local( - entry.data[CONF_IP_ADDRESS], + ip_address, entry.data[CONF_PASSWORD], port=entry.data[CONF_PORT], use_ssl=entry.data.get(CONF_SSL, DEFAULT_SSL), @@ -238,6 +239,7 @@ async def async_setup_entry( # noqa: C901 if not entry.unique_id or is_ip_address(entry.unique_id): # If the config entry doesn't already have a unique ID, set one: entry_updates["unique_id"] = controller.mac + if CONF_DEFAULT_ZONE_RUN_TIME in entry.data: # If a zone run time exists in the config entry's data, pop it and move it to # options: @@ -252,6 +254,17 @@ async def async_setup_entry( # noqa: C901 if entry_updates: hass.config_entries.async_update_entry(entry, **entry_updates) + if entry.unique_id and controller.mac != entry.unique_id: + # If the mac address of the device does not match the unique_id + # of the config entry, it likely means the DHCP lease has expired + # and the device has been assigned a new IP address. We need to + # wait for the next discovery to find the device at its new address + # and update the config entry so we do not mix up devices. + raise ConfigEntryNotReady( + f"Unexpected device found at {ip_address}; expected {entry.unique_id}, " + f"found {controller.mac}" + ) + async def async_update(api_category: str) -> dict: """Update the appropriate API data based on a category.""" data: dict = {} From 82e92cdf829939820c0d0774d813337626e49054 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 23 Aug 2023 21:36:18 +0200 Subject: [PATCH 008/124] Use snapshot assertion for Axis diagnostics test (#98902) --- tests/components/axis/conftest.py | 1 + .../axis/snapshots/test_diagnostics.ambr | 92 ++++++++++++++++++ tests/components/axis/test_diagnostics.py | 96 ++----------------- 3 files changed, 102 insertions(+), 87 deletions(-) create mode 100644 tests/components/axis/snapshots/test_diagnostics.ambr diff --git a/tests/components/axis/conftest.py b/tests/components/axis/conftest.py index 5c9c4e5a255..3c476705258 100644 --- a/tests/components/axis/conftest.py +++ b/tests/components/axis/conftest.py @@ -60,6 +60,7 @@ def config_entry_fixture(hass, config, options, config_entry_version): """Define a config entry fixture.""" entry = MockConfigEntry( domain=AXIS_DOMAIN, + entry_id="676abe5b73621446e6550a2e86ffe3dd", unique_id=FORMATTED_MAC, data=config, options=options, diff --git a/tests/components/axis/snapshots/test_diagnostics.ambr b/tests/components/axis/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..74a1f110c14 --- /dev/null +++ b/tests/components/axis/snapshots/test_diagnostics.ambr @@ -0,0 +1,92 @@ +# serializer version: 1 +# name: test_entry_diagnostics[api_discovery_items0] + dict({ + 'api_discovery': list([ + dict({ + 'id': 'api-discovery', + 'name': 'API Discovery Service', + 'version': '1.0', + }), + dict({ + 'id': 'param-cgi', + 'name': 'Legacy Parameter Handling', + 'version': '1.0', + }), + dict({ + 'id': 'basic-device-info', + 'name': 'Basic Device Information', + 'version': '1.1', + }), + ]), + 'basic_device_info': dict({ + 'ProdNbr': 'M1065-LW', + 'ProdType': 'Network Camera', + 'SerialNumber': '**REDACTED**', + 'Version': '9.80.1', + }), + 'camera_sources': dict({ + 'Image': 'http://1.2.3.4:80/axis-cgi/jpg/image.cgi', + 'MJPEG': 'http://1.2.3.4:80/axis-cgi/mjpg/video.cgi', + 'Stream': 'rtsp://user:pass@1.2.3.4/axis-media/media.amp?videocodec=h264', + }), + 'config': dict({ + 'data': dict({ + 'host': '1.2.3.4', + 'model': 'model', + 'name': 'name', + 'password': '**REDACTED**', + 'port': 80, + 'username': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'axis', + 'entry_id': '676abe5b73621446e6550a2e86ffe3dd', + 'options': dict({ + 'events': True, + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': '**REDACTED**', + 'version': 3, + }), + 'params': dict({ + 'root.IOPort': dict({ + 'I0.Configurable': 'no', + 'I0.Direction': 'input', + 'I0.Input.Name': 'PIR sensor', + 'I0.Input.Trig': 'closed', + }), + 'root.Input': dict({ + 'NbrOfInputs': '1', + }), + 'root.Output': dict({ + 'NbrOfOutputs': '0', + }), + 'root.Properties': dict({ + 'API.HTTP.Version': '3', + 'API.Metadata.Metadata': 'yes', + 'API.Metadata.Version': '1.0', + 'EmbeddedDevelopment.Version': '2.16', + 'Firmware.BuildDate': 'Feb 15 2019 09:42', + 'Firmware.BuildNumber': '26', + 'Firmware.Version': '9.10.1', + 'Image.Format': 'jpeg,mjpeg,h264', + 'Image.NbrOfViews': '2', + 'Image.Resolution': '1920x1080,1280x960,1280x720,1024x768,1024x576,800x600,640x480,640x360,352x240,320x240', + 'Image.Rotation': '0,180', + 'System.SerialNumber': '**REDACTED**', + }), + 'root.StreamProfile': dict({ + 'MaxGroups': '26', + 'S0.Description': 'profile_1_description', + 'S0.Name': 'profile_1', + 'S0.Parameters': 'videocodec=h264', + 'S1.Description': 'profile_2_description', + 'S1.Name': 'profile_2', + 'S1.Parameters': 'videocodec=h265', + }), + }), + }) +# --- diff --git a/tests/components/axis/test_diagnostics.py b/tests/components/axis/test_diagnostics.py index a76aa40ebc8..af11fdc388a 100644 --- a/tests/components/axis/test_diagnostics.py +++ b/tests/components/axis/test_diagnostics.py @@ -1,7 +1,7 @@ """Test Axis diagnostics.""" import pytest +from syrupy import SnapshotAssertion -from homeassistant.components.diagnostics import REDACTED from homeassistant.core import HomeAssistant from .const import API_DISCOVERY_BASIC_DEVICE_INFO @@ -12,91 +12,13 @@ from tests.typing import ClientSessionGenerator @pytest.mark.parametrize("api_discovery_items", [API_DISCOVERY_BASIC_DEVICE_INFO]) async def test_entry_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator, setup_config_entry + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + setup_config_entry, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - assert await get_diagnostics_for_config_entry( - hass, hass_client, setup_config_entry - ) == { - "config": { - "entry_id": setup_config_entry.entry_id, - "version": 3, - "domain": "axis", - "title": "Mock Title", - "data": { - "host": "1.2.3.4", - "username": REDACTED, - "password": REDACTED, - "port": 80, - "model": "model", - "name": "name", - }, - "options": {"events": True}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "unique_id": REDACTED, - "disabled_by": None, - }, - "camera_sources": { - "Image": "http://1.2.3.4:80/axis-cgi/jpg/image.cgi", - "MJPEG": "http://1.2.3.4:80/axis-cgi/mjpg/video.cgi", - "Stream": "rtsp://user:pass@1.2.3.4/axis-media/media.amp?videocodec=h264", - }, - "api_discovery": [ - { - "id": "api-discovery", - "name": "API Discovery Service", - "version": "1.0", - }, - { - "id": "param-cgi", - "name": "Legacy Parameter Handling", - "version": "1.0", - }, - { - "id": "basic-device-info", - "name": "Basic Device Information", - "version": "1.1", - }, - ], - "basic_device_info": { - "ProdNbr": "M1065-LW", - "ProdType": "Network Camera", - "SerialNumber": REDACTED, - "Version": "9.80.1", - }, - "params": { - "root.IOPort": { - "I0.Configurable": "no", - "I0.Direction": "input", - "I0.Input.Name": "PIR sensor", - "I0.Input.Trig": "closed", - }, - "root.Input": {"NbrOfInputs": "1"}, - "root.Output": {"NbrOfOutputs": "0"}, - "root.Properties": { - "API.HTTP.Version": "3", - "API.Metadata.Metadata": "yes", - "API.Metadata.Version": "1.0", - "EmbeddedDevelopment.Version": "2.16", - "Firmware.BuildDate": "Feb 15 2019 09:42", - "Firmware.BuildNumber": "26", - "Firmware.Version": "9.10.1", - "Image.Format": "jpeg,mjpeg,h264", - "Image.NbrOfViews": "2", - "Image.Resolution": "1920x1080,1280x960,1280x720,1024x768,1024x576,800x600,640x480,640x360,352x240,320x240", - "Image.Rotation": "0,180", - "System.SerialNumber": REDACTED, - }, - "root.StreamProfile": { - "MaxGroups": "26", - "S0.Description": "profile_1_description", - "S0.Name": "profile_1", - "S0.Parameters": "videocodec=h264", - "S1.Description": "profile_2_description", - "S1.Name": "profile_2", - "S1.Parameters": "videocodec=h265", - }, - }, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, setup_config_entry) + == snapshot + ) From 1f0e8f93c577427e24e055dca5c3aa7ccd66eff7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 23 Aug 2023 21:37:03 +0200 Subject: [PATCH 009/124] Use snapshot assertion for Deconz diagnostics test (#98908) --- .../deconz/snapshots/test_diagnostics.ambr | 79 +++++++++++++++++++ tests/components/deconz/test_diagnostics.py | 60 ++------------ 2 files changed, 86 insertions(+), 53 deletions(-) create mode 100644 tests/components/deconz/snapshots/test_diagnostics.ambr diff --git a/tests/components/deconz/snapshots/test_diagnostics.ambr b/tests/components/deconz/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..bbd96f1751c --- /dev/null +++ b/tests/components/deconz/snapshots/test_diagnostics.ambr @@ -0,0 +1,79 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'alarm_systems': dict({ + }), + 'config': dict({ + 'data': dict({ + 'api_key': '**REDACTED**', + 'host': '1.2.3.4', + 'port': 80, + }), + 'disabled_by': None, + 'domain': 'deconz', + 'entry_id': '1', + 'options': dict({ + 'master': True, + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': '**REDACTED**', + 'version': 1, + }), + 'deconz_config': dict({ + 'bridgeid': '**REDACTED**', + 'ipaddress': '1.2.3.4', + 'mac': '**REDACTED**', + 'modelid': 'deCONZ', + 'name': 'deCONZ mock gateway', + 'sw_version': '2.05.69', + 'uuid': '1234', + 'websocketport': 1234, + }), + 'deconz_ids': dict({ + }), + 'entities': dict({ + 'alarm_control_panel': list([ + ]), + 'binary_sensor': list([ + ]), + 'button': list([ + ]), + 'climate': list([ + ]), + 'cover': list([ + ]), + 'fan': list([ + ]), + 'light': list([ + ]), + 'lock': list([ + ]), + 'number': list([ + ]), + 'scene': list([ + ]), + 'select': list([ + ]), + 'sensor': list([ + ]), + 'siren': list([ + ]), + 'switch': list([ + ]), + }), + 'events': dict({ + }), + 'groups': dict({ + }), + 'lights': dict({ + }), + 'scenes': dict({ + }), + 'sensors': dict({ + }), + 'websocket_state': 'running', + }) +# --- diff --git a/tests/components/deconz/test_diagnostics.py b/tests/components/deconz/test_diagnostics.py index 44b8bfd50dc..e7e470cdf81 100644 --- a/tests/components/deconz/test_diagnostics.py +++ b/tests/components/deconz/test_diagnostics.py @@ -1,12 +1,10 @@ """Test deCONZ diagnostics.""" from pydeconz.websocket import State +from syrupy import SnapshotAssertion -from homeassistant.components.deconz.const import CONF_MASTER_GATEWAY -from homeassistant.components.diagnostics import REDACTED -from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant -from .test_gateway import HOST, PORT, setup_deconz_integration +from .test_gateway import setup_deconz_integration from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.test_util.aiohttp import AiohttpClientMocker @@ -18,6 +16,7 @@ async def test_entry_diagnostics( hass_client: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, mock_deconz_websocket, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" config_entry = await setup_deconz_integration(hass, aioclient_mock) @@ -25,52 +24,7 @@ async def test_entry_diagnostics( await mock_deconz_websocket(state=State.RUNNING) await hass.async_block_till_done() - assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "config": { - "data": {CONF_API_KEY: REDACTED, CONF_HOST: HOST, CONF_PORT: PORT}, - "disabled_by": None, - "domain": "deconz", - "entry_id": "1", - "options": {CONF_MASTER_GATEWAY: True}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "title": "Mock Title", - "unique_id": REDACTED, - "version": 1, - }, - "deconz_config": { - "bridgeid": REDACTED, - "ipaddress": HOST, - "mac": REDACTED, - "modelid": "deCONZ", - "name": "deCONZ mock gateway", - "sw_version": "2.05.69", - "uuid": "1234", - "websocketport": 1234, - }, - "websocket_state": State.RUNNING.value, - "deconz_ids": {}, - "entities": { - str(Platform.ALARM_CONTROL_PANEL): [], - str(Platform.BINARY_SENSOR): [], - str(Platform.BUTTON): [], - str(Platform.CLIMATE): [], - str(Platform.COVER): [], - str(Platform.FAN): [], - str(Platform.LIGHT): [], - str(Platform.LOCK): [], - str(Platform.NUMBER): [], - str(Platform.SCENE): [], - str(Platform.SELECT): [], - str(Platform.SENSOR): [], - str(Platform.SIREN): [], - str(Platform.SWITCH): [], - }, - "events": {}, - "alarm_systems": {}, - "groups": {}, - "lights": {}, - "scenes": {}, - "sensors": {}, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) From f83c33540924e8dea809621fee60b9c07b85bafc Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 23 Aug 2023 22:21:24 +0200 Subject: [PATCH 010/124] Use snapshot assertion for Environment Canada diagnostics test (#98912) --- .../fixtures/config_entry_data.json | 110 ----------------- .../snapshots/test_diagnostics.ambr | 113 ++++++++++++++++++ .../environment_canada/test_diagnostics.py | 11 +- 3 files changed, 119 insertions(+), 115 deletions(-) delete mode 100644 tests/components/environment_canada/fixtures/config_entry_data.json create mode 100644 tests/components/environment_canada/snapshots/test_diagnostics.ambr diff --git a/tests/components/environment_canada/fixtures/config_entry_data.json b/tests/components/environment_canada/fixtures/config_entry_data.json deleted file mode 100644 index 085a3394dce..00000000000 --- a/tests/components/environment_canada/fixtures/config_entry_data.json +++ /dev/null @@ -1,110 +0,0 @@ -{ - "config_entry_data": { - "latitude": "**REDACTED**", - "longitude": "**REDACTED**", - "station": "XX/1234567", - "language": "Gibberish" - }, - "weather_data": { - "temperature": { - "label": "Temperature", - "value": 14.9, - "unit": "C" - }, - "dewpoint": { - "label": "Dew Point", - "value": 1.4, - "unit": "C" - }, - "wind_chill": { - "label": "Wind Chill", - "value": null - }, - "humidex": { - "label": "Humidex", - "value": null - }, - "pressure": { - "label": "Pressure", - "value": 102.7, - "unit": "kPa" - }, - "tendency": { - "label": "Tendency", - "value": "falling" - }, - "humidity": { - "label": "Humidity", - "value": 40, - "unit": "%" - }, - "visibility": { - "label": "Visibility", - "value": 24.1, - "unit": "km" - }, - "condition": { - "label": "Condition", - "value": "Mainly Sunny" - }, - "wind_speed": { - "label": "Wind Speed", - "value": 1, - "unit": "km/h" - }, - "wind_gust": { - "label": "Wind Gust", - "value": null - }, - "wind_dir": { - "label": "Wind Direction", - "value": "N" - }, - "wind_bearing": { - "label": "Wind Bearing", - "value": 0, - "unit": "degrees" - }, - "high_temp": { - "label": "High Temperature", - "value": 18, - "unit": "C" - }, - "low_temp": { - "label": "Low Temperature", - "value": -1, - "unit": "C" - }, - "uv_index": { - "label": "UV Index", - "value": 5 - }, - "pop": { - "label": "Chance of Precip.", - "value": null - }, - "icon_code": { - "label": "Icon Code", - "value": "01" - }, - "precip_yesterday": { - "label": "Precipitation Yesterday", - "value": 0.0, - "unit": "mm" - }, - "normal_high": { - "label": "Normal High Temperature", - "value": 15, - "unit": "C" - }, - "normal_low": { - "label": "Normal Low Temperature", - "value": 6, - "unit": "C" - }, - "text_summary": { - "label": "Forecast", - "value": "Tonight. Clear. Fog patches developing after midnight. Low minus 1 with frost." - } - } -} diff --git a/tests/components/environment_canada/snapshots/test_diagnostics.ambr b/tests/components/environment_canada/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..94ed1d88201 --- /dev/null +++ b/tests/components/environment_canada/snapshots/test_diagnostics.ambr @@ -0,0 +1,113 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'config_entry_data': dict({ + 'language': 'Gibberish', + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'station': 'XX/1234567', + }), + 'weather_data': dict({ + 'condition': dict({ + 'label': 'Condition', + 'value': 'Mainly Sunny', + }), + 'dewpoint': dict({ + 'label': 'Dew Point', + 'unit': 'C', + 'value': 1.4, + }), + 'high_temp': dict({ + 'label': 'High Temperature', + 'unit': 'C', + 'value': 18, + }), + 'humidex': dict({ + 'label': 'Humidex', + 'value': None, + }), + 'humidity': dict({ + 'label': 'Humidity', + 'unit': '%', + 'value': 40, + }), + 'icon_code': dict({ + 'label': 'Icon Code', + 'value': '01', + }), + 'low_temp': dict({ + 'label': 'Low Temperature', + 'unit': 'C', + 'value': -1, + }), + 'normal_high': dict({ + 'label': 'Normal High Temperature', + 'unit': 'C', + 'value': 15, + }), + 'normal_low': dict({ + 'label': 'Normal Low Temperature', + 'unit': 'C', + 'value': 6, + }), + 'pop': dict({ + 'label': 'Chance of Precip.', + 'value': None, + }), + 'precip_yesterday': dict({ + 'label': 'Precipitation Yesterday', + 'unit': 'mm', + 'value': 0.0, + }), + 'pressure': dict({ + 'label': 'Pressure', + 'unit': 'kPa', + 'value': 102.7, + }), + 'temperature': dict({ + 'label': 'Temperature', + 'unit': 'C', + 'value': 14.9, + }), + 'tendency': dict({ + 'label': 'Tendency', + 'value': 'falling', + }), + 'text_summary': dict({ + 'label': 'Forecast', + 'value': 'Tonight. Clear. Fog patches developing after midnight. Low minus 1 with frost.', + }), + 'uv_index': dict({ + 'label': 'UV Index', + 'value': 5, + }), + 'visibility': dict({ + 'label': 'Visibility', + 'unit': 'km', + 'value': 24.1, + }), + 'wind_bearing': dict({ + 'label': 'Wind Bearing', + 'unit': 'degrees', + 'value': 0, + }), + 'wind_chill': dict({ + 'label': 'Wind Chill', + 'value': None, + }), + 'wind_dir': dict({ + 'label': 'Wind Direction', + 'value': 'N', + }), + 'wind_gust': dict({ + 'label': 'Wind Gust', + 'value': None, + }), + 'wind_speed': dict({ + 'label': 'Wind Speed', + 'unit': 'km/h', + 'value': 1, + }), + }), + }) +# --- diff --git a/tests/components/environment_canada/test_diagnostics.py b/tests/components/environment_canada/test_diagnostics.py index 6044c9e778b..3eedb7a0ddb 100644 --- a/tests/components/environment_canada/test_diagnostics.py +++ b/tests/components/environment_canada/test_diagnostics.py @@ -3,6 +3,8 @@ from datetime import UTC, datetime import json from unittest.mock import AsyncMock, MagicMock, patch +from syrupy import SnapshotAssertion + from homeassistant.components.environment_canada.const import ( CONF_LANGUAGE, CONF_STATION, @@ -72,7 +74,9 @@ async def init_integration(hass: HomeAssistant) -> MockConfigEntry: async def test_entry_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" @@ -80,8 +84,5 @@ async def test_entry_diagnostics( diagnostics = await get_diagnostics_for_config_entry( hass, hass_client, config_entry ) - redacted_entry = json.loads( - load_fixture("environment_canada/config_entry_data.json") - ) - assert diagnostics == redacted_entry + assert diagnostics == snapshot From 364d872a47d253b95cad179ae579c5c254ff329e Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Wed, 23 Aug 2023 22:43:08 +0200 Subject: [PATCH 011/124] Bump energyzero to v0.5.0 (#98914) --- homeassistant/components/energyzero/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/energyzero/manifest.json b/homeassistant/components/energyzero/manifest.json index 05d23ca4464..8e2b8aba894 100644 --- a/homeassistant/components/energyzero/manifest.json +++ b/homeassistant/components/energyzero/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/energyzero", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["energyzero==0.4.1"] + "requirements": ["energyzero==0.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index f7cb1f9d60a..d6572d05f3b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -730,7 +730,7 @@ emulated-roku==0.2.1 energyflip-client==0.2.2 # homeassistant.components.energyzero -energyzero==0.4.1 +energyzero==0.5.0 # homeassistant.components.enocean enocean==0.50 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fbae222770c..ea5e8680ec7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -586,7 +586,7 @@ emulated-roku==0.2.1 energyflip-client==0.2.2 # homeassistant.components.energyzero -energyzero==0.4.1 +energyzero==0.5.0 # homeassistant.components.enocean enocean==0.50 From 816f834807341aa0036dbed140f4a734de34f6bc Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Wed, 23 Aug 2023 22:46:34 +0200 Subject: [PATCH 012/124] Add moisture sensors entities for gardena (#98282) Add support for soil moisture sensors for gardena --- .../gardena_bluetooth/binary_sensor.py | 16 +++++- .../components/gardena_bluetooth/button.py | 7 ++- .../gardena_bluetooth/coordinator.py | 15 ++++-- .../components/gardena_bluetooth/number.py | 34 ++++++++++-- .../components/gardena_bluetooth/sensor.py | 53 ++++++++++++++++++- .../components/gardena_bluetooth/strings.json | 15 ++++++ .../snapshots/test_number.ambr | 34 ++++++++++++ .../snapshots/test_sensor.ambr | 30 +++++++++++ .../gardena_bluetooth/test_number.py | 27 +++++++++- .../gardena_bluetooth/test_sensor.py | 27 +++++++++- 10 files changed, 244 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/gardena_bluetooth/binary_sensor.py b/homeassistant/components/gardena_bluetooth/binary_sensor.py index 0285f7bdf82..b66cb8cd00d 100644 --- a/homeassistant/components/gardena_bluetooth/binary_sensor.py +++ b/homeassistant/components/gardena_bluetooth/binary_sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from gardena_bluetooth.const import Valve +from gardena_bluetooth.const import Sensor, Valve from gardena_bluetooth.parse import CharacteristicBool from homeassistant.components.binary_sensor import ( @@ -26,6 +26,11 @@ class GardenaBluetoothBinarySensorEntityDescription(BinarySensorEntityDescriptio char: CharacteristicBool = field(default_factory=lambda: CharacteristicBool("")) + @property + def context(self) -> set[str]: + """Context needed for update coordinator.""" + return {self.char.uuid} + DESCRIPTIONS = ( GardenaBluetoothBinarySensorEntityDescription( @@ -35,6 +40,13 @@ DESCRIPTIONS = ( entity_category=EntityCategory.DIAGNOSTIC, char=Valve.connected_state, ), + GardenaBluetoothBinarySensorEntityDescription( + key=Sensor.connected_state.uuid, + translation_key="sensor_connected_state", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + char=Sensor.connected_state, + ), ) @@ -44,7 +56,7 @@ async def async_setup_entry( """Set up binary sensor based on a config entry.""" coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id] entities = [ - GardenaBluetoothBinarySensor(coordinator, description) + GardenaBluetoothBinarySensor(coordinator, description, description.context) for description in DESCRIPTIONS if description.key in coordinator.characteristics ] diff --git a/homeassistant/components/gardena_bluetooth/button.py b/homeassistant/components/gardena_bluetooth/button.py index a9dac9902f8..1ed738a9690 100644 --- a/homeassistant/components/gardena_bluetooth/button.py +++ b/homeassistant/components/gardena_bluetooth/button.py @@ -22,6 +22,11 @@ class GardenaBluetoothButtonEntityDescription(ButtonEntityDescription): char: CharacteristicBool = field(default_factory=lambda: CharacteristicBool("")) + @property + def context(self) -> set[str]: + """Context needed for update coordinator.""" + return {self.char.uuid} + DESCRIPTIONS = ( GardenaBluetoothButtonEntityDescription( @@ -40,7 +45,7 @@ async def async_setup_entry( """Set up button based on a config entry.""" coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id] entities = [ - GardenaBluetoothButton(coordinator, description) + GardenaBluetoothButton(coordinator, description, description.context) for description in DESCRIPTIONS if description.key in coordinator.characteristics ] diff --git a/homeassistant/components/gardena_bluetooth/coordinator.py b/homeassistant/components/gardena_bluetooth/coordinator.py index 67ed056f7b1..73552e25c03 100644 --- a/homeassistant/components/gardena_bluetooth/coordinator.py +++ b/homeassistant/components/gardena_bluetooth/coordinator.py @@ -117,8 +117,12 @@ class GardenaBluetoothEntity(CoordinatorEntity[Coordinator]): @property def available(self) -> bool: """Return if entity is available.""" - return super().available and bluetooth.async_address_present( - self.hass, self.coordinator.address, True + return ( + self.coordinator.last_update_success + and bluetooth.async_address_present( + self.hass, self.coordinator.address, True + ) + and self._attr_available ) @@ -126,9 +130,12 @@ class GardenaBluetoothDescriptorEntity(GardenaBluetoothEntity): """Coordinator entity for entities with entity description.""" def __init__( - self, coordinator: Coordinator, description: EntityDescription + self, + coordinator: Coordinator, + description: EntityDescription, + context: set[str], ) -> None: """Initialize description entity.""" - super().__init__(coordinator, {description.key}) + super().__init__(coordinator, context) self._attr_unique_id = f"{coordinator.address}-{description.key}" self.entity_description = description diff --git a/homeassistant/components/gardena_bluetooth/number.py b/homeassistant/components/gardena_bluetooth/number.py index f53a7720577..f0ba5dbd2fe 100644 --- a/homeassistant/components/gardena_bluetooth/number.py +++ b/homeassistant/components/gardena_bluetooth/number.py @@ -3,8 +3,9 @@ from __future__ import annotations from dataclasses import dataclass, field -from gardena_bluetooth.const import DeviceConfiguration, Valve +from gardena_bluetooth.const import DeviceConfiguration, Sensor, Valve from gardena_bluetooth.parse import ( + Characteristic, CharacteristicInt, CharacteristicLong, CharacteristicUInt16, @@ -16,7 +17,7 @@ from homeassistant.components.number import ( NumberMode, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory, UnitOfTime +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -35,6 +36,15 @@ class GardenaBluetoothNumberEntityDescription(NumberEntityDescription): char: CharacteristicInt | CharacteristicUInt16 | CharacteristicLong = field( default_factory=lambda: CharacteristicInt("") ) + connected_state: Characteristic | None = None + + @property + def context(self) -> set[str]: + """Context needed for update coordinator.""" + data = {self.char.uuid} + if self.connected_state: + data.add(self.connected_state.uuid) + return data DESCRIPTIONS = ( @@ -81,6 +91,18 @@ DESCRIPTIONS = ( entity_category=EntityCategory.CONFIG, char=DeviceConfiguration.seasonal_adjust, ), + GardenaBluetoothNumberEntityDescription( + key=Sensor.threshold.uuid, + translation_key="sensor_threshold", + native_unit_of_measurement=PERCENTAGE, + mode=NumberMode.BOX, + native_min_value=0.0, + native_max_value=100.0, + native_step=1.0, + entity_category=EntityCategory.CONFIG, + char=Sensor.threshold, + connected_state=Sensor.connected_state, + ), ) @@ -90,7 +112,7 @@ async def async_setup_entry( """Set up entity based on a config entry.""" coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id] entities: list[NumberEntity] = [ - GardenaBluetoothNumber(coordinator, description) + GardenaBluetoothNumber(coordinator, description, description.context) for description in DESCRIPTIONS if description.key in coordinator.characteristics ] @@ -110,6 +132,12 @@ class GardenaBluetoothNumber(GardenaBluetoothDescriptorEntity, NumberEntity): self._attr_native_value = None else: self._attr_native_value = float(data) + + if char := self.entity_description.connected_state: + self._attr_available = bool(self.coordinator.get_cached(char)) + else: + self._attr_available = True + super()._handle_coordinator_update() async def async_set_native_value(self, value: float) -> None: diff --git a/homeassistant/components/gardena_bluetooth/sensor.py b/homeassistant/components/gardena_bluetooth/sensor.py index dd2bde43cc4..396d8469ffc 100644 --- a/homeassistant/components/gardena_bluetooth/sensor.py +++ b/homeassistant/components/gardena_bluetooth/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta -from gardena_bluetooth.const import Battery, Valve +from gardena_bluetooth.const import Battery, Sensor, Valve from gardena_bluetooth.parse import Characteristic from homeassistant.components.sensor import ( @@ -32,6 +32,15 @@ class GardenaBluetoothSensorEntityDescription(SensorEntityDescription): """Description of entity.""" char: Characteristic = field(default_factory=lambda: Characteristic("")) + connected_state: Characteristic | None = None + + @property + def context(self) -> set[str]: + """Context needed for update coordinator.""" + data = {self.char.uuid} + if self.connected_state: + data.add(self.connected_state.uuid) + return data DESCRIPTIONS = ( @@ -51,6 +60,40 @@ DESCRIPTIONS = ( native_unit_of_measurement=PERCENTAGE, char=Battery.battery_level, ), + GardenaBluetoothSensorEntityDescription( + key=Sensor.battery_level.uuid, + translation_key="sensor_battery_level", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + char=Sensor.battery_level, + connected_state=Sensor.connected_state, + ), + GardenaBluetoothSensorEntityDescription( + key=Sensor.value.uuid, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.MOISTURE, + native_unit_of_measurement=PERCENTAGE, + char=Sensor.value, + connected_state=Sensor.connected_state, + ), + GardenaBluetoothSensorEntityDescription( + key=Sensor.type.uuid, + translation_key="sensor_type", + entity_category=EntityCategory.DIAGNOSTIC, + char=Sensor.type, + connected_state=Sensor.connected_state, + ), + GardenaBluetoothSensorEntityDescription( + key=Sensor.measurement_timestamp.uuid, + translation_key="sensor_measurement_timestamp", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + char=Sensor.measurement_timestamp, + connected_state=Sensor.connected_state, + ), ) @@ -60,7 +103,7 @@ async def async_setup_entry( """Set up Gardena Bluetooth sensor based on a config entry.""" coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id] entities: list[GardenaBluetoothEntity] = [ - GardenaBluetoothSensor(coordinator, description) + GardenaBluetoothSensor(coordinator, description, description.context) for description in DESCRIPTIONS if description.key in coordinator.characteristics ] @@ -81,6 +124,12 @@ class GardenaBluetoothSensor(GardenaBluetoothDescriptorEntity, SensorEntity): tzinfo=dt_util.get_time_zone(self.hass.config.time_zone) ) self._attr_native_value = value + + if char := self.entity_description.connected_state: + self._attr_available = bool(self.coordinator.get_cached(char)) + else: + self._attr_available = True + super()._handle_coordinator_update() diff --git a/homeassistant/components/gardena_bluetooth/strings.json b/homeassistant/components/gardena_bluetooth/strings.json index 538f97ffdb3..01eac80d1e0 100644 --- a/homeassistant/components/gardena_bluetooth/strings.json +++ b/homeassistant/components/gardena_bluetooth/strings.json @@ -23,6 +23,9 @@ "binary_sensor": { "valve_connected_state": { "name": "Valve connection" + }, + "sensor_connected_state": { + "name": "Sensor connection" } }, "button": { @@ -45,12 +48,24 @@ }, "seasonal_adjust": { "name": "Seasonal adjust" + }, + "sensor_threshold": { + "name": "Sensor threshold" } }, "sensor": { "activation_reason": { "name": "Activation reason" }, + "sensor_battery_level": { + "name": "Sensor battery" + }, + "sensor_type": { + "name": "Sensor type" + }, + "sensor_measurement_timestamp": { + "name": "Sensor timestamp" + }, "remaining_open_timestamp": { "name": "Valve closing" } diff --git a/tests/components/gardena_bluetooth/snapshots/test_number.ambr b/tests/components/gardena_bluetooth/snapshots/test_number.ambr index 0c464f7cbc1..0b39525dc82 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_number.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_number.ambr @@ -67,6 +67,40 @@ 'state': 'unavailable', }) # --- +# name: test_connected_state + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Sensor threshold', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.mock_title_sensor_threshold', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_connected_state.1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Sensor threshold', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.mock_title_sensor_threshold', + 'last_changed': , + 'last_updated': , + 'state': '45.0', + }) +# --- # name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-number.mock_title_remaining_open_time] StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr b/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr index 8df37b40abc..1c33e8ebab9 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr @@ -1,4 +1,34 @@ # serializer version: 1 +# name: test_connected_state + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Mock Title Sensor battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.mock_title_sensor_battery', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_connected_state.1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Mock Title Sensor battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.mock_title_sensor_battery', + 'last_changed': , + 'last_updated': , + 'state': '45', + }) +# --- # name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-sensor.mock_title_valve_closing] StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/gardena_bluetooth/test_number.py b/tests/components/gardena_bluetooth/test_number.py index 0003532fb60..ce2d19b8c63 100644 --- a/tests/components/gardena_bluetooth/test_number.py +++ b/tests/components/gardena_bluetooth/test_number.py @@ -5,7 +5,7 @@ from collections.abc import Awaitable, Callable from typing import Any from unittest.mock import Mock, call -from gardena_bluetooth.const import Valve +from gardena_bluetooth.const import Sensor, Valve from gardena_bluetooth.exceptions import ( CharacteristicNoAccess, GardenaBluetoothException, @@ -149,3 +149,28 @@ async def test_bluetooth_error_unavailable( await scan_step() assert hass.states.get("number.mock_title_remaining_open_time") == snapshot assert hass.states.get("number.mock_title_manual_watering_time") == snapshot + + +async def test_connected_state( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_entry: MockConfigEntry, + mock_read_char_raw: dict[str, bytes], + scan_step: Callable[[], Awaitable[None]], +) -> None: + """Verify that a connectivity error makes all entities unavailable.""" + + mock_read_char_raw[Sensor.connected_state.uuid] = Sensor.connected_state.encode( + False + ) + mock_read_char_raw[Sensor.threshold.uuid] = Sensor.threshold.encode(45) + + await setup_entry(hass, mock_entry, [Platform.NUMBER]) + assert hass.states.get("number.mock_title_sensor_threshold") == snapshot + + mock_read_char_raw[Sensor.connected_state.uuid] = Sensor.connected_state.encode( + True + ) + + await scan_step() + assert hass.states.get("number.mock_title_sensor_threshold") == snapshot diff --git a/tests/components/gardena_bluetooth/test_sensor.py b/tests/components/gardena_bluetooth/test_sensor.py index 307a9467f00..dc0d0cb4809 100644 --- a/tests/components/gardena_bluetooth/test_sensor.py +++ b/tests/components/gardena_bluetooth/test_sensor.py @@ -1,7 +1,7 @@ """Test Gardena Bluetooth sensor.""" from collections.abc import Awaitable, Callable -from gardena_bluetooth.const import Battery, Valve +from gardena_bluetooth.const import Battery, Sensor, Valve import pytest from syrupy.assertion import SnapshotAssertion @@ -52,3 +52,28 @@ async def test_setup( mock_read_char_raw[uuid] = char_raw await scan_step() assert hass.states.get(entity_id) == snapshot + + +async def test_connected_state( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_entry: MockConfigEntry, + mock_read_char_raw: dict[str, bytes], + scan_step: Callable[[], Awaitable[None]], +) -> None: + """Verify that a connectivity error makes all entities unavailable.""" + + mock_read_char_raw[Sensor.connected_state.uuid] = Sensor.connected_state.encode( + False + ) + mock_read_char_raw[Sensor.battery_level.uuid] = Sensor.battery_level.encode(45) + + await setup_entry(hass, mock_entry, [Platform.SENSOR]) + assert hass.states.get("sensor.mock_title_sensor_battery") == snapshot + + mock_read_char_raw[Sensor.connected_state.uuid] = Sensor.connected_state.encode( + True + ) + + await scan_step() + assert hass.states.get("sensor.mock_title_sensor_battery") == snapshot From d8f0c090cf6ce10c061311885b2e116058342c00 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Wed, 23 Aug 2023 23:02:19 +0200 Subject: [PATCH 013/124] Energyzero - Add sensor entity to pick best hours (#98916) * Add entity to pick best hours * Add entity also to diagnostics * Remove string translation that doesn't exists --------- Co-authored-by: Joost Lekkerkerker --- .../components/energyzero/diagnostics.py | 1 + homeassistant/components/energyzero/sensor.py | 16 ++++- .../components/energyzero/strings.json | 3 - .../snapshots/test_diagnostics.ambr | 2 + .../energyzero/snapshots/test_sensor.ambr | 65 +++++++++++++++++++ tests/components/energyzero/test_sensor.py | 5 ++ 6 files changed, 88 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/energyzero/diagnostics.py b/homeassistant/components/energyzero/diagnostics.py index 5e3e402efbf..3b0c05b7368 100644 --- a/homeassistant/components/energyzero/diagnostics.py +++ b/homeassistant/components/energyzero/diagnostics.py @@ -50,6 +50,7 @@ async def async_get_config_entry_diagnostics( "highest_price_time": coordinator.data.energy_today.highest_price_time, "lowest_price_time": coordinator.data.energy_today.lowest_price_time, "percentage_of_max": coordinator.data.energy_today.pct_of_max_price, + "hours_priced_equal_or_lower": coordinator.data.energy_today.hours_priced_equal_or_lower, }, "gas": { "current_hour_price": get_gas_price(coordinator.data, 0), diff --git a/homeassistant/components/energyzero/sensor.py b/homeassistant/components/energyzero/sensor.py index 2d3a8954220..2468e5e68bf 100644 --- a/homeassistant/components/energyzero/sensor.py +++ b/homeassistant/components/energyzero/sensor.py @@ -13,7 +13,13 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CURRENCY_EURO, PERCENTAGE, UnitOfEnergy, UnitOfVolume +from homeassistant.const import ( + CURRENCY_EURO, + PERCENTAGE, + UnitOfEnergy, + UnitOfTime, + UnitOfVolume, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -114,6 +120,14 @@ SENSORS: tuple[EnergyZeroSensorEntityDescription, ...] = ( icon="mdi:percent", value_fn=lambda data: data.energy_today.pct_of_max_price, ), + EnergyZeroSensorEntityDescription( + key="hours_priced_equal_or_lower", + translation_key="hours_priced_equal_or_lower", + service_type="today_energy", + native_unit_of_measurement=UnitOfTime.HOURS, + icon="mdi:clock", + value_fn=lambda data: data.energy_today.hours_priced_equal_or_lower, + ), ) diff --git a/homeassistant/components/energyzero/strings.json b/homeassistant/components/energyzero/strings.json index 93fb264b01d..a27ce236c28 100644 --- a/homeassistant/components/energyzero/strings.json +++ b/homeassistant/components/energyzero/strings.json @@ -37,9 +37,6 @@ }, "hours_priced_equal_or_lower": { "name": "Hours priced equal or lower than current - today" - }, - "hours_priced_equal_or_higher": { - "name": "Hours priced equal or higher than current - today" } } } diff --git a/tests/components/energyzero/snapshots/test_diagnostics.ambr b/tests/components/energyzero/snapshots/test_diagnostics.ambr index 488e01e8d18..90c11ecfc6f 100644 --- a/tests/components/energyzero/snapshots/test_diagnostics.ambr +++ b/tests/components/energyzero/snapshots/test_diagnostics.ambr @@ -5,6 +5,7 @@ 'average_price': 0.37, 'current_hour_price': 0.49, 'highest_price_time': '2022-12-07T16:00:00+00:00', + 'hours_priced_equal_or_lower': 23, 'lowest_price_time': '2022-12-07T02:00:00+00:00', 'max_price': 0.55, 'min_price': 0.26, @@ -26,6 +27,7 @@ 'average_price': 0.37, 'current_hour_price': 0.49, 'highest_price_time': '2022-12-07T16:00:00+00:00', + 'hours_priced_equal_or_lower': 23, 'lowest_price_time': '2022-12-07T02:00:00+00:00', 'max_price': 0.55, 'min_price': 0.26, diff --git a/tests/components/energyzero/snapshots/test_sensor.ambr b/tests/components/energyzero/snapshots/test_sensor.ambr index 619813c52c1..e51aef980d1 100644 --- a/tests/components/energyzero/snapshots/test_sensor.ambr +++ b/tests/components/energyzero/snapshots/test_sensor.ambr @@ -651,6 +651,71 @@ 'via_device_id': None, }) # --- +# name: test_sensor[sensor.energyzero_today_energy_hours_priced_equal_or_lower-today_energy_hours_priced_equal_or_lower-today_energy] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by EnergyZero', + 'friendly_name': 'Energy market price Hours priced equal or lower than current - today', + 'icon': 'mdi:clock', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_energy_hours_priced_equal_or_lower', + 'last_changed': , + 'last_updated': , + 'state': '23', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_hours_priced_equal_or_lower-today_energy_hours_priced_equal_or_lower-today_energy].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energyzero_today_energy_hours_priced_equal_or_lower', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:clock', + 'original_name': 'Hours priced equal or lower than current - today', + 'platform': 'energyzero', + 'supported_features': 0, + 'translation_key': 'hours_priced_equal_or_lower', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_hours_priced_equal_or_lower-today_energy_hours_priced_equal_or_lower-today_energy].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'is_new': False, + 'manufacturer': 'EnergyZero', + 'model': None, + 'name': 'Energy market price', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_sensor[sensor.energyzero_today_energy_max_price-today_energy_max_price-today_energy] StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/energyzero/test_sensor.py b/tests/components/energyzero/test_sensor.py index 466e754df27..6c7eec9d5d8 100644 --- a/tests/components/energyzero/test_sensor.py +++ b/tests/components/energyzero/test_sensor.py @@ -41,6 +41,11 @@ pytestmark = [pytest.mark.freeze_time("2022-12-07 15:00:00")] "today_energy_highest_price_time", "today_energy", ), + ( + "sensor.energyzero_today_energy_hours_priced_equal_or_lower", + "today_energy_hours_priced_equal_or_lower", + "today_energy", + ), ( "sensor.energyzero_today_gas_current_hour_price", "today_gas_current_hour_price", From e471110288d597dac63ceef3dc1e1fa716da2a73 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 24 Aug 2023 00:52:04 +0200 Subject: [PATCH 014/124] Use snapshot assertion for August diagnostics test (#98901) --- .../august/snapshots/test_diagnostics.ambr | 125 +++++++++++++++++ tests/components/august/test_diagnostics.py | 127 +----------------- 2 files changed, 131 insertions(+), 121 deletions(-) create mode 100644 tests/components/august/snapshots/test_diagnostics.ambr diff --git a/tests/components/august/snapshots/test_diagnostics.ambr b/tests/components/august/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..b394255c555 --- /dev/null +++ b/tests/components/august/snapshots/test_diagnostics.ambr @@ -0,0 +1,125 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'brand': 'august', + 'doorbells': dict({ + 'K98GiDT45GUL': dict({ + 'HouseID': '**REDACTED**', + 'LockID': 'BBBB1F5F11114C24CCCC97571DD6AAAA', + 'appID': 'august-iphone', + 'caps': list([ + 'reconnect', + ]), + 'createdAt': '2016-11-26T22:27:11.176Z', + 'doorbellID': 'K98GiDT45GUL', + 'doorbellServerURL': 'https://doorbells.august.com', + 'dvrSubscriptionSetupDone': True, + 'firmwareVersion': '2.3.0-RC153+201711151527', + 'installDate': '2016-11-26T22:27:11.176Z', + 'installUserID': '**REDACTED**', + 'name': 'Front Door', + 'pubsubChannel': '**REDACTED**', + 'recentImage': '**REDACTED**', + 'serialNumber': 'tBXZR0Z35E', + 'settings': dict({ + 'ABREnabled': True, + 'IREnabled': True, + 'IVAEnabled': False, + 'JPGQuality': 70, + 'batteryLowThreshold': 3.1, + 'batteryRun': False, + 'batteryUseThreshold': 3.4, + 'bitrateCeiling': 512000, + 'buttonpush_notifications': True, + 'debug': False, + 'directLink': True, + 'initialBitrate': 384000, + 'irConfiguration': 8448272, + 'keepEncoderRunning': True, + 'micVolume': 100, + 'minACNoScaling': 40, + 'motion_notifications': True, + 'notify_when_offline': True, + 'overlayEnabled': True, + 'ringSoundEnabled': True, + 'speakerVolume': 92, + 'turnOffCamera': False, + 'videoResolution': '640x480', + }), + 'status': 'doorbell_call_status_online', + 'status_timestamp': 1512811834532, + 'telemetry': dict({ + 'BSSID': '88:ee:00:dd:aa:11', + 'SSID': 'foo_ssid', + 'ac_in': 23.856874, + 'battery': 4.061763, + 'battery_soc': 96, + 'battery_soh': 95, + 'date': '2017-12-10 08:05:12', + 'doorbell_low_battery': False, + 'ip_addr': '10.0.1.11', + 'link_quality': 54, + 'load_average': '0.50 0.47 0.35 1/154 9345', + 'signal_level': -56, + 'steady_ac_in': 22.196405, + 'temperature': 28.25, + 'updated_at': '2017-12-10T08:05:13.650Z', + 'uptime': '16168.75 13830.49', + 'wifi_freq': 5745, + }), + 'updatedAt': '2017-12-10T08:05:13.650Z', + }), + }), + 'locks': dict({ + 'online_with_doorsense': dict({ + 'Bridge': dict({ + '_id': 'bridgeid', + 'deviceModel': 'august-connect', + 'firmwareVersion': '2.2.1', + 'hyperBridge': True, + 'mfgBridgeID': 'C5WY200WSH', + 'operative': True, + 'status': dict({ + 'current': 'online', + 'lastOffline': '2000-00-00T00:00:00.447Z', + 'lastOnline': '2000-00-00T00:00:00.447Z', + 'updated': '2000-00-00T00:00:00.447Z', + }), + }), + 'Calibrated': False, + 'Created': '2000-00-00T00:00:00.447Z', + 'HouseID': '**REDACTED**', + 'HouseName': 'Test', + 'LockID': 'online_with_doorsense', + 'LockName': 'Online door with doorsense', + 'LockStatus': dict({ + 'dateTime': '2017-12-10T04:48:30.272Z', + 'doorState': 'open', + 'isLockStatusChanged': False, + 'status': 'locked', + 'valid': True, + }), + 'SerialNumber': 'XY', + 'Type': 1001, + 'Updated': '2000-00-00T00:00:00.447Z', + 'battery': 0.922, + 'currentFirmwareVersion': 'undefined-4.3.0-1.8.14', + 'homeKitEnabled': True, + 'hostLockInfo': dict({ + 'manufacturer': 'yale', + 'productID': 1536, + 'productTypeID': 32770, + 'serialNumber': 'ABC', + }), + 'isGalileo': False, + 'macAddress': '12:22', + 'pins': '**REDACTED**', + 'pubsubChannel': '**REDACTED**', + 'skuNumber': 'AUG-MD01', + 'supportsEntryCodes': True, + 'timeZone': 'Pacific/Hawaii', + 'zWaveEnabled': False, + }), + }), + }) +# --- diff --git a/tests/components/august/test_diagnostics.py b/tests/components/august/test_diagnostics.py index c15ccfd0119..72008f02d03 100644 --- a/tests/components/august/test_diagnostics.py +++ b/tests/components/august/test_diagnostics.py @@ -1,4 +1,6 @@ """Test august diagnostics.""" +from syrupy import SnapshotAssertion + from homeassistant.core import HomeAssistant from .mocks import ( @@ -12,7 +14,9 @@ from tests.typing import ClientSessionGenerator async def test_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test generating diagnostics for a config entry.""" lock_one = await _mock_lock_from_fixture( @@ -23,123 +27,4 @@ async def test_diagnostics( entry, _ = await _create_august_api_with_devices(hass, [lock_one, doorbell_one]) diag = await get_diagnostics_for_config_entry(hass, hass_client, entry) - assert diag == { - "doorbells": { - "K98GiDT45GUL": { - "HouseID": "**REDACTED**", - "LockID": "BBBB1F5F11114C24CCCC97571DD6AAAA", - "appID": "august-iphone", - "caps": ["reconnect"], - "createdAt": "2016-11-26T22:27:11.176Z", - "doorbellID": "K98GiDT45GUL", - "doorbellServerURL": "https://doorbells.august.com", - "dvrSubscriptionSetupDone": True, - "firmwareVersion": "2.3.0-RC153+201711151527", - "installDate": "2016-11-26T22:27:11.176Z", - "installUserID": "**REDACTED**", - "name": "Front Door", - "pubsubChannel": "**REDACTED**", - "recentImage": "**REDACTED**", - "serialNumber": "tBXZR0Z35E", - "settings": { - "ABREnabled": True, - "IREnabled": True, - "IVAEnabled": False, - "JPGQuality": 70, - "batteryLowThreshold": 3.1, - "batteryRun": False, - "batteryUseThreshold": 3.4, - "bitrateCeiling": 512000, - "buttonpush_notifications": True, - "debug": False, - "directLink": True, - "initialBitrate": 384000, - "irConfiguration": 8448272, - "keepEncoderRunning": True, - "micVolume": 100, - "minACNoScaling": 40, - "motion_notifications": True, - "notify_when_offline": True, - "overlayEnabled": True, - "ringSoundEnabled": True, - "speakerVolume": 92, - "turnOffCamera": False, - "videoResolution": "640x480", - }, - "status": "doorbell_call_status_online", - "status_timestamp": 1512811834532, - "telemetry": { - "BSSID": "88:ee:00:dd:aa:11", - "SSID": "foo_ssid", - "ac_in": 23.856874, - "battery": 4.061763, - "battery_soc": 96, - "battery_soh": 95, - "date": "2017-12-10 08:05:12", - "doorbell_low_battery": False, - "ip_addr": "10.0.1.11", - "link_quality": 54, - "load_average": "0.50 0.47 0.35 1/154 9345", - "signal_level": -56, - "steady_ac_in": 22.196405, - "temperature": 28.25, - "updated_at": "2017-12-10T08:05:13.650Z", - "uptime": "16168.75 13830.49", - "wifi_freq": 5745, - }, - "updatedAt": "2017-12-10T08:05:13.650Z", - } - }, - "locks": { - "online_with_doorsense": { - "Bridge": { - "_id": "bridgeid", - "deviceModel": "august-connect", - "firmwareVersion": "2.2.1", - "hyperBridge": True, - "mfgBridgeID": "C5WY200WSH", - "operative": True, - "status": { - "current": "online", - "lastOffline": "2000-00-00T00:00:00.447Z", - "lastOnline": "2000-00-00T00:00:00.447Z", - "updated": "2000-00-00T00:00:00.447Z", - }, - }, - "Calibrated": False, - "Created": "2000-00-00T00:00:00.447Z", - "HouseID": "**REDACTED**", - "HouseName": "Test", - "LockID": "online_with_doorsense", - "LockName": "Online door with doorsense", - "LockStatus": { - "dateTime": "2017-12-10T04:48:30.272Z", - "doorState": "open", - "isLockStatusChanged": False, - "status": "locked", - "valid": True, - }, - "SerialNumber": "XY", - "Type": 1001, - "Updated": "2000-00-00T00:00:00.447Z", - "battery": 0.922, - "currentFirmwareVersion": "undefined-4.3.0-1.8.14", - "homeKitEnabled": True, - "hostLockInfo": { - "manufacturer": "yale", - "productID": 1536, - "productTypeID": 32770, - "serialNumber": "ABC", - }, - "isGalileo": False, - "macAddress": "12:22", - "pins": "**REDACTED**", - "pubsubChannel": "**REDACTED**", - "skuNumber": "AUG-MD01", - "supportsEntryCodes": True, - "timeZone": "Pacific/Hawaii", - "zWaveEnabled": False, - } - }, - "brand": "august", - } + assert diag == snapshot From 3b4774d9edc81eaae30447e2a8846f85467dd8ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Thu, 24 Aug 2023 01:54:02 +0300 Subject: [PATCH 015/124] Remove unnnecessary pylint configs from components/[a-d]* (#98911) --- homeassistant/components/abode/camera.py | 2 +- homeassistant/components/aten_pe/switch.py | 2 +- homeassistant/components/avea/light.py | 2 +- homeassistant/components/azure_service_bus/notify.py | 6 +++--- homeassistant/components/beewi_smartclim/sensor.py | 2 +- homeassistant/components/bloomsky/binary_sensor.py | 2 +- homeassistant/components/bloomsky/sensor.py | 2 +- homeassistant/components/bluetooth/wrappers.py | 2 +- .../components/bluetooth_tracker/device_tracker.py | 2 +- homeassistant/components/browser/__init__.py | 3 +-- homeassistant/components/caldav/calendar.py | 1 - homeassistant/components/color_extractor/__init__.py | 2 +- homeassistant/components/config/config_entries.py | 4 ---- homeassistant/components/conversation/default_agent.py | 4 +--- homeassistant/components/decora/light.py | 4 ++-- homeassistant/components/decora_wifi/light.py | 1 - homeassistant/components/demo/light.py | 2 +- homeassistant/components/dhcp/__init__.py | 4 +--- homeassistant/components/digital_ocean/binary_sensor.py | 2 +- homeassistant/components/digital_ocean/switch.py | 2 +- .../components/dlib_face_detect/image_processing.py | 3 +-- .../components/dlib_face_identify/image_processing.py | 1 - homeassistant/components/dsmr/sensor.py | 2 +- homeassistant/components/duckdns/__init__.py | 2 +- tests/components/alexa/test_flash_briefings.py | 1 - tests/components/alexa/test_intent.py | 1 - tests/components/api/test_init.py | 7 ------- tests/components/arcam_fmj/test_media_player.py | 4 ++-- tests/components/assist_pipeline/test_select.py | 1 - tests/components/balboa/conftest.py | 2 +- tests/components/cast/test_media_player.py | 1 - tests/components/dhcp/test_init.py | 2 +- 32 files changed, 27 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/abode/camera.py b/homeassistant/components/abode/camera.py index afe017bfcc7..326e845b16b 100644 --- a/homeassistant/components/abode/camera.py +++ b/homeassistant/components/abode/camera.py @@ -30,7 +30,7 @@ async def async_setup_entry( data: AbodeSystem = hass.data[DOMAIN] async_add_entities( - AbodeCamera(data, device, TIMELINE.CAPTURE_IMAGE) # pylint: disable=no-member + AbodeCamera(data, device, TIMELINE.CAPTURE_IMAGE) for device in data.abode.get_devices(generic_type=CONST.TYPE_CAMERA) ) diff --git a/homeassistant/components/aten_pe/switch.py b/homeassistant/components/aten_pe/switch.py index 13214b04628..3293a3e7a09 100644 --- a/homeassistant/components/aten_pe/switch.py +++ b/homeassistant/components/aten_pe/switch.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging from typing import Any -from atenpdu import AtenPE, AtenPEError # pylint: disable=import-error +from atenpdu import AtenPE, AtenPEError import voluptuous as vol from homeassistant.components.switch import ( diff --git a/homeassistant/components/avea/light.py b/homeassistant/components/avea/light.py index 5b306b058d3..a33fbfeab79 100644 --- a/homeassistant/components/avea/light.py +++ b/homeassistant/components/avea/light.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -import avea # pylint: disable=import-error +import avea from homeassistant.components.light import ( ATTR_BRIGHTNESS, diff --git a/homeassistant/components/azure_service_bus/notify.py b/homeassistant/components/azure_service_bus/notify.py index b318c5224df..23235a23dff 100644 --- a/homeassistant/components/azure_service_bus/notify.py +++ b/homeassistant/components/azure_service_bus/notify.py @@ -4,13 +4,13 @@ from __future__ import annotations import json import logging -# pylint: disable-next=import-error, no-name-in-module +# pylint: disable-next=no-name-in-module from azure.servicebus import ServiceBusMessage -# pylint: disable-next=import-error, no-name-in-module +# pylint: disable-next=no-name-in-module from azure.servicebus.aio import ServiceBusClient, ServiceBusSender -# pylint: disable-next=import-error, no-name-in-module +# pylint: disable-next=no-name-in-module from azure.servicebus.exceptions import ( MessagingEntityNotFoundError, ServiceBusConnectionError, diff --git a/homeassistant/components/beewi_smartclim/sensor.py b/homeassistant/components/beewi_smartclim/sensor.py index 0bb3a5bbb69..08f2410ee06 100644 --- a/homeassistant/components/beewi_smartclim/sensor.py +++ b/homeassistant/components/beewi_smartclim/sensor.py @@ -1,7 +1,7 @@ """Platform for beewi_smartclim integration.""" from __future__ import annotations -from beewi_smartclim import BeewiSmartClimPoller # pylint: disable=import-error +from beewi_smartclim import BeewiSmartClimPoller import voluptuous as vol from homeassistant.components.sensor import ( diff --git a/homeassistant/components/bloomsky/binary_sensor.py b/homeassistant/components/bloomsky/binary_sensor.py index 7b59039a89e..b99fdfe0c78 100644 --- a/homeassistant/components/bloomsky/binary_sensor.py +++ b/homeassistant/components/bloomsky/binary_sensor.py @@ -49,7 +49,7 @@ def setup_platform( class BloomSkySensor(BinarySensorEntity): """Representation of a single binary sensor in a BloomSky device.""" - def __init__(self, bs, device, sensor_name): # pylint: disable=invalid-name + def __init__(self, bs, device, sensor_name): """Initialize a BloomSky binary sensor.""" self._bloomsky = bs self._device_id = device["DeviceID"] diff --git a/homeassistant/components/bloomsky/sensor.py b/homeassistant/components/bloomsky/sensor.py index 6cefcdb3346..35c9a40a46a 100644 --- a/homeassistant/components/bloomsky/sensor.py +++ b/homeassistant/components/bloomsky/sensor.py @@ -93,7 +93,7 @@ def setup_platform( class BloomSkySensor(SensorEntity): """Representation of a single sensor in a BloomSky device.""" - def __init__(self, bs, device, sensor_name): # pylint: disable=invalid-name + def __init__(self, bs, device, sensor_name): """Initialize a BloomSky sensor.""" self._bloomsky = bs self._device_id = device["DeviceID"] diff --git a/homeassistant/components/bluetooth/wrappers.py b/homeassistant/components/bluetooth/wrappers.py index 2ae036080f8..3a0abc855b5 100644 --- a/homeassistant/components/bluetooth/wrappers.py +++ b/homeassistant/components/bluetooth/wrappers.py @@ -199,7 +199,7 @@ class HaBleakClientWrapper(BleakClient): when an integration does this. """ - def __init__( # pylint: disable=super-init-not-called, keyword-arg-before-vararg + def __init__( # pylint: disable=super-init-not-called self, address_or_ble_device: str | BLEDevice, disconnected_callback: Callable[[BleakClient], None] | None = None, diff --git a/homeassistant/components/bluetooth_tracker/device_tracker.py b/homeassistant/components/bluetooth_tracker/device_tracker.py index f4fc6a8df08..4bfbe72d8b5 100644 --- a/homeassistant/components/bluetooth_tracker/device_tracker.py +++ b/homeassistant/components/bluetooth_tracker/device_tracker.py @@ -7,7 +7,7 @@ from datetime import datetime, timedelta import logging from typing import Final -import bluetooth # pylint: disable=import-error +import bluetooth from bt_proximity import BluetoothRSSI import voluptuous as vol diff --git a/homeassistant/components/browser/__init__.py b/homeassistant/components/browser/__init__.py index b01f04fa140..9dc3e1fe66a 100644 --- a/homeassistant/components/browser/__init__.py +++ b/homeassistant/components/browser/__init__.py @@ -16,8 +16,7 @@ SERVICE_BROWSE_URL = "browse_url" SERVICE_BROWSE_URL_SCHEMA = vol.Schema( { - # pylint: disable-next=no-value-for-parameter - vol.Required(ATTR_URL, default=ATTR_URL_DEFAULT): vol.Url() + vol.Required(ATTR_URL, default=ATTR_URL_DEFAULT): vol.Url(), } ) diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index 57bf8e81e03..f30f79f7275 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -43,7 +43,6 @@ OFFSET = "!!" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { - # pylint: disable=no-value-for-parameter vol.Required(CONF_URL): vol.Url(), vol.Optional(CONF_CALENDARS, default=[]): vol.All(cv.ensure_list, [cv.string]), vol.Inclusive(CONF_USERNAME, "authentication"): cv.string, diff --git a/homeassistant/components/color_extractor/__init__.py b/homeassistant/components/color_extractor/__init__.py index 0e27f396c6d..fb04ebb76a4 100644 --- a/homeassistant/components/color_extractor/__init__.py +++ b/homeassistant/components/color_extractor/__init__.py @@ -81,7 +81,7 @@ async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool: except UnidentifiedImageError as ex: _LOGGER.error( "Bad image from %s '%s' provided, are you sure it's an image? %s", - image_type, # pylint: disable=used-before-assignment + image_type, image_reference, ex, ) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 9691994512c..77e2548d424 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -143,7 +143,6 @@ class ConfigManagerFlowIndexView(FlowManagerIndexView): ) async def post(self, request): """Handle a POST request.""" - # pylint: disable=no-value-for-parameter try: return await super().post(request) except DependencyError as exc: @@ -175,7 +174,6 @@ class ConfigManagerFlowResourceView(FlowManagerResourceView): ) async def post(self, request, flow_id): """Handle a POST request.""" - # pylint: disable=no-value-for-parameter return await super().post(request, flow_id) def _prepare_result_json(self, result): @@ -212,7 +210,6 @@ class OptionManagerFlowIndexView(FlowManagerIndexView): handler in request is entry_id. """ - # pylint: disable=no-value-for-parameter return await super().post(request) @@ -234,7 +231,6 @@ class OptionManagerFlowResourceView(FlowManagerResourceView): ) async def post(self, request, flow_id): """Handle a POST request.""" - # pylint: disable=no-value-for-parameter return await super().post(request, flow_id) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 04aafc8a99d..09245fde8dc 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -54,9 +54,7 @@ _DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that" _ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"] REGEX_TYPE = type(re.compile("")) -TRIGGER_CALLBACK_TYPE = Callable[ # pylint: disable=invalid-name - [str, RecognizeResult], Awaitable[str | None] -] +TRIGGER_CALLBACK_TYPE = Callable[[str, RecognizeResult], Awaitable[str | None]] def json_load(fp: IO[str]) -> JsonObjectType: diff --git a/homeassistant/components/decora/light.py b/homeassistant/components/decora/light.py index b46732178b8..d060b69c3f6 100644 --- a/homeassistant/components/decora/light.py +++ b/homeassistant/components/decora/light.py @@ -8,8 +8,8 @@ import logging import time from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar -from bluepy.btle import BTLEException # pylint: disable=import-error -import decora # pylint: disable=import-error +from bluepy.btle import BTLEException +import decora import voluptuous as vol from homeassistant import util diff --git a/homeassistant/components/decora_wifi/light.py b/homeassistant/components/decora_wifi/light.py index c103636563c..a9d43736743 100644 --- a/homeassistant/components/decora_wifi/light.py +++ b/homeassistant/components/decora_wifi/light.py @@ -4,7 +4,6 @@ from __future__ import annotations import logging from typing import Any -# pylint: disable=import-error from decora_wifi import DecoraWiFiSession from decora_wifi.models.person import Person from decora_wifi.models.residence import Residence diff --git a/homeassistant/components/demo/light.py b/homeassistant/components/demo/light.py index 7009df75caa..d8451bdd683 100644 --- a/homeassistant/components/demo/light.py +++ b/homeassistant/components/demo/light.py @@ -106,7 +106,7 @@ class DemoLight(LightEntity): state: bool, available: bool = False, brightness: int = 180, - ct: int | None = None, # pylint: disable=invalid-name + ct: int | None = None, effect_list: list[str] | None = None, effect: str | None = None, hs_color: tuple[int, int] | None = None, diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index b3cfd1b65f2..29b25d0781b 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -415,9 +415,7 @@ class DHCPWatcher(WatcherBase): """Start watching for dhcp packets.""" # Local import because importing from scapy has side effects such as opening # sockets - from scapy import ( # pylint: disable=import-outside-toplevel,unused-import # noqa: F401 - arch, - ) + from scapy import arch # pylint: disable=import-outside-toplevel # noqa: F401 from scapy.layers.dhcp import DHCP # pylint: disable=import-outside-toplevel from scapy.layers.inet import IP # pylint: disable=import-outside-toplevel from scapy.layers.l2 import Ether # pylint: disable=import-outside-toplevel diff --git a/homeassistant/components/digital_ocean/binary_sensor.py b/homeassistant/components/digital_ocean/binary_sensor.py index 59c6f7961c2..e2bd09ba15e 100644 --- a/homeassistant/components/digital_ocean/binary_sensor.py +++ b/homeassistant/components/digital_ocean/binary_sensor.py @@ -65,7 +65,7 @@ class DigitalOceanBinarySensor(BinarySensorEntity): _attr_attribution = ATTRIBUTION - def __init__(self, do, droplet_id): # pylint: disable=invalid-name + def __init__(self, do, droplet_id): """Initialize a new Digital Ocean sensor.""" self._digital_ocean = do self._droplet_id = droplet_id diff --git a/homeassistant/components/digital_ocean/switch.py b/homeassistant/components/digital_ocean/switch.py index 2791d83d6bc..b226dbab0a9 100644 --- a/homeassistant/components/digital_ocean/switch.py +++ b/homeassistant/components/digital_ocean/switch.py @@ -63,7 +63,7 @@ class DigitalOceanSwitch(SwitchEntity): _attr_attribution = ATTRIBUTION - def __init__(self, do, droplet_id): # pylint: disable=invalid-name + def __init__(self, do, droplet_id): """Initialize a new Digital Ocean sensor.""" self._digital_ocean = do self._droplet_id = droplet_id diff --git a/homeassistant/components/dlib_face_detect/image_processing.py b/homeassistant/components/dlib_face_detect/image_processing.py index dcae1c1eb40..42031b28844 100644 --- a/homeassistant/components/dlib_face_detect/image_processing.py +++ b/homeassistant/components/dlib_face_detect/image_processing.py @@ -3,7 +3,7 @@ from __future__ import annotations import io -import face_recognition # pylint: disable=import-error +import face_recognition from homeassistant.components.image_processing import ImageProcessingFaceEntity from homeassistant.const import ATTR_LOCATION, CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE @@ -11,7 +11,6 @@ from homeassistant.core import HomeAssistant, split_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -# pylint: disable=unused-import from homeassistant.components.image_processing import ( # noqa: F401, isort:skip PLATFORM_SCHEMA, ) diff --git a/homeassistant/components/dlib_face_identify/image_processing.py b/homeassistant/components/dlib_face_identify/image_processing.py index 373f2c2b928..e6aaa6848d0 100644 --- a/homeassistant/components/dlib_face_identify/image_processing.py +++ b/homeassistant/components/dlib_face_identify/image_processing.py @@ -4,7 +4,6 @@ from __future__ import annotations import io import logging -# pylint: disable=import-error import face_recognition import voluptuous as vol diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 3d198e38f36..e4f9d0e9ab9 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -509,7 +509,7 @@ async def async_setup_entry( if stop_listener and ( hass.state == CoreState.not_running or hass.is_running ): - stop_listener() # pylint: disable=not-callable + stop_listener() if transport: transport.close() diff --git a/homeassistant/components/duckdns/__init__.py b/homeassistant/components/duckdns/__init__.py index 278c3c989db..d477bd41a26 100644 --- a/homeassistant/components/duckdns/__init__.py +++ b/homeassistant/components/duckdns/__init__.py @@ -138,6 +138,6 @@ def async_track_time_interval_backoff( def remove_listener() -> None: """Remove interval listener.""" if remove: - remove() # pylint: disable=not-callable + remove() return remove_listener diff --git a/tests/components/alexa/test_flash_briefings.py b/tests/components/alexa/test_flash_briefings.py index 0a4acda79f5..c6c2b3cc421 100644 --- a/tests/components/alexa/test_flash_briefings.py +++ b/tests/components/alexa/test_flash_briefings.py @@ -14,7 +14,6 @@ SESSION_ID = "amzn1.echo-api.session.0000000-0000-0000-0000-00000000000" APPLICATION_ID = "amzn1.echo-sdk-ams.app.000000-d0ed-0000-ad00-000000d00ebe" REQUEST_ID = "amzn1.echo-api.request.0000000-0000-0000-0000-00000000000" -# pylint: disable=invalid-name calls = [] NPR_NEWS_MP3_URL = "https://pd.npr.org/anon.npr-mp3/npr/news/newscast.mp3" diff --git a/tests/components/alexa/test_intent.py b/tests/components/alexa/test_intent.py index 03546c0ed22..c63825b3c12 100644 --- a/tests/components/alexa/test_intent.py +++ b/tests/components/alexa/test_intent.py @@ -19,7 +19,6 @@ REQUEST_ID = "amzn1.echo-api.request.0000000-0000-0000-0000-00000000000" AUTHORITY_ID = "amzn1.er-authority.000000-d0ed-0000-ad00-000000d00ebe.ZODIAC" BUILTIN_AUTH_ID = "amzn1.er-authority.000000-d0ed-0000-ad00-000000d00ebe.TEST" -# pylint: disable=invalid-name calls = [] NPR_NEWS_MP3_URL = "https://pd.npr.org/anon.npr-mp3/npr/news/newscast.mp3" diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index 5ba9d60996b..116529b02a4 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -81,7 +81,6 @@ async def test_api_state_change( assert hass.states.get("test.test").state == "debug_state_change2" -# pylint: disable=invalid-name async def test_api_state_change_of_non_existing_entity( hass: HomeAssistant, mock_api_client: TestClient ) -> None: @@ -97,7 +96,6 @@ async def test_api_state_change_of_non_existing_entity( assert hass.states.get("test_entity.that_does_not_exist").state == new_state -# pylint: disable=invalid-name async def test_api_state_change_with_bad_data( hass: HomeAssistant, mock_api_client: TestClient ) -> None: @@ -109,7 +107,6 @@ async def test_api_state_change_with_bad_data( assert resp.status == HTTPStatus.BAD_REQUEST -# pylint: disable=invalid-name async def test_api_state_change_to_zero_value( hass: HomeAssistant, mock_api_client: TestClient ) -> None: @@ -127,7 +124,6 @@ async def test_api_state_change_to_zero_value( assert resp.status == HTTPStatus.OK -# pylint: disable=invalid-name async def test_api_state_change_push( hass: HomeAssistant, mock_api_client: TestClient ) -> None: @@ -154,7 +150,6 @@ async def test_api_state_change_push( assert len(events) == 1 -# pylint: disable=invalid-name async def test_api_fire_event_with_no_data( hass: HomeAssistant, mock_api_client: TestClient ) -> None: @@ -174,7 +169,6 @@ async def test_api_fire_event_with_no_data( assert len(test_value) == 1 -# pylint: disable=invalid-name async def test_api_fire_event_with_data( hass: HomeAssistant, mock_api_client: TestClient ) -> None: @@ -199,7 +193,6 @@ async def test_api_fire_event_with_data( assert len(test_value) == 1 -# pylint: disable=invalid-name async def test_api_fire_event_with_invalid_json( hass: HomeAssistant, mock_api_client: TestClient ) -> None: diff --git a/tests/components/arcam_fmj/test_media_player.py b/tests/components/arcam_fmj/test_media_player.py index b9c86140cb9..9287e8dbc18 100644 --- a/tests/components/arcam_fmj/test_media_player.py +++ b/tests/components/arcam_fmj/test_media_player.py @@ -228,9 +228,9 @@ async def test_sound_mode_list(player, state, modes, modes_enum) -> None: async def test_is_volume_muted(player, state) -> None: """Test muted.""" state.get_mute.return_value = True - assert player.is_volume_muted is True # pylint: disable=singleton-comparison + assert player.is_volume_muted is True state.get_mute.return_value = False - assert player.is_volume_muted is False # pylint: disable=singleton-comparison + assert player.is_volume_muted is False state.get_mute.return_value = None assert player.is_volume_muted is None diff --git a/tests/components/assist_pipeline/test_select.py b/tests/components/assist_pipeline/test_select.py index 1868d9b005e..1419eb58750 100644 --- a/tests/components/assist_pipeline/test_select.py +++ b/tests/components/assist_pipeline/test_select.py @@ -26,7 +26,6 @@ from tests.common import MockConfigEntry, MockPlatform, mock_entity_platform class SelectPlatform(MockPlatform): """Fake select platform.""" - # pylint: disable=method-hidden async def async_setup_entry( self, hass: HomeAssistant, diff --git a/tests/components/balboa/conftest.py b/tests/components/balboa/conftest.py index 04447d0b3cc..e5da4582454 100644 --- a/tests/components/balboa/conftest.py +++ b/tests/components/balboa/conftest.py @@ -29,7 +29,7 @@ def client_fixture() -> Generator[MagicMock, None, None]: client = mock_balboa.return_value callback: list[Callable] = [] - def on(_, _callback: Callable): # pylint: disable=invalid-name + def on(_, _callback: Callable): callback.append(_callback) return lambda: None diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index 6f7a13b47af..3d9feb3e43c 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -42,7 +42,6 @@ from tests.components.media_player import common from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import WebSocketGenerator -# pylint: disable=invalid-name FakeUUID = UUID("57355bce-9364-4aa6-ac1e-eb849dccf9e2") FakeUUID2 = UUID("57355bce-9364-4aa6-ac1e-eb849dccf9e4") FakeGroupUUID = UUID("57355bce-9364-4aa6-ac1e-eb849dccf9e3") diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py index 0754febfc76..076138080cc 100644 --- a/tests/components/dhcp/test_init.py +++ b/tests/components/dhcp/test_init.py @@ -4,7 +4,7 @@ import threading from unittest.mock import MagicMock, patch import pytest -from scapy import arch # pylint: disable=unused-import # noqa: F401 +from scapy import arch # noqa: F401 from scapy.error import Scapy_Exception from scapy.layers.dhcp import DHCP from scapy.layers.l2 import Ether From 34b47a2597d27da584ee3a5f4aa92857a3606817 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Thu, 24 Aug 2023 01:56:50 +0300 Subject: [PATCH 016/124] Remove unnnecessary pylint configs from components [m-r]* (#98924) --- homeassistant/components/mailgun/notify.py | 1 - .../components/mobile_app/helpers.py | 2 +- homeassistant/components/mobile_app/notify.py | 1 - homeassistant/components/mpd/media_player.py | 1 - homeassistant/components/mqtt/config_flow.py | 4 +-- .../components/mqtt/light/schema_json.py | 28 +++++++++---------- homeassistant/components/mycroft/notify.py | 2 +- .../components/opencv/image_processing.py | 1 - .../components/owntracks/__init__.py | 1 - .../components/pandora/media_player.py | 2 -- .../components/panel_iframe/__init__.py | 1 - .../auto_repairs/statistics/duplicates.py | 4 --- homeassistant/components/recorder/const.py | 4 +-- .../components/recorder/db_schema.py | 1 - homeassistant/components/recorder/executor.py | 1 - .../components/recorder/history/legacy.py | 4 --- .../components/recorder/history/modern.py | 4 --- .../components/recorder/migration.py | 8 ++---- .../components/recorder/models/time.py | 2 -- homeassistant/components/recorder/queries.py | 8 ------ .../components/recorder/statistics.py | 16 ----------- homeassistant/components/recorder/util.py | 2 +- homeassistant/components/rocketchat/notify.py | 1 - tests/components/mobile_app/conftest.py | 1 - .../owntracks/test_device_tracker.py | 6 ++-- .../auto_repairs/events/test_schema.py | 1 - .../auto_repairs/states/test_schema.py | 1 - .../statistics/test_duplicates.py | 2 -- .../auto_repairs/statistics/test_schema.py | 1 - .../recorder/auto_repairs/test_schema.py | 1 - tests/components/recorder/db_schema_0.py | 1 - tests/components/recorder/db_schema_16.py | 1 - tests/components/recorder/db_schema_18.py | 1 - tests/components/recorder/db_schema_22.py | 1 - tests/components/recorder/db_schema_23.py | 1 - .../db_schema_23_with_newer_columns.py | 1 - tests/components/recorder/db_schema_25.py | 1 - tests/components/recorder/db_schema_28.py | 1 - tests/components/recorder/db_schema_30.py | 1 - tests/components/recorder/db_schema_32.py | 1 - ...est_filters_with_entityfilter_schema_37.py | 1 - tests/components/recorder/test_history.py | 2 -- .../recorder/test_history_db_schema_30.py | 2 -- .../recorder/test_history_db_schema_32.py | 2 -- tests/components/recorder/test_init.py | 2 -- .../recorder/test_migration_from_schema_32.py | 1 - .../recorder/test_purge_v32_schema.py | 1 - tests/components/recorder/test_statistics.py | 2 -- .../recorder/test_statistics_v23_migration.py | 2 -- .../components/recorder/test_v32_migration.py | 1 - .../components/recorder/test_websocket_api.py | 1 - 51 files changed, 23 insertions(+), 116 deletions(-) diff --git a/homeassistant/components/mailgun/notify.py b/homeassistant/components/mailgun/notify.py index 7bea67b596d..b7104d4a0f1 100644 --- a/homeassistant/components/mailgun/notify.py +++ b/homeassistant/components/mailgun/notify.py @@ -31,7 +31,6 @@ ATTR_IMAGES = "images" DEFAULT_SANDBOX = False -# pylint: disable=no-value-for-parameter PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( {vol.Required(CONF_RECIPIENT): vol.Email(), vol.Optional(CONF_SENDER): vol.Email()} ) diff --git a/homeassistant/components/mobile_app/helpers.py b/homeassistant/components/mobile_app/helpers.py index 741b0a400cc..e8460b721a2 100644 --- a/homeassistant/components/mobile_app/helpers.py +++ b/homeassistant/components/mobile_app/helpers.py @@ -144,7 +144,7 @@ def error_response( def supports_encryption() -> bool: """Test if we support encryption.""" try: - import nacl # noqa: F401 pylint: disable=unused-import, import-outside-toplevel + import nacl # noqa: F401 pylint: disable=import-outside-toplevel return True except OSError: diff --git a/homeassistant/components/mobile_app/notify.py b/homeassistant/components/mobile_app/notify.py index 47b997e410c..164f21af15a 100644 --- a/homeassistant/components/mobile_app/notify.py +++ b/homeassistant/components/mobile_app/notify.py @@ -59,7 +59,6 @@ def push_registrations(hass): return targets -# pylint: disable=invalid-name def log_rate_limits(hass, device_name, resp, level=logging.INFO): """Output rate limit log line at given level.""" if ATTR_PUSH_RATE_LIMITS not in resp: diff --git a/homeassistant/components/mpd/media_player.py b/homeassistant/components/mpd/media_player.py index 457f9058242..8eab83b5d41 100644 --- a/homeassistant/components/mpd/media_player.py +++ b/homeassistant/components/mpd/media_player.py @@ -85,7 +85,6 @@ class MpdDevice(MediaPlayerEntity): _attr_media_content_type = MediaType.MUSIC - # pylint: disable=no-member def __init__(self, server, port, password, name): """Initialize the MPD device.""" self.server = server diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index bea8a900a83..9f960b0d909 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -563,9 +563,7 @@ async def async_get_broker_settings( ) schema = vol.Schema({cv.string: cv.template}) schema(validated_user_input[CONF_WS_HEADERS]) - except JSON_DECODE_EXCEPTIONS + ( # pylint: disable=wrong-exception-operation - vol.MultipleInvalid, - ): + except JSON_DECODE_EXCEPTIONS + (vol.MultipleInvalid,): errors["base"] = "bad_ws_headers" return False return True diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 8f710eb5ea6..b7787912161 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -307,31 +307,31 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): self._attr_color_mode = ColorMode.HS self._attr_hs_color = (hue, saturation) elif color_mode == ColorMode.RGB: - r = int(values["color"]["r"]) # pylint: disable=invalid-name - g = int(values["color"]["g"]) # pylint: disable=invalid-name - b = int(values["color"]["b"]) # pylint: disable=invalid-name + r = int(values["color"]["r"]) + g = int(values["color"]["g"]) + b = int(values["color"]["b"]) self._attr_color_mode = ColorMode.RGB self._attr_rgb_color = (r, g, b) elif color_mode == ColorMode.RGBW: - r = int(values["color"]["r"]) # pylint: disable=invalid-name - g = int(values["color"]["g"]) # pylint: disable=invalid-name - b = int(values["color"]["b"]) # pylint: disable=invalid-name - w = int(values["color"]["w"]) # pylint: disable=invalid-name + r = int(values["color"]["r"]) + g = int(values["color"]["g"]) + b = int(values["color"]["b"]) + w = int(values["color"]["w"]) self._attr_color_mode = ColorMode.RGBW self._attr_rgbw_color = (r, g, b, w) elif color_mode == ColorMode.RGBWW: - r = int(values["color"]["r"]) # pylint: disable=invalid-name - g = int(values["color"]["g"]) # pylint: disable=invalid-name - b = int(values["color"]["b"]) # pylint: disable=invalid-name - c = int(values["color"]["c"]) # pylint: disable=invalid-name - w = int(values["color"]["w"]) # pylint: disable=invalid-name + r = int(values["color"]["r"]) + g = int(values["color"]["g"]) + b = int(values["color"]["b"]) + c = int(values["color"]["c"]) + w = int(values["color"]["w"]) self._attr_color_mode = ColorMode.RGBWW self._attr_rgbww_color = (r, g, b, c, w) elif color_mode == ColorMode.WHITE: self._attr_color_mode = ColorMode.WHITE elif color_mode == ColorMode.XY: - x = float(values["color"]["x"]) # pylint: disable=invalid-name - y = float(values["color"]["y"]) # pylint: disable=invalid-name + x = float(values["color"]["x"]) + y = float(values["color"]["y"]) self._attr_color_mode = ColorMode.XY self._attr_xy_color = (x, y) except (KeyError, ValueError): diff --git a/homeassistant/components/mycroft/notify.py b/homeassistant/components/mycroft/notify.py index 172a01017c4..a9dd82caef1 100644 --- a/homeassistant/components/mycroft/notify.py +++ b/homeassistant/components/mycroft/notify.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from mycroftapi import MycroftAPI # pylint: disable=import-error +from mycroftapi import MycroftAPI from homeassistant.components.notify import BaseNotificationService from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/opencv/image_processing.py b/homeassistant/components/opencv/image_processing.py index 41738100cab..89c1a16aa59 100644 --- a/homeassistant/components/opencv/image_processing.py +++ b/homeassistant/components/opencv/image_processing.py @@ -188,7 +188,6 @@ class OpenCVImageProcessor(ImageProcessingEntity): cv_image, scaleFactor=scale, minNeighbors=neighbors, minSize=min_size ) regions = [] - # pylint: disable=invalid-name for x, y, w, h in detections: regions.append((int(x), int(y), int(w), int(h))) total_matches += 1 diff --git a/homeassistant/components/owntracks/__init__.py b/homeassistant/components/owntracks/__init__.py index 560493888d4..1b3d67ce7b4 100644 --- a/homeassistant/components/owntracks/__init__.py +++ b/homeassistant/components/owntracks/__init__.py @@ -278,7 +278,6 @@ class OwnTracksContext: func(**msg) self._pending_msg.clear() - # pylint: disable=method-hidden @callback def async_see(self, **data): """Send a see message to the device tracker.""" diff --git a/homeassistant/components/pandora/media_player.py b/homeassistant/components/pandora/media_player.py index 43410895010..7b09f40c3f1 100644 --- a/homeassistant/components/pandora/media_player.py +++ b/homeassistant/components/pandora/media_player.py @@ -223,11 +223,9 @@ class PandoraMediaPlayer(MediaPlayerEntity): _LOGGER.warning("On unexpected station list page") self._pianobar.sendcontrol("m") # press enter self._pianobar.sendcontrol("m") # do it again b/c an 'i' got in - # pylint: disable=assignment-from-none response = self.update_playing_status() elif match_idx == 3: _LOGGER.debug("Received new playlist list") - # pylint: disable=assignment-from-none response = self.update_playing_status() else: response = self._pianobar.before.decode("utf-8") diff --git a/homeassistant/components/panel_iframe/__init__.py b/homeassistant/components/panel_iframe/__init__.py index 8313bc4ba25..e33e5078288 100644 --- a/homeassistant/components/panel_iframe/__init__.py +++ b/homeassistant/components/panel_iframe/__init__.py @@ -20,7 +20,6 @@ CONFIG_SCHEMA = vol.Schema( DOMAIN: cv.schema_with_slug_keys( vol.Schema( { - # pylint: disable=no-value-for-parameter vol.Optional(CONF_TITLE): cv.string, vol.Optional(CONF_ICON): cv.icon, vol.Optional(CONF_REQUIRE_ADMIN, default=False): cv.boolean, diff --git a/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py b/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py index 8a24dcbf92b..5b7a141bd70 100644 --- a/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py +++ b/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py @@ -37,8 +37,6 @@ def _find_duplicates( literal_column("1").label("is_duplicate"), ) .group_by(table.metadata_id, table.start) - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable .having(func.count() > 1) .subquery() ) @@ -195,8 +193,6 @@ def _find_statistics_meta_duplicates(session: Session) -> list[int]: literal_column("1").label("is_duplicate"), ) .group_by(StatisticsMeta.statistic_id) - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable .having(func.count() > 1) .subquery() ) diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index fc7683db901..724a9589680 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -3,9 +3,7 @@ from enum import StrEnum from homeassistant.const import ATTR_ATTRIBUTION, ATTR_RESTORED, ATTR_SUPPORTED_FEATURES -from homeassistant.helpers.json import ( # noqa: F401 pylint: disable=unused-import - JSON_DUMP, -) +from homeassistant.helpers.json import JSON_DUMP # noqa: F401 DATA_INSTANCE = "recorder_instance" SQLITE_URL_PREFIX = "sqlite://" diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index c99aadb8caa..508874c54e5 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -63,7 +63,6 @@ from .models import ( # SQLAlchemy Schema -# pylint: disable=invalid-name class Base(DeclarativeBase): """Base class for tables.""" diff --git a/homeassistant/components/recorder/executor.py b/homeassistant/components/recorder/executor.py index 6eea2f651c3..3f677e72fdf 100644 --- a/homeassistant/components/recorder/executor.py +++ b/homeassistant/components/recorder/executor.py @@ -39,7 +39,6 @@ class DBInterruptibleThreadPoolExecutor(InterruptibleThreadPoolExecutor): # When the executor gets lost, the weakref callback will wake up # the worker threads. - # pylint: disable=invalid-name def weakref_cb( # type: ignore[no-untyped-def] _: Any, q=self._work_queue, diff --git a/homeassistant/components/recorder/history/legacy.py b/homeassistant/components/recorder/history/legacy.py index 64ce1aa7d55..191c74ac0d4 100644 --- a/homeassistant/components/recorder/history/legacy.py +++ b/homeassistant/components/recorder/history/legacy.py @@ -565,8 +565,6 @@ def _get_states_for_entities_stmt( most_recent_states_for_entities_by_date := ( select( States.entity_id.label("max_entity_id"), - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable func.max(States.last_updated_ts).label("max_last_updated"), ) .filter( @@ -590,8 +588,6 @@ def _get_states_for_entities_stmt( ( most_recent_states_for_entities_by_date := select( States.entity_id.label("max_entity_id"), - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable func.max(States.last_updated).label("max_last_updated"), ) .filter( diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index 393bcfa3676..68c357c0ed4 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -432,8 +432,6 @@ def _get_last_state_changes_single_stmt(metadata_id: int) -> Select: lastest_state_for_metadata_id := ( select( States.metadata_id.label("max_metadata_id"), - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable func.max(States.last_updated_ts).label("max_last_updated"), ) .filter(States.metadata_id == metadata_id) @@ -537,8 +535,6 @@ def _get_start_time_state_for_entities_stmt( most_recent_states_for_entities_by_date := ( select( States.metadata_id.label("max_metadata_id"), - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable func.max(States.last_updated_ts).label("max_last_updated"), ) .filter( diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 8fe1d0482e9..f07e91ddaea 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -524,7 +524,7 @@ def _update_states_table_with_foreign_key_options( return states_key_constraints = Base.metadata.tables[TABLE_STATES].foreign_key_constraints - old_states_table = Table( # noqa: F841 pylint: disable=unused-variable + old_states_table = Table( # noqa: F841 TABLE_STATES, MetaData(), *(alter["old_fk"] for alter in alters) # type: ignore[arg-type] ) @@ -553,9 +553,7 @@ def _drop_foreign_key_constraints( drops.append(ForeignKeyConstraint((), (), name=foreign_key["name"])) # Bind the ForeignKeyConstraints to the table - old_table = Table( # noqa: F841 pylint: disable=unused-variable - table, MetaData(), *drops - ) + old_table = Table(table, MetaData(), *drops) # noqa: F841 for drop in drops: with session_scope(session=session_maker()) as session: @@ -772,8 +770,6 @@ def _apply_update( # noqa: C901 with session_scope(session=session_maker()) as session: if session.query(Statistics.id).count() and ( last_run_string := session.query( - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable func.max(StatisticsRuns.start) ).scalar() ): diff --git a/homeassistant/components/recorder/models/time.py b/homeassistant/components/recorder/models/time.py index 078a982d5ad..40e4afd18a7 100644 --- a/homeassistant/components/recorder/models/time.py +++ b/homeassistant/components/recorder/models/time.py @@ -7,8 +7,6 @@ from typing import overload import homeassistant.util.dt as dt_util -# pylint: disable=invalid-name - _LOGGER = logging.getLogger(__name__) DB_TIMEZONE = "+00:00" diff --git a/homeassistant/components/recorder/queries.py b/homeassistant/components/recorder/queries.py index 49f66fdcd68..71a996f0381 100644 --- a/homeassistant/components/recorder/queries.py +++ b/homeassistant/components/recorder/queries.py @@ -76,8 +76,6 @@ def find_states_metadata_ids(entity_ids: Iterable[str]) -> StatementLambdaElemen def _state_attrs_exist(attr: int | None) -> Select: """Check if a state attributes id exists in the states table.""" - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable return select(func.min(States.attributes_id)).where(States.attributes_id == attr) @@ -315,8 +313,6 @@ def data_ids_exist_in_events_with_fast_in_distinct( def _event_data_id_exist(data_id: int | None) -> Select: """Check if a event data id exists in the events table.""" - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable return select(func.min(Events.data_id)).where(Events.data_id == data_id) @@ -659,8 +655,6 @@ def find_statistics_runs_to_purge( def find_latest_statistics_runs_run_id() -> StatementLambdaElement: """Find the latest statistics_runs run_id.""" - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable return lambda_stmt(lambda: select(func.max(StatisticsRuns.run_id))) @@ -696,8 +690,6 @@ def find_legacy_detached_states_and_attributes_to_purge( def find_legacy_row() -> StatementLambdaElement: """Check if there are still states in the table with an event_id.""" - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable return lambda_stmt(lambda: select(func.max(States.event_id))) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 9bbf35bb40a..005859b865b 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -103,11 +103,7 @@ QUERY_STATISTICS_SHORT_TERM = ( QUERY_STATISTICS_SUMMARY_MEAN = ( StatisticsShortTerm.metadata_id, func.avg(StatisticsShortTerm.mean), - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable func.min(StatisticsShortTerm.min), - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable func.max(StatisticsShortTerm.max), ) @@ -417,8 +413,6 @@ def compile_missing_statistics(instance: Recorder) -> bool: exception_filter=_filter_unique_constraint_integrity_error(instance), ) as session: # Find the newest statistics run, if any - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable if last_run := session.query(func.max(StatisticsRuns.start)).scalar(): start = max(start, process_timestamp(last_run) + timedelta(minutes=5)) @@ -1078,17 +1072,11 @@ def _get_max_mean_min_statistic_in_sub_period( # Calculate max, mean, min columns = select() if "max" in types: - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable columns = columns.add_columns(func.max(table.max)) if "mean" in types: columns = columns.add_columns(func.avg(table.mean)) - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable columns = columns.add_columns(func.count(table.mean)) if "min" in types: - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable columns = columns.add_columns(func.min(table.min)) stmt = _generate_max_mean_min_statistic_in_sub_period_stmt( columns, start_time, end_time, table, metadata_id @@ -1831,8 +1819,6 @@ def _latest_short_term_statistics_stmt( most_recent_statistic_row := ( select( StatisticsShortTerm.metadata_id, - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable func.max(StatisticsShortTerm.start_ts).label("start_max"), ) .where(StatisticsShortTerm.metadata_id.in_(metadata_ids)) @@ -1895,8 +1881,6 @@ def _generate_statistics_at_time_stmt( ( most_recent_statistic_ids := ( select( - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable func.max(table.start_ts).label("max_start_ts"), table.metadata_id.label("max_metadata_id"), ) diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 1c3e07f40fd..d438cbede9f 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -426,7 +426,7 @@ def _datetime_or_none(value: str) -> datetime | None: def build_mysqldb_conv() -> dict: """Build a MySQLDB conv dict that uses cisco8601 to parse datetimes.""" # Late imports since we only call this if they are using mysqldb - # pylint: disable=import-outside-toplevel,import-error + # pylint: disable=import-outside-toplevel from MySQLdb.constants import FIELD_TYPE from MySQLdb.converters import conversions diff --git a/homeassistant/components/rocketchat/notify.py b/homeassistant/components/rocketchat/notify.py index 317364b262a..30bcc6c4515 100644 --- a/homeassistant/components/rocketchat/notify.py +++ b/homeassistant/components/rocketchat/notify.py @@ -23,7 +23,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) -# pylint: disable=no-value-for-parameter PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_URL): vol.Url(), diff --git a/tests/components/mobile_app/conftest.py b/tests/components/mobile_app/conftest.py index 875fccea294..e7c9ad4995a 100644 --- a/tests/components/mobile_app/conftest.py +++ b/tests/components/mobile_app/conftest.py @@ -1,7 +1,6 @@ """Tests for mobile_app component.""" from http import HTTPStatus -# pylint: disable=unused-import import pytest from homeassistant.components.mobile_app.const import DOMAIN diff --git a/tests/components/owntracks/test_device_tracker.py b/tests/components/owntracks/test_device_tracker.py index 865c5e52770..41c3b7f058d 100644 --- a/tests/components/owntracks/test_device_tracker.py +++ b/tests/components/owntracks/test_device_tracker.py @@ -279,7 +279,7 @@ BAD_MESSAGE = {"_type": "unsupported", "tst": 1} BAD_JSON_PREFIX = "--$this is bad json#--" BAD_JSON_SUFFIX = "** and it ends here ^^" -# pylint: disable=invalid-name, len-as-condition +# pylint: disable=len-as-condition @pytest.fixture @@ -311,8 +311,6 @@ def context(hass, setup_comp): orig_context = owntracks.OwnTracksContext context = None - # pylint: disable=no-value-for-parameter - def store_context(*args): """Store the context.""" nonlocal context @@ -1503,7 +1501,7 @@ async def test_encrypted_payload_no_topic_key(hass: HomeAssistant, setup_comp) - async def test_encrypted_payload_libsodium(hass: HomeAssistant, setup_comp) -> None: """Test sending encrypted message payload.""" try: - import nacl # noqa: F401 pylint: disable=unused-import + import nacl # noqa: F401 except (ImportError, OSError): pytest.skip("PyNaCl/libsodium is not installed") return diff --git a/tests/components/recorder/auto_repairs/events/test_schema.py b/tests/components/recorder/auto_repairs/events/test_schema.py index 1fd5d769c7c..1dc9fb1f560 100644 --- a/tests/components/recorder/auto_repairs/events/test_schema.py +++ b/tests/components/recorder/auto_repairs/events/test_schema.py @@ -1,6 +1,5 @@ """The test repairing events schema.""" -# pylint: disable=invalid-name from unittest.mock import ANY, patch import pytest diff --git a/tests/components/recorder/auto_repairs/states/test_schema.py b/tests/components/recorder/auto_repairs/states/test_schema.py index 9b90489d7c0..f3d733c7c45 100644 --- a/tests/components/recorder/auto_repairs/states/test_schema.py +++ b/tests/components/recorder/auto_repairs/states/test_schema.py @@ -1,6 +1,5 @@ """The test repairing states schema.""" -# pylint: disable=invalid-name from unittest.mock import ANY, patch import pytest diff --git a/tests/components/recorder/auto_repairs/statistics/test_duplicates.py b/tests/components/recorder/auto_repairs/statistics/test_duplicates.py index 98f46cadf03..0d0d9847c5d 100644 --- a/tests/components/recorder/auto_repairs/statistics/test_duplicates.py +++ b/tests/components/recorder/auto_repairs/statistics/test_duplicates.py @@ -1,7 +1,5 @@ """Test removing statistics duplicates.""" from collections.abc import Callable - -# pylint: disable=invalid-name import importlib from pathlib import Path import sys diff --git a/tests/components/recorder/auto_repairs/statistics/test_schema.py b/tests/components/recorder/auto_repairs/statistics/test_schema.py index 10d1ed00b5b..032cd57ce49 100644 --- a/tests/components/recorder/auto_repairs/statistics/test_schema.py +++ b/tests/components/recorder/auto_repairs/statistics/test_schema.py @@ -1,6 +1,5 @@ """The test repairing statistics schema.""" -# pylint: disable=invalid-name from unittest.mock import ANY, patch import pytest diff --git a/tests/components/recorder/auto_repairs/test_schema.py b/tests/components/recorder/auto_repairs/test_schema.py index ad2c33bfb88..83fb64dca6c 100644 --- a/tests/components/recorder/auto_repairs/test_schema.py +++ b/tests/components/recorder/auto_repairs/test_schema.py @@ -1,6 +1,5 @@ """The test validating and repairing schema.""" -# pylint: disable=invalid-name from unittest.mock import patch import pytest diff --git a/tests/components/recorder/db_schema_0.py b/tests/components/recorder/db_schema_0.py index 5f64fbda736..3fbf9cce5fc 100644 --- a/tests/components/recorder/db_schema_0.py +++ b/tests/components/recorder/db_schema_0.py @@ -26,7 +26,6 @@ from homeassistant.helpers.json import JSONEncoder import homeassistant.util.dt as dt_util # SQLAlchemy Schema -# pylint: disable=invalid-name Base = declarative_base() _LOGGER = logging.getLogger(__name__) diff --git a/tests/components/recorder/db_schema_16.py b/tests/components/recorder/db_schema_16.py index 36641ada625..8c491b82c39 100644 --- a/tests/components/recorder/db_schema_16.py +++ b/tests/components/recorder/db_schema_16.py @@ -39,7 +39,6 @@ from homeassistant.helpers.json import JSONEncoder import homeassistant.util.dt as dt_util # SQLAlchemy Schema -# pylint: disable=invalid-name Base = declarative_base() SCHEMA_VERSION = 16 diff --git a/tests/components/recorder/db_schema_18.py b/tests/components/recorder/db_schema_18.py index ba1cfa09cd4..2ce0dfae5f5 100644 --- a/tests/components/recorder/db_schema_18.py +++ b/tests/components/recorder/db_schema_18.py @@ -39,7 +39,6 @@ from homeassistant.helpers.json import JSONEncoder import homeassistant.util.dt as dt_util # SQLAlchemy Schema -# pylint: disable=invalid-name Base = declarative_base() SCHEMA_VERSION = 18 diff --git a/tests/components/recorder/db_schema_22.py b/tests/components/recorder/db_schema_22.py index 3bcef248e0f..329e5d262bc 100644 --- a/tests/components/recorder/db_schema_22.py +++ b/tests/components/recorder/db_schema_22.py @@ -45,7 +45,6 @@ from homeassistant.helpers.json import JSONEncoder import homeassistant.util.dt as dt_util # SQLAlchemy Schema -# pylint: disable=invalid-name Base = declarative_base() SCHEMA_VERSION = 22 diff --git a/tests/components/recorder/db_schema_23.py b/tests/components/recorder/db_schema_23.py index 50839f41906..a89599520c0 100644 --- a/tests/components/recorder/db_schema_23.py +++ b/tests/components/recorder/db_schema_23.py @@ -43,7 +43,6 @@ from homeassistant.helpers.json import JSONEncoder import homeassistant.util.dt as dt_util # SQLAlchemy Schema -# pylint: disable=invalid-name Base = declarative_base() SCHEMA_VERSION = 23 diff --git a/tests/components/recorder/db_schema_23_with_newer_columns.py b/tests/components/recorder/db_schema_23_with_newer_columns.py index 9f73e304e9b..160ddc5761c 100644 --- a/tests/components/recorder/db_schema_23_with_newer_columns.py +++ b/tests/components/recorder/db_schema_23_with_newer_columns.py @@ -51,7 +51,6 @@ from homeassistant.helpers.json import JSONEncoder import homeassistant.util.dt as dt_util # SQLAlchemy Schema -# pylint: disable=invalid-name Base = declarative_base() SCHEMA_VERSION = 23 diff --git a/tests/components/recorder/db_schema_25.py b/tests/components/recorder/db_schema_25.py index 291fdb1231d..24b5b764c65 100644 --- a/tests/components/recorder/db_schema_25.py +++ b/tests/components/recorder/db_schema_25.py @@ -39,7 +39,6 @@ from homeassistant.helpers.typing import UNDEFINED, UndefinedType import homeassistant.util.dt as dt_util # SQLAlchemy Schema -# pylint: disable=invalid-name Base = declarative_base() SCHEMA_VERSION = 25 diff --git a/tests/components/recorder/db_schema_28.py b/tests/components/recorder/db_schema_28.py index 7e88d6a5548..9df32f1b6c1 100644 --- a/tests/components/recorder/db_schema_28.py +++ b/tests/components/recorder/db_schema_28.py @@ -45,7 +45,6 @@ from homeassistant.core import Context, Event, EventOrigin, State, split_entity_ import homeassistant.util.dt as dt_util # SQLAlchemy Schema -# pylint: disable=invalid-name Base = declarative_base() SCHEMA_VERSION = 28 diff --git a/tests/components/recorder/db_schema_30.py b/tests/components/recorder/db_schema_30.py index 55bee20df56..c1a61159c98 100644 --- a/tests/components/recorder/db_schema_30.py +++ b/tests/components/recorder/db_schema_30.py @@ -55,7 +55,6 @@ from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads ALL_DOMAIN_EXCLUDE_ATTRS = {ATTR_ATTRIBUTION, ATTR_RESTORED, ATTR_SUPPORTED_FEATURES} # SQLAlchemy Schema -# pylint: disable=invalid-name Base = declarative_base() SCHEMA_VERSION = 30 diff --git a/tests/components/recorder/db_schema_32.py b/tests/components/recorder/db_schema_32.py index 660a2a54d4b..e092de28eca 100644 --- a/tests/components/recorder/db_schema_32.py +++ b/tests/components/recorder/db_schema_32.py @@ -55,7 +55,6 @@ from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads ALL_DOMAIN_EXCLUDE_ATTRS = {ATTR_ATTRIBUTION, ATTR_RESTORED, ATTR_SUPPORTED_FEATURES} # SQLAlchemy Schema -# pylint: disable=invalid-name Base = declarative_base() SCHEMA_VERSION = 32 diff --git a/tests/components/recorder/test_filters_with_entityfilter_schema_37.py b/tests/components/recorder/test_filters_with_entityfilter_schema_37.py index 9829996818f..9a03c024a83 100644 --- a/tests/components/recorder/test_filters_with_entityfilter_schema_37.py +++ b/tests/components/recorder/test_filters_with_entityfilter_schema_37.py @@ -1,5 +1,4 @@ """The tests for the recorder filter matching the EntityFilter component.""" -# pylint: disable=invalid-name import json from unittest.mock import patch diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index be77f2907d6..21016a65cc2 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -2,8 +2,6 @@ from __future__ import annotations from collections.abc import Callable - -# pylint: disable=invalid-name from copy import copy from datetime import datetime, timedelta import json diff --git a/tests/components/recorder/test_history_db_schema_30.py b/tests/components/recorder/test_history_db_schema_30.py index 30d8de654d7..0ed6061de98 100644 --- a/tests/components/recorder/test_history_db_schema_30.py +++ b/tests/components/recorder/test_history_db_schema_30.py @@ -2,8 +2,6 @@ from __future__ import annotations from collections.abc import Callable - -# pylint: disable=invalid-name from copy import copy from datetime import datetime, timedelta import json diff --git a/tests/components/recorder/test_history_db_schema_32.py b/tests/components/recorder/test_history_db_schema_32.py index 51e4bfdc402..5b721cd4c87 100644 --- a/tests/components/recorder/test_history_db_schema_32.py +++ b/tests/components/recorder/test_history_db_schema_32.py @@ -2,8 +2,6 @@ from __future__ import annotations from collections.abc import Callable - -# pylint: disable=invalid-name from copy import copy from datetime import datetime, timedelta import json diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index bdeecf14c57..e4e5e49eab5 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -594,7 +594,6 @@ def test_setup_without_migration(hass_recorder: Callable[..., HomeAssistant]) -> assert recorder.get_instance(hass).schema_version == SCHEMA_VERSION -# pylint: disable=invalid-name def test_saving_state_include_domains( hass_recorder: Callable[..., HomeAssistant] ) -> None: @@ -955,7 +954,6 @@ async def test_defaults_set(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "history", {}) assert recorder_config is not None - # pylint: disable=unsubscriptable-object assert recorder_config["auto_purge"] assert recorder_config["auto_repack"] assert recorder_config["purge_keep_days"] == 10 diff --git a/tests/components/recorder/test_migration_from_schema_32.py b/tests/components/recorder/test_migration_from_schema_32.py index 2ae32018213..cdf930fde26 100644 --- a/tests/components/recorder/test_migration_from_schema_32.py +++ b/tests/components/recorder/test_migration_from_schema_32.py @@ -1,5 +1,4 @@ """The tests for the recorder filter matching the EntityFilter component.""" -# pylint: disable=invalid-name import importlib import sys from unittest.mock import patch diff --git a/tests/components/recorder/test_purge_v32_schema.py b/tests/components/recorder/test_purge_v32_schema.py index 18c35e8eb81..3b315481f4e 100644 --- a/tests/components/recorder/test_purge_v32_schema.py +++ b/tests/components/recorder/test_purge_v32_schema.py @@ -1,6 +1,5 @@ """Test data purging.""" -# pylint: disable=invalid-name from datetime import datetime, timedelta import json import sqlite3 diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index de10d9f569b..ab89b82d713 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -1,7 +1,5 @@ """The tests for sensor recorder platform.""" from collections.abc import Callable - -# pylint: disable=invalid-name from datetime import timedelta from unittest.mock import patch diff --git a/tests/components/recorder/test_statistics_v23_migration.py b/tests/components/recorder/test_statistics_v23_migration.py index c3d65e7290f..75a9fed4ad1 100644 --- a/tests/components/recorder/test_statistics_v23_migration.py +++ b/tests/components/recorder/test_statistics_v23_migration.py @@ -4,8 +4,6 @@ The v23 schema used for these tests has been slightly modified to add the EventData table to allow the recorder to startup successfully. """ from functools import partial - -# pylint: disable=invalid-name import importlib import json from pathlib import Path diff --git a/tests/components/recorder/test_v32_migration.py b/tests/components/recorder/test_v32_migration.py index dae4fb39c59..98f401e45d8 100644 --- a/tests/components/recorder/test_v32_migration.py +++ b/tests/components/recorder/test_v32_migration.py @@ -1,5 +1,4 @@ """The tests for recorder platform migrating data from v30.""" -# pylint: disable=invalid-name import asyncio from datetime import timedelta import importlib diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 32d4fabb02b..a9dc23ef5b3 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -1,5 +1,4 @@ """The tests for sensor recorder platform.""" -# pylint: disable=invalid-name import datetime from datetime import timedelta from statistics import fmean From 360d2de1e1591ddf61ebedc357b64738316f3c0f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 24 Aug 2023 00:57:27 +0200 Subject: [PATCH 017/124] Use snapshot assertion for Cpuspeed diagnostics test (#98907) --- .../cpuspeed/snapshots/test_diagnostics.ambr | 15 +++++++++++++++ tests/components/cpuspeed/test_diagnostics.py | 15 +++++++-------- 2 files changed, 22 insertions(+), 8 deletions(-) create mode 100644 tests/components/cpuspeed/snapshots/test_diagnostics.ambr diff --git a/tests/components/cpuspeed/snapshots/test_diagnostics.ambr b/tests/components/cpuspeed/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..8efe36def6d --- /dev/null +++ b/tests/components/cpuspeed/snapshots/test_diagnostics.ambr @@ -0,0 +1,15 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'arch_string_raw': 'aargh', + 'brand_raw': 'Intel Ryzen 7', + 'hz_actual': list([ + 3200000001, + 0, + ]), + 'hz_advertised': list([ + 3600000001, + 0, + ]), + }) +# --- diff --git a/tests/components/cpuspeed/test_diagnostics.py b/tests/components/cpuspeed/test_diagnostics.py index 154f79f2f3e..2c91566216d 100644 --- a/tests/components/cpuspeed/test_diagnostics.py +++ b/tests/components/cpuspeed/test_diagnostics.py @@ -1,6 +1,8 @@ """Tests for the diagnostics data provided by the CPU Speed integration.""" from unittest.mock import patch +from syrupy import SnapshotAssertion + from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -12,6 +14,7 @@ async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" info = { @@ -25,11 +28,7 @@ async def test_diagnostics( "homeassistant.components.cpuspeed.diagnostics.cpuinfo.get_cpu_info", return_value=info, ): - assert await get_diagnostics_for_config_entry( - hass, hass_client, init_integration - ) == { - "hz_actual": [3200000001, 0], - "arch_string_raw": "aargh", - "brand_raw": "Intel Ryzen 7", - "hz_advertised": [3600000001, 0], - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) From a539d851ccc5ac49e7df5bd6a586893f7ec60156 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 24 Aug 2023 00:57:55 +0200 Subject: [PATCH 018/124] Use snapshot assertion for Enphase Envoy diagnostics test (#98910) --- tests/components/enphase_envoy/conftest.py | 1 + .../snapshots/test_diagnostics.ambr | 28 +++++++++++++++++ .../enphase_envoy/test_diagnostics.py | 30 +++++-------------- 3 files changed, 36 insertions(+), 23 deletions(-) create mode 100644 tests/components/enphase_envoy/snapshots/test_diagnostics.ambr diff --git a/tests/components/enphase_envoy/conftest.py b/tests/components/enphase_envoy/conftest.py index 355c247b182..41cbb239129 100644 --- a/tests/components/enphase_envoy/conftest.py +++ b/tests/components/enphase_envoy/conftest.py @@ -24,6 +24,7 @@ def config_entry_fixture(hass: HomeAssistant, config, serial_number): """Define a config entry fixture.""" entry = MockConfigEntry( domain=DOMAIN, + entry_id="45a36e55aaddb2007c5f6602e0c38e72", title=f"Envoy {serial_number}" if serial_number else "Envoy", unique_id=serial_number, data=config, diff --git a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..098fc4ee37e --- /dev/null +++ b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr @@ -0,0 +1,28 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'data': dict({ + 'varies_by': 'firmware_version', + }), + 'entry': dict({ + 'data': dict({ + 'host': '1.1.1.1', + 'name': '**REDACTED**', + 'password': '**REDACTED**', + 'token': '**REDACTED**', + 'username': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'enphase_envoy', + 'entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': '**REDACTED**', + 'unique_id': '**REDACTED**', + 'version': 1, + }), + }) +# --- diff --git a/tests/components/enphase_envoy/test_diagnostics.py b/tests/components/enphase_envoy/test_diagnostics.py index fb1a54dc522..c3659b2a9bb 100644 --- a/tests/components/enphase_envoy/test_diagnostics.py +++ b/tests/components/enphase_envoy/test_diagnostics.py @@ -1,5 +1,6 @@ """Test Enphase Envoy diagnostics.""" -from homeassistant.components.diagnostics import REDACTED +from syrupy import SnapshotAssertion + from homeassistant.core import HomeAssistant from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -11,27 +12,10 @@ async def test_entry_diagnostics( config_entry, hass_client: ClientSessionGenerator, setup_enphase_envoy, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "entry": { - "entry_id": config_entry.entry_id, - "version": 1, - "domain": "enphase_envoy", - "title": REDACTED, - "data": { - "host": "1.1.1.1", - "name": REDACTED, - "username": REDACTED, - "password": REDACTED, - "token": REDACTED, - }, - "options": {}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "unique_id": REDACTED, - "disabled_by": None, - }, - "data": {"varies_by": "firmware_version"}, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) From f1fb28aad55eebf1a0774cee47bb5f634435f2b5 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 24 Aug 2023 01:01:58 +0200 Subject: [PATCH 019/124] Use snapshot assertion for ESPHome diagnostics test (#98913) --- tests/components/esphome/conftest.py | 1 + .../esphome/snapshots/test_diagnostics.ambr | 26 +++++++++++++++++++ tests/components/esphome/test_diagnostics.py | 18 +++---------- 3 files changed, 30 insertions(+), 15 deletions(-) create mode 100644 tests/components/esphome/snapshots/test_diagnostics.ambr diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index f0fe2d9ccb0..6b06545a06b 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -62,6 +62,7 @@ def mock_config_entry(hass) -> MockConfigEntry: """Return the default mocked config entry.""" config_entry = MockConfigEntry( title="ESPHome Device", + entry_id="08d821dc059cf4f645cb024d32c8e708", domain=DOMAIN, data={ CONF_HOST: "192.168.1.2", diff --git a/tests/components/esphome/snapshots/test_diagnostics.ambr b/tests/components/esphome/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..d8de8f06bc6 --- /dev/null +++ b/tests/components/esphome/snapshots/test_diagnostics.ambr @@ -0,0 +1,26 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config': dict({ + 'data': dict({ + 'device_name': 'test', + 'host': '192.168.1.2', + 'noise_psk': '**REDACTED**', + 'password': '**REDACTED**', + 'port': 6053, + }), + 'disabled_by': None, + 'domain': 'esphome', + 'entry_id': '08d821dc059cf4f645cb024d32c8e708', + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'ESPHome Device', + 'unique_id': '11:22:33:44:55:aa', + 'version': 1, + }), + 'dashboard': 'mock-slug', + }) +# --- diff --git a/tests/components/esphome/test_diagnostics.py b/tests/components/esphome/test_diagnostics.py index 025c5bcaae8..6000b270d87 100644 --- a/tests/components/esphome/test_diagnostics.py +++ b/tests/components/esphome/test_diagnostics.py @@ -1,12 +1,8 @@ """Tests for the diagnostics data provided by the ESPHome integration.""" +from syrupy import SnapshotAssertion - -from homeassistant.components.esphome.const import CONF_DEVICE_NAME, CONF_NOISE_PSK -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant -from . import DASHBOARD_SLUG - from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -18,17 +14,9 @@ async def test_diagnostics( init_integration: MockConfigEntry, enable_bluetooth: None, mock_dashboard, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics for config entry.""" result = await get_diagnostics_for_config_entry(hass, hass_client, init_integration) - assert isinstance(result, dict) - assert result["config"]["data"] == { - CONF_DEVICE_NAME: "test", - CONF_HOST: "192.168.1.2", - CONF_PORT: 6053, - CONF_PASSWORD: "**REDACTED**", - CONF_NOISE_PSK: "**REDACTED**", - } - assert result["config"]["unique_id"] == "11:22:33:44:55:aa" - assert result["dashboard"] == DASHBOARD_SLUG + assert result == snapshot From a1307e117dd4ff466ed1e19068021db29bf3d0bc Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 24 Aug 2023 01:02:52 +0200 Subject: [PATCH 020/124] Add additional debug logging for imap (#98877) --- homeassistant/components/imap/coordinator.py | 22 ++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index b9b541997a3..72be5e9bcf0 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -65,14 +65,28 @@ async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL: else: ssl_context = create_no_verify_ssl_context() client = IMAP4_SSL(data[CONF_SERVER], data[CONF_PORT], ssl_context=ssl_context) - + _LOGGER.debug( + "Wait for hello message from server %s on port %s, verify_ssl: %s", + data[CONF_SERVER], + data[CONF_PORT], + data.get(CONF_VERIFY_SSL, True), + ) await client.wait_hello_from_server() - if client.protocol.state == NONAUTH: + _LOGGER.debug( + "Authenticating with %s on server %s", + data[CONF_USERNAME], + data[CONF_SERVER], + ) await client.login(data[CONF_USERNAME], data[CONF_PASSWORD]) if client.protocol.state not in {AUTH, SELECTED}: raise InvalidAuth("Invalid username or password") if client.protocol.state == AUTH: + _LOGGER.debug( + "Selecting mail folder %s on server %s", + data[CONF_FOLDER], + data[CONF_SERVER], + ) await client.select(data[CONF_FOLDER]) if client.protocol.state != SELECTED: raise InvalidFolder(f"Folder {data[CONF_FOLDER]} is invalid") @@ -312,6 +326,9 @@ class ImapPollingDataUpdateCoordinator(ImapDataUpdateCoordinator): self, hass: HomeAssistant, imap_client: IMAP4_SSL, entry: ConfigEntry ) -> None: """Initiate imap client.""" + _LOGGER.debug( + "Connected to server %s using IMAP polling", entry.data[CONF_SERVER] + ) super().__init__(hass, imap_client, entry, timedelta(seconds=10)) async def _async_update_data(self) -> int | None: @@ -354,6 +371,7 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator): self, hass: HomeAssistant, imap_client: IMAP4_SSL, entry: ConfigEntry ) -> None: """Initiate imap client.""" + _LOGGER.debug("Connected to server %s using IMAP push", entry.data[CONF_SERVER]) super().__init__(hass, imap_client, entry, None) self._push_wait_task: asyncio.Task[None] | None = None From faa4489f4c817d64cbbe51405b905f36a6e85288 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 24 Aug 2023 01:18:49 +0200 Subject: [PATCH 021/124] Use snapshot assertion for Co2signal diagnostics test (#98905) --- .../co2signal/snapshots/test_diagnostics.ambr | 33 +++++++++++++++++++ .../components/co2signal/test_diagnostics.py | 19 +++++------ 2 files changed, 42 insertions(+), 10 deletions(-) create mode 100644 tests/components/co2signal/snapshots/test_diagnostics.ambr diff --git a/tests/components/co2signal/snapshots/test_diagnostics.ambr b/tests/components/co2signal/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..ffb35edfbbb --- /dev/null +++ b/tests/components/co2signal/snapshots/test_diagnostics.ambr @@ -0,0 +1,33 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'config_entry': dict({ + 'data': dict({ + 'api_key': '**REDACTED**', + 'location': '', + }), + 'disabled_by': None, + 'domain': 'co2signal', + 'entry_id': '904a74160aa6f335526706bee85dfb83', + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': None, + 'version': 1, + }), + 'data': dict({ + 'countryCode': 'FR', + 'data': dict({ + 'carbonIntensity': 45.98623190095805, + 'fossilFuelPercentage': 5.461182741937103, + }), + 'status': 'ok', + 'units': dict({ + 'carbonIntensity': 'gCO2eq/kWh', + }), + }), + }) +# --- diff --git a/tests/components/co2signal/test_diagnostics.py b/tests/components/co2signal/test_diagnostics.py index c73409fa59b..ed73cb960b5 100644 --- a/tests/components/co2signal/test_diagnostics.py +++ b/tests/components/co2signal/test_diagnostics.py @@ -1,8 +1,9 @@ """Test the CO2Signal diagnostics.""" from unittest.mock import patch +from syrupy import SnapshotAssertion + from homeassistant.components.co2signal import DOMAIN -from homeassistant.components.diagnostics import REDACTED from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -15,11 +16,15 @@ from tests.typing import ClientSessionGenerator async def test_entry_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_API_KEY: "api_key", "location": ""} + domain=DOMAIN, + data={CONF_API_KEY: "api_key", "location": ""}, + entry_id="904a74160aa6f335526706bee85dfb83", ) config_entry.add_to_hass(hass) with patch("CO2Signal.get_latest", return_value=VALID_PAYLOAD): @@ -27,10 +32,4 @@ async def test_entry_diagnostics( result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - config_entry_dict = config_entry.as_dict() - config_entry_dict["data"][CONF_API_KEY] = REDACTED - - assert result == { - "config_entry": config_entry_dict, - "data": VALID_PAYLOAD, - } + assert result == snapshot From c39f6b3bea37139b842b47b8750b0cde80cbc64e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 24 Aug 2023 01:23:31 +0200 Subject: [PATCH 022/124] Use snapshot assertion for Coinbase diagnostics test (#98906) --- tests/components/coinbase/common.py | 1 + tests/components/coinbase/const.py | 41 ----------- .../coinbase/snapshots/test_diagnostics.ambr | 70 +++++++++++++++++++ tests/components/coinbase/test_diagnostics.py | 15 ++-- 4 files changed, 77 insertions(+), 50 deletions(-) create mode 100644 tests/components/coinbase/snapshots/test_diagnostics.ambr diff --git a/tests/components/coinbase/common.py b/tests/components/coinbase/common.py index 4866039f310..6ab33f3bc7c 100644 --- a/tests/components/coinbase/common.py +++ b/tests/components/coinbase/common.py @@ -68,6 +68,7 @@ async def init_mock_coinbase(hass, currencies=None, rates=None): """Init Coinbase integration for testing.""" config_entry = MockConfigEntry( domain=DOMAIN, + entry_id="080272b77a4f80c41b94d7cdc86fd826", unique_id=None, title="Test User", data={CONF_API_KEY: "123456", CONF_API_TOKEN: "AbCDeF"}, diff --git a/tests/components/coinbase/const.py b/tests/components/coinbase/const.py index 4db6abca37d..2b437e15478 100644 --- a/tests/components/coinbase/const.py +++ b/tests/components/coinbase/const.py @@ -1,6 +1,5 @@ """Constants for testing the Coinbase integration.""" -from homeassistant.components.diagnostics import REDACTED GOOD_CURRENCY = "BTC" GOOD_CURRENCY_2 = "USD" @@ -36,43 +35,3 @@ MOCK_ACCOUNTS_RESPONSE = [ "type": "fiat", }, ] - -MOCK_ACCOUNTS_RESPONSE_REDACTED = [ - { - "balance": {"amount": REDACTED, "currency": GOOD_CURRENCY}, - "currency": GOOD_CURRENCY, - "id": REDACTED, - "name": "BTC Wallet", - "native_balance": {"amount": REDACTED, "currency": GOOD_CURRENCY_2}, - "type": "wallet", - }, - { - "balance": {"amount": REDACTED, "currency": GOOD_CURRENCY}, - "currency": GOOD_CURRENCY, - "id": REDACTED, - "name": "BTC Vault", - "native_balance": {"amount": REDACTED, "currency": GOOD_CURRENCY_2}, - "type": "vault", - }, - { - "balance": {"amount": REDACTED, "currency": GOOD_CURRENCY_2}, - "currency": "USD", - "id": REDACTED, - "name": "USD Wallet", - "native_balance": {"amount": REDACTED, "currency": GOOD_CURRENCY_2}, - "type": "fiat", - }, -] - -MOCK_ENTRY_REDACTED = { - "version": 1, - "domain": "coinbase", - "title": REDACTED, - "data": {"api_token": REDACTED, "api_key": REDACTED}, - "options": {"account_balance_currencies": [], "exchange_rate_currencies": []}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "unique_id": None, - "disabled_by": None, -} diff --git a/tests/components/coinbase/snapshots/test_diagnostics.ambr b/tests/components/coinbase/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..c214330d5f9 --- /dev/null +++ b/tests/components/coinbase/snapshots/test_diagnostics.ambr @@ -0,0 +1,70 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'accounts': list([ + dict({ + 'balance': dict({ + 'amount': '**REDACTED**', + 'currency': 'BTC', + }), + 'currency': 'BTC', + 'id': '**REDACTED**', + 'name': 'BTC Wallet', + 'native_balance': dict({ + 'amount': '**REDACTED**', + 'currency': 'USD', + }), + 'type': 'wallet', + }), + dict({ + 'balance': dict({ + 'amount': '**REDACTED**', + 'currency': 'BTC', + }), + 'currency': 'BTC', + 'id': '**REDACTED**', + 'name': 'BTC Vault', + 'native_balance': dict({ + 'amount': '**REDACTED**', + 'currency': 'USD', + }), + 'type': 'vault', + }), + dict({ + 'balance': dict({ + 'amount': '**REDACTED**', + 'currency': 'USD', + }), + 'currency': 'USD', + 'id': '**REDACTED**', + 'name': 'USD Wallet', + 'native_balance': dict({ + 'amount': '**REDACTED**', + 'currency': 'USD', + }), + 'type': 'fiat', + }), + ]), + 'entry': dict({ + 'data': dict({ + 'api_key': '**REDACTED**', + 'api_token': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'coinbase', + 'entry_id': '080272b77a4f80c41b94d7cdc86fd826', + 'options': dict({ + 'account_balance_currencies': list([ + ]), + 'exchange_rate_currencies': list([ + ]), + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': '**REDACTED**', + 'unique_id': None, + 'version': 1, + }), + }) +# --- diff --git a/tests/components/coinbase/test_diagnostics.py b/tests/components/coinbase/test_diagnostics.py index 73978790441..897722b32b4 100644 --- a/tests/components/coinbase/test_diagnostics.py +++ b/tests/components/coinbase/test_diagnostics.py @@ -1,6 +1,8 @@ """Test the Coinbase diagnostics.""" from unittest.mock import patch +from syrupy import SnapshotAssertion + from homeassistant.core import HomeAssistant from .common import ( @@ -9,14 +11,15 @@ from .common import ( mock_get_exchange_rates, mocked_get_accounts, ) -from .const import MOCK_ACCOUNTS_RESPONSE_REDACTED, MOCK_ENTRY_REDACTED from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator async def test_entry_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test we handle a and redact a diagnostics request.""" @@ -34,10 +37,4 @@ async def test_entry_diagnostics( result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - # Remove the ID to match the constant - result["entry"].pop("entry_id") - - assert result == { - "entry": MOCK_ENTRY_REDACTED, - "accounts": MOCK_ACCOUNTS_RESPONSE_REDACTED, - } + assert result == snapshot From b51c0f6ddc0b2c939620e8a95714d4d4f3877754 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Thu, 24 Aug 2023 02:25:32 +0300 Subject: [PATCH 023/124] Remove unnnecessary pylint configs from components [s-z]* (#98925) --- homeassistant/components/saj/sensor.py | 4 ++-- homeassistant/components/sendgrid/notify.py | 1 - homeassistant/components/sia/hub.py | 14 ++++++-------- homeassistant/components/smarty/__init__.py | 2 +- homeassistant/components/smarty/binary_sensor.py | 2 +- homeassistant/components/smarty/fan.py | 2 +- homeassistant/components/smarty/sensor.py | 2 +- homeassistant/components/smhi/weather.py | 4 +--- homeassistant/components/sms/__init__.py | 2 +- homeassistant/components/sms/config_flow.py | 2 +- homeassistant/components/sms/gateway.py | 4 ++-- homeassistant/components/sms/notify.py | 2 +- homeassistant/components/smtp/notify.py | 1 - homeassistant/components/ssdp/__init__.py | 10 ++++------ homeassistant/components/system_log/__init__.py | 2 +- homeassistant/components/tank_utility/sensor.py | 9 +++------ .../components/template/template_entity.py | 2 +- homeassistant/components/template/trigger.py | 2 -- .../components/tensorflow/image_processing.py | 4 ++-- homeassistant/components/tuya/__init__.py | 4 ---- homeassistant/components/upnp/__init__.py | 2 +- homeassistant/components/watson_tts/tts.py | 6 ++---- homeassistant/components/xiaomi_aqara/__init__.py | 6 ++---- homeassistant/components/xiaomi_miio/light.py | 15 ++++++--------- homeassistant/components/xmpp/notify.py | 4 +--- .../zha/core/cluster_handlers/general.py | 6 ++---- .../core/cluster_handlers/manufacturerspecific.py | 4 ++-- .../zha/core/cluster_handlers/measurement.py | 4 +--- homeassistant/components/zha/core/const.py | 2 +- homeassistant/components/zha/core/discovery.py | 4 ++-- homeassistant/components/zha/core/endpoint.py | 2 +- homeassistant/components/zha/device_tracker.py | 2 +- homeassistant/components/zha/light.py | 4 ++-- homeassistant/components/zha/sensor.py | 2 +- tests/components/sensor/test_recorder.py | 2 -- tests/components/smartthings/test_init.py | 2 +- tests/components/time_date/test_sensor.py | 1 - tests/components/water_heater/test_init.py | 4 ---- tests/components/websocket_api/test_auth.py | 1 - 39 files changed, 55 insertions(+), 93 deletions(-) diff --git a/homeassistant/components/saj/sensor.py b/homeassistant/components/saj/sensor.py index 58dd4436861..12a5ae99570 100644 --- a/homeassistant/components/saj/sensor.py +++ b/homeassistant/components/saj/sensor.py @@ -141,7 +141,7 @@ async def async_setup_platform( @callback def stop_update_interval(event): """Properly cancel the scheduled update.""" - remove_interval_update() # pylint: disable=not-callable + remove_interval_update() hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, stop_update_interval) async_at_start(hass, start_update_interval) @@ -171,7 +171,7 @@ def async_track_time_interval_backoff( def remove_listener() -> None: """Remove interval listener.""" if remove: - remove() # pylint: disable=not-callable + remove() return remove_listener diff --git a/homeassistant/components/sendgrid/notify.py b/homeassistant/components/sendgrid/notify.py index 4c47f497b36..25d00fdd3b8 100644 --- a/homeassistant/components/sendgrid/notify.py +++ b/homeassistant/components/sendgrid/notify.py @@ -29,7 +29,6 @@ CONF_SENDER_NAME = "sender_name" DEFAULT_SENDER_NAME = "Home Assistant" -# pylint: disable=no-value-for-parameter PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_API_KEY): cv.string, diff --git a/homeassistant/components/sia/hub.py b/homeassistant/components/sia/hub.py index 64ca3832ce0..859841d3bea 100644 --- a/homeassistant/components/sia/hub.py +++ b/homeassistant/components/sia/hub.py @@ -110,14 +110,12 @@ class SIAHub: self.sia_client.accounts = self.sia_accounts return # the new client class method creates a subclass based on protocol, hence the type ignore - self.sia_client = ( - SIAClient( # pylint: disable=abstract-class-instantiated # type: ignore - host="", - port=self._port, - accounts=self.sia_accounts, - function=self.async_create_and_fire_event, - protocol=CommunicationsProtocol(self._protocol), - ) + self.sia_client = SIAClient( + host="", + port=self._port, + accounts=self.sia_accounts, + function=self.async_create_and_fire_event, + protocol=CommunicationsProtocol(self._protocol), ) def _load_options(self) -> None: diff --git a/homeassistant/components/smarty/__init__.py b/homeassistant/components/smarty/__init__.py index 036fb6e1e90..e3cf1dcf287 100644 --- a/homeassistant/components/smarty/__init__.py +++ b/homeassistant/components/smarty/__init__.py @@ -3,7 +3,7 @@ from datetime import timedelta import ipaddress import logging -from pysmarty import Smarty # pylint: disable=import-error +from pysmarty import Smarty import voluptuous as vol from homeassistant.const import CONF_HOST, CONF_NAME, Platform diff --git a/homeassistant/components/smarty/binary_sensor.py b/homeassistant/components/smarty/binary_sensor.py index baa25115186..d9d757a71b5 100644 --- a/homeassistant/components/smarty/binary_sensor.py +++ b/homeassistant/components/smarty/binary_sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from pysmarty import Smarty # pylint: disable=import-error +from pysmarty import Smarty from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, diff --git a/homeassistant/components/smarty/fan.py b/homeassistant/components/smarty/fan.py index cf7db560c15..cf4b49e6105 100644 --- a/homeassistant/components/smarty/fan.py +++ b/homeassistant/components/smarty/fan.py @@ -5,7 +5,7 @@ import logging import math from typing import Any -from pysmarty import Smarty # pylint: disable=import-error +from pysmarty import Smarty from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/smarty/sensor.py b/homeassistant/components/smarty/sensor.py index df99529b1f4..57d681594cf 100644 --- a/homeassistant/components/smarty/sensor.py +++ b/homeassistant/components/smarty/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations import datetime as dt import logging -from pysmarty import Smarty # pylint: disable=import-error +from pysmarty import Smarty from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.const import UnitOfTemperature diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py index c8ff9127ba8..05683f19b11 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -192,9 +192,7 @@ class SmhiWeather(WeatherEntity): async def retry_update(self, _: datetime) -> None: """Retry refresh weather forecast.""" - await self.async_update( # pylint: disable=unexpected-keyword-arg - no_throttle=True - ) + await self.async_update(no_throttle=True) @property def forecast(self) -> list[Forecast] | None: diff --git a/homeassistant/components/sms/__init__.py b/homeassistant/components/sms/__init__.py index 5b4ecc3a141..824a95e36b1 100644 --- a/homeassistant/components/sms/__init__.py +++ b/homeassistant/components/sms/__init__.py @@ -3,7 +3,7 @@ import asyncio from datetime import timedelta import logging -import gammu # pylint: disable=import-error +import gammu import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/sms/config_flow.py b/homeassistant/components/sms/config_flow.py index 9128b6187c1..df3530764cb 100644 --- a/homeassistant/components/sms/config_flow.py +++ b/homeassistant/components/sms/config_flow.py @@ -1,7 +1,7 @@ """Config flow for SMS integration.""" import logging -import gammu # pylint: disable=import-error +import gammu import voluptuous as vol from homeassistant import config_entries, core, exceptions diff --git a/homeassistant/components/sms/gateway.py b/homeassistant/components/sms/gateway.py index 36ada5421e0..578b2191bd2 100644 --- a/homeassistant/components/sms/gateway.py +++ b/homeassistant/components/sms/gateway.py @@ -1,8 +1,8 @@ """The sms gateway to interact with a GSM modem.""" import logging -import gammu # pylint: disable=import-error -from gammu.asyncworker import GammuAsyncWorker # pylint: disable=import-error +import gammu +from gammu.asyncworker import GammuAsyncWorker from homeassistant.core import callback diff --git a/homeassistant/components/sms/notify.py b/homeassistant/components/sms/notify.py index 9d94472b1b8..21d3ab2beb5 100644 --- a/homeassistant/components/sms/notify.py +++ b/homeassistant/components/sms/notify.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -import gammu # pylint: disable=import-error +import gammu from homeassistant.components.notify import ATTR_DATA, BaseNotificationService from homeassistant.const import CONF_TARGET diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index 28c3121a172..7037c239db3 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -56,7 +56,6 @@ DEFAULT_ENCRYPTION = "starttls" ENCRYPTION_OPTIONS = ["tls", "starttls", "none"] -# pylint: disable=no-value-for-parameter PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_RECIPIENT): vol.All(cv.ensure_list, [vol.Email()]), diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index 4bc9bb24835..3be5475a71a 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -153,7 +153,7 @@ async def async_register_callback( @bind_hass -async def async_get_discovery_info_by_udn_st( # pylint: disable=invalid-name +async def async_get_discovery_info_by_udn_st( hass: HomeAssistant, udn: str, st: str ) -> SsdpServiceInfo | None: """Fetch the discovery info cache.""" @@ -162,7 +162,7 @@ async def async_get_discovery_info_by_udn_st( # pylint: disable=invalid-name @bind_hass -async def async_get_discovery_info_by_st( # pylint: disable=invalid-name +async def async_get_discovery_info_by_st( hass: HomeAssistant, st: str ) -> list[SsdpServiceInfo]: """Fetch all the entries matching the st.""" @@ -575,7 +575,7 @@ class Scanner: info_desc = await self._async_get_description_dict(location) return discovery_info_from_headers_and_description(headers, info_desc) - async def async_get_discovery_info_by_udn_st( # pylint: disable=invalid-name + async def async_get_discovery_info_by_udn_st( self, udn: str, st: str ) -> SsdpServiceInfo | None: """Return discovery_info for a udn and st.""" @@ -583,9 +583,7 @@ class Scanner: return await self._async_headers_to_discovery_info(headers) return None - async def async_get_discovery_info_by_st( # pylint: disable=invalid-name - self, st: str - ) -> list[SsdpServiceInfo]: + async def async_get_discovery_info_by_st(self, st: str) -> list[SsdpServiceInfo]: """Return matching discovery_infos for a st.""" return [ await self._async_headers_to_discovery_info(headers) diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py index cba8082d23c..ab271ec676c 100644 --- a/homeassistant/components/system_log/__init__.py +++ b/homeassistant/components/system_log/__init__.py @@ -141,7 +141,7 @@ class LogEntry: self.root_cause = None if record.exc_info: self.exception = "".join(traceback.format_exception(*record.exc_info)) - _, _, tb = record.exc_info # pylint: disable=invalid-name + _, _, tb = record.exc_info # Last line of traceback contains the root cause of the exception if traceback.extract_tb(tb): self.root_cause = str(traceback.extract_tb(tb)[-1]) diff --git a/homeassistant/components/tank_utility/sensor.py b/homeassistant/components/tank_utility/sensor.py index f902abc22e0..0aecbb0f405 100644 --- a/homeassistant/components/tank_utility/sensor.py +++ b/homeassistant/components/tank_utility/sensor.py @@ -56,10 +56,7 @@ def setup_platform( try: token = auth.get_token(email, password) except requests.exceptions.HTTPError as http_error: - if ( - http_error.response.status_code - == requests.codes.unauthorized # pylint: disable=no-member - ): + if http_error.response.status_code == requests.codes.unauthorized: _LOGGER.error("Invalid credentials") return @@ -121,8 +118,8 @@ class TankUtilitySensor(SensorEntity): data = tank_monitor.get_device_data(self._token, self.device) except requests.exceptions.HTTPError as http_error: if http_error.response.status_code in ( - requests.codes.unauthorized, # pylint: disable=no-member - requests.codes.bad_request, # pylint: disable=no-member + requests.codes.unauthorized, + requests.codes.bad_request, ): _LOGGER.info("Getting new token") self._token = auth.get_token(self._email, self._password, force=True) diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index 0d6d5a99748..fe1a53e6510 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -15,7 +15,7 @@ from homeassistant.const import ( ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.template import Template -from homeassistant.helpers.template_entity import ( # noqa: F401 pylint: disable=unused-import +from homeassistant.helpers.template_entity import ( # noqa: F401 TEMPLATE_ENTITY_BASE_SCHEMA, TemplateEntity, make_template_entity_base_schema, diff --git a/homeassistant/components/template/trigger.py b/homeassistant/components/template/trigger.py index 113da3aa3ee..327c988106e 100644 --- a/homeassistant/components/template/trigger.py +++ b/homeassistant/components/template/trigger.py @@ -80,7 +80,6 @@ async def async_attach_trigger( return if delay_cancel: - # pylint: disable-next=not-callable delay_cancel() delay_cancel = None @@ -156,7 +155,6 @@ async def async_attach_trigger( """Remove state listeners async.""" unsub() if delay_cancel: - # pylint: disable-next=not-callable delay_cancel() return async_remove diff --git a/homeassistant/components/tensorflow/image_processing.py b/homeassistant/components/tensorflow/image_processing.py index a149ea92371..e2fce4b94c2 100644 --- a/homeassistant/components/tensorflow/image_processing.py +++ b/homeassistant/components/tensorflow/image_processing.py @@ -9,7 +9,7 @@ import time import numpy as np from PIL import Image, ImageDraw, UnidentifiedImageError -import tensorflow as tf # pylint: disable=import-error +import tensorflow as tf import voluptuous as vol from homeassistant.components.image_processing import ( @@ -148,7 +148,7 @@ def setup_platform( try: # Display warning that PIL will be used if no OpenCV is found. - import cv2 # noqa: F401 pylint: disable=unused-import, import-outside-toplevel + import cv2 # noqa: F401 pylint: disable=import-outside-toplevel except ImportError: _LOGGER.warning( "No OpenCV library found. TensorFlow will process image with " diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 2b28a7e4e5e..509e7e17013 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -234,10 +234,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class DeviceListener(TuyaDeviceListener): """Device Update Listener.""" - # pylint: disable=arguments-differ - # Library incorrectly defines methods as 'classmethod' - # https://github.com/tuya/tuya-iot-python-sdk/pull/48 - def __init__( self, hass: HomeAssistant, diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index bb505c08ad0..326ff5d7651 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -43,7 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) udn = entry.data[CONFIG_ENTRY_UDN] - st = entry.data[CONFIG_ENTRY_ST] # pylint: disable=invalid-name + st = entry.data[CONFIG_ENTRY_ST] usn = f"{udn}::{st}" # Register device discovered-callback. diff --git a/homeassistant/components/watson_tts/tts.py b/homeassistant/components/watson_tts/tts.py index 7af6c1ce97b..7adb1b1582f 100644 --- a/homeassistant/components/watson_tts/tts.py +++ b/homeassistant/components/watson_tts/tts.py @@ -1,10 +1,8 @@ """Support for IBM Watson TTS integration.""" import logging -from ibm_cloud_sdk_core.authenticators import ( # pylint: disable=import-error - IAMAuthenticator, -) -from ibm_watson import TextToSpeechV1 # pylint: disable=import-error +from ibm_cloud_sdk_core.authenticators import IAMAuthenticator +from ibm_watson import TextToSpeechV1 import voluptuous as vol from homeassistant.components.tts import PLATFORM_SCHEMA, Provider diff --git a/homeassistant/components/xiaomi_aqara/__init__.py b/homeassistant/components/xiaomi_aqara/__init__.py index 8f5ac19ee68..f7bc1910521 100644 --- a/homeassistant/components/xiaomi_aqara/__init__.py +++ b/homeassistant/components/xiaomi_aqara/__init__.py @@ -267,10 +267,8 @@ class XiaomiDevice(Entity): self.parse_data(device["data"], device["raw_data"]) self.parse_voltage(device["data"]) - if hasattr(self, "_data_key") and self._data_key: # pylint: disable=no-member - self._unique_id = ( - f"{self._data_key}{self._sid}" # pylint: disable=no-member - ) + if hasattr(self, "_data_key") and self._data_key: + self._unique_id = f"{self._data_key}{self._sid}" else: self._unique_id = f"{self._type}{self._sid}" diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index 0a4ed1527c0..1fc032b5c36 100644 --- a/homeassistant/components/xiaomi_miio/light.py +++ b/homeassistant/components/xiaomi_miio/light.py @@ -449,14 +449,11 @@ class XiaomiPhilipsBulb(XiaomiPhilipsGenericLight): if ATTR_BRIGHTNESS in kwargs and ATTR_COLOR_TEMP in kwargs: _LOGGER.debug( - ( - "Setting brightness and color temperature: " - "%s %s%%, %s mireds, %s%% cct" - ), + "Setting brightness and color temperature: %s %s%%, %s mireds, %s%% cct", brightness, - percent_brightness, # pylint: disable=used-before-assignment + percent_brightness, color_temp, - percent_color_temp, # pylint: disable=used-before-assignment + percent_color_temp, ) result = await self._try_command( @@ -832,8 +829,8 @@ class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb): _LOGGER.debug( "Setting brightness and color: %s %s%%, %s", brightness, - percent_brightness, # pylint: disable=used-before-assignment - rgb, # pylint: disable=used-before-assignment + percent_brightness, + rgb, ) result = await self._try_command( @@ -856,7 +853,7 @@ class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb): brightness, percent_brightness, color_temp, - percent_color_temp, # pylint: disable=used-before-assignment + percent_color_temp, ) result = await self._try_command( diff --git a/homeassistant/components/xmpp/notify.py b/homeassistant/components/xmpp/notify.py index 2f5bad116c4..0150e761838 100644 --- a/homeassistant/components/xmpp/notify.py +++ b/homeassistant/components/xmpp/notify.py @@ -198,9 +198,7 @@ async def async_send_message( # noqa: C901 _LOGGER.info("Sending file to %s", recipient) message = self.Message(sto=recipient, stype="chat") message["body"] = url - message["oob"][ # pylint: disable=invalid-sequence-index - "url" - ] = url + message["oob"]["url"] = url try: message.send() except (IqError, IqTimeout, XMPPError) as ex: diff --git a/homeassistant/components/zha/core/cluster_handlers/general.py b/homeassistant/components/zha/core/cluster_handlers/general.py index 622c9e4340e..bd66b0f6c63 100644 --- a/homeassistant/components/zha/core/cluster_handlers/general.py +++ b/homeassistant/components/zha/core/cluster_handlers/general.py @@ -164,9 +164,7 @@ class BasicClusterHandler(ClusterHandler): """Initialize Basic cluster handler.""" super().__init__(cluster, endpoint) if is_hue_motion_sensor(self) and self.cluster.endpoint.endpoint_id == 2: - self.ZCL_INIT_ATTRS = ( # pylint: disable=invalid-name - self.ZCL_INIT_ATTRS.copy() - ) + self.ZCL_INIT_ATTRS = self.ZCL_INIT_ATTRS.copy() self.ZCL_INIT_ATTRS["trigger_indicator"] = True elif ( self.cluster.endpoint.manufacturer == "TexasInstruments" @@ -373,7 +371,7 @@ class OnOffClusterHandler(ClusterHandler): except KeyError: return - self.ZCL_INIT_ATTRS = self.ZCL_INIT_ATTRS.copy() # pylint: disable=invalid-name + self.ZCL_INIT_ATTRS = self.ZCL_INIT_ATTRS.copy() self.ZCL_INIT_ATTRS["backlight_mode"] = True self.ZCL_INIT_ATTRS["power_on_state"] = True diff --git a/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py index e46031cce14..450a1aeec97 100644 --- a/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py +++ b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py @@ -92,7 +92,7 @@ class TuyaClusterHandler(ClusterHandler): "_TZE200_k6jhsr0q", "_TZE200_9mahtqtg", ): - self.ZCL_INIT_ATTRS = { # pylint: disable=invalid-name + self.ZCL_INIT_ATTRS = { "backlight_mode": True, "power_on_state": True, } @@ -109,7 +109,7 @@ class OppleRemote(ClusterHandler): """Initialize Opple cluster handler.""" super().__init__(cluster, endpoint) if self.cluster.endpoint.model == "lumi.motion.ac02": - self.ZCL_INIT_ATTRS = { # pylint: disable=invalid-name + self.ZCL_INIT_ATTRS = { "detection_interval": True, "motion_sensitivity": True, "trigger_indicator": True, diff --git a/homeassistant/components/zha/core/cluster_handlers/measurement.py b/homeassistant/components/zha/core/cluster_handlers/measurement.py index beeb6296e32..bd483920842 100644 --- a/homeassistant/components/zha/core/cluster_handlers/measurement.py +++ b/homeassistant/components/zha/core/cluster_handlers/measurement.py @@ -67,9 +67,7 @@ class OccupancySensing(ClusterHandler): """Initialize Occupancy cluster handler.""" super().__init__(cluster, endpoint) if is_hue_motion_sensor(self): - self.ZCL_INIT_ATTRS = ( # pylint: disable=invalid-name - self.ZCL_INIT_ATTRS.copy() - ) + self.ZCL_INIT_ATTRS = self.ZCL_INIT_ATTRS.copy() self.ZCL_INIT_ATTRS["sensitivity"] = True diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index c90c78243d1..7aab6112ab0 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -7,7 +7,7 @@ import logging import bellows.zigbee.application import voluptuous as vol import zigpy.application -from zigpy.config import CONF_DEVICE_PATH # noqa: F401 # pylint: disable=unused-import +from zigpy.config import CONF_DEVICE_PATH # noqa: F401 import zigpy.types as t import zigpy_deconz.zigbee.application import zigpy_xbee.zigbee.application diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index 0ca1c136271..92b68bdb159 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import async_entries_for_device from homeassistant.helpers.typing import ConfigType -from .. import ( # noqa: F401 pylint: disable=unused-import, +from .. import ( # noqa: F401 alarm_control_panel, binary_sensor, button, @@ -35,7 +35,7 @@ from .. import ( # noqa: F401 pylint: disable=unused-import, from . import const as zha_const, registries as zha_regs # importing cluster handlers updates registries -from .cluster_handlers import ( # noqa: F401 pylint: disable=unused-import, +from .cluster_handlers import ( # noqa: F401 ClusterHandler, closures, general, diff --git a/homeassistant/components/zha/core/endpoint.py b/homeassistant/components/zha/core/endpoint.py index 53a3fb883ef..bdef5ac46af 100644 --- a/homeassistant/components/zha/core/endpoint.py +++ b/homeassistant/components/zha/core/endpoint.py @@ -27,7 +27,7 @@ ATTR_IN_CLUSTERS: Final[str] = "input_clusters" ATTR_OUT_CLUSTERS: Final[str] = "output_clusters" _LOGGER = logging.getLogger(__name__) -CALLABLE_T = TypeVar("CALLABLE_T", bound=Callable) # pylint: disable=invalid-name +CALLABLE_T = TypeVar("CALLABLE_T", bound=Callable) class Endpoint: diff --git a/homeassistant/components/zha/device_tracker.py b/homeassistant/components/zha/device_tracker.py index 04c74a44dbe..bda346624dd 100644 --- a/homeassistant/components/zha/device_tracker.py +++ b/homeassistant/components/zha/device_tracker.py @@ -111,7 +111,7 @@ class ZHADeviceScannerEntity(ScannerEntity, ZhaEntity): return self._battery_level @property # type: ignore[misc] - def device_info( # pylint: disable=overridden-final-method + def device_info( self, ) -> DeviceInfo: """Return device info.""" diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 73955614c07..6331b192859 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -1112,13 +1112,13 @@ class LightGroup(BaseLight, ZhaGroupEntity): super().__init__(entity_ids, unique_id, group_id, zha_device, **kwargs) group = self.zha_device.gateway.get_group(self._group_id) - self._GROUP_SUPPORTS_EXECUTE_IF_OFF = True # pylint: disable=invalid-name + self._GROUP_SUPPORTS_EXECUTE_IF_OFF = True for member in group.members: # Ensure we do not send group commands that violate the minimum transition # time of any members. if member.device.manufacturer in DEFAULT_MIN_TRANSITION_MANUFACTURERS: - self._DEFAULT_MIN_TRANSITION_TIME = ( # pylint: disable=invalid-name + self._DEFAULT_MIN_TRANSITION_TIME = ( MinTransitionLight._DEFAULT_MIN_TRANSITION_TIME ) diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index c514e02ec57..535733230b9 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -228,7 +228,7 @@ class Battery(Sensor): return cls(unique_id, zha_device, cluster_handlers, **kwargs) @staticmethod - def formatter(value: int) -> int | None: # pylint: disable=arguments-differ + def formatter(value: int) -> int | None: """Return the state of the entity.""" # per zcl specs battery percent is reported at 200% ¯\_(ツ)_/¯ if not isinstance(value, numbers.Number) or value == -1: diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 65b0a0b9485..1c0200e1b53 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -1,7 +1,5 @@ """The tests for sensor recorder platform.""" from collections.abc import Callable - -# pylint: disable=invalid-name from datetime import datetime, timedelta import math from statistics import mean diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index f08d1b54985..0630ffd8392 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -422,7 +422,7 @@ async def test_broker_regenerates_token(hass: HomeAssistant, config_entry) -> No broker.connect() assert stored_action - await stored_action(None) # pylint:disable=not-callable + await stored_action(None) assert token.refresh.call_count == 1 assert config_entry.data[CONF_REFRESH_TOKEN] == token.refresh_token diff --git a/tests/components/time_date/test_sensor.py b/tests/components/time_date/test_sensor.py index 42f1e260280..96c7edf422b 100644 --- a/tests/components/time_date/test_sensor.py +++ b/tests/components/time_date/test_sensor.py @@ -102,7 +102,6 @@ async def test_states_non_default_timezone(hass: HomeAssistant) -> None: assert device.state == "2017-05-17T20:54:00" -# pylint: disable=no-member async def test_timezone_intervals(hass: HomeAssistant) -> None: """Test date sensor behavior in a timezone besides UTC.""" hass.config.set_time_zone("America/New_York") diff --git a/tests/components/water_heater/test_init.py b/tests/components/water_heater/test_init.py index 66276f0bc88..bc996ab6fa4 100644 --- a/tests/components/water_heater/test_init.py +++ b/tests/components/water_heater/test_init.py @@ -71,14 +71,12 @@ async def test_sync_turn_on(hass: HomeAssistant) -> None: setattr(water_heater, "turn_on", MagicMock()) await water_heater.async_turn_on() - # pylint: disable-next=no-member assert water_heater.turn_on.call_count == 1 # Test with async_turn_on method defined setattr(water_heater, "async_turn_on", AsyncMock()) await water_heater.async_turn_on() - # pylint: disable-next=no-member assert water_heater.async_turn_on.call_count == 1 @@ -91,12 +89,10 @@ async def test_sync_turn_off(hass: HomeAssistant) -> None: setattr(water_heater, "turn_off", MagicMock()) await water_heater.async_turn_off() - # pylint: disable-next=no-member assert water_heater.turn_off.call_count == 1 # Test with async_turn_off method defined setattr(water_heater, "async_turn_off", AsyncMock()) await water_heater.async_turn_off() - # pylint: disable-next=no-member assert water_heater.async_turn_off.call_count == 1 diff --git a/tests/components/websocket_api/test_auth.py b/tests/components/websocket_api/test_auth.py index 51bff1af0d7..aba34aeb44b 100644 --- a/tests/components/websocket_api/test_auth.py +++ b/tests/components/websocket_api/test_auth.py @@ -296,7 +296,6 @@ async def test_auth_sending_unknown_type_disconnects( auth_msg = await ws.receive_json() assert auth_msg["type"] == TYPE_AUTH_REQUIRED - # pylint: disable-next=protected-access await ws._writer._send_frame(b"1" * 130, 0x30) auth_msg = await ws.receive() assert auth_msg.type == WSMsgType.close From 46a0f8410173de30d45ef9182022a0da28eb247a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Aug 2023 20:18:21 -0500 Subject: [PATCH 024/124] Bump bluetooth-data-tools to 1.9.0 (#98927) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/components/esphome/manifest.json | 2 +- homeassistant/components/ld2410_ble/manifest.json | 2 +- homeassistant/components/led_ble/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 84453344c3c..db28e550d23 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -18,7 +18,7 @@ "bleak-retry-connector==3.1.1", "bluetooth-adapters==0.16.0", "bluetooth-auto-recovery==1.2.1", - "bluetooth-data-tools==1.8.0", + "bluetooth-data-tools==1.9.0", "dbus-fast==1.93.0" ] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 313ba5355bb..0bcae814b75 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "requirements": [ "async_interrupt==1.1.1", "aioesphomeapi==16.0.1", - "bluetooth-data-tools==1.8.0", + "bluetooth-data-tools==1.9.0", "esphome-dashboard-api==1.2.3" ], "zeroconf": ["_esphomelib._tcp.local."] diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index 1115a0efc54..6065009760e 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.8.0", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.9.0", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index ffaf2bf87db..b9bdf31f066 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -32,5 +32,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.8.0", "led-ble==1.0.0"] + "requirements": ["bluetooth-data-tools==1.9.0", "led-ble==1.0.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b09296888fe..878e1dabb47 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -12,7 +12,7 @@ bleak-retry-connector==3.1.1 bleak==0.20.2 bluetooth-adapters==0.16.0 bluetooth-auto-recovery==1.2.1 -bluetooth-data-tools==1.8.0 +bluetooth-data-tools==1.9.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.3 diff --git a/requirements_all.txt b/requirements_all.txt index d6572d05f3b..2c8f3b9f8f1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -543,7 +543,7 @@ bluetooth-auto-recovery==1.2.1 # homeassistant.components.esphome # homeassistant.components.ld2410_ble # homeassistant.components.led_ble -bluetooth-data-tools==1.8.0 +bluetooth-data-tools==1.9.0 # homeassistant.components.bond bond-async==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ea5e8680ec7..7f7865d5b40 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -454,7 +454,7 @@ bluetooth-auto-recovery==1.2.1 # homeassistant.components.esphome # homeassistant.components.ld2410_ble # homeassistant.components.led_ble -bluetooth-data-tools==1.8.0 +bluetooth-data-tools==1.9.0 # homeassistant.components.bond bond-async==0.2.1 From 14f80560c005a8bb479ca78dbff7cd2d562f5d90 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 24 Aug 2023 08:14:46 +0200 Subject: [PATCH 025/124] Use snapshot assertion for Ridwell diagnostics test (#98919) --- tests/components/ridwell/conftest.py | 7 ++- .../ridwell/snapshots/test_diagnostics.ambr | 49 ++++++++++++++++++ tests/components/ridwell/test_diagnostics.py | 50 +++---------------- 3 files changed, 62 insertions(+), 44 deletions(-) create mode 100644 tests/components/ridwell/snapshots/test_diagnostics.ambr diff --git a/tests/components/ridwell/conftest.py b/tests/components/ridwell/conftest.py index 87ca00c37c3..651c2a96388 100644 --- a/tests/components/ridwell/conftest.py +++ b/tests/components/ridwell/conftest.py @@ -56,7 +56,12 @@ def client_fixture(account): @pytest.fixture(name="config_entry") def config_entry_fixture(hass, config): """Define a config entry fixture.""" - entry = MockConfigEntry(domain=DOMAIN, unique_id=config[CONF_USERNAME], data=config) + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=config[CONF_USERNAME], + data=config, + entry_id="11554ec901379b9cc8f5a6c1d11ce978", + ) entry.add_to_hass(hass) return entry diff --git a/tests/components/ridwell/snapshots/test_diagnostics.ambr b/tests/components/ridwell/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..a98374d2941 --- /dev/null +++ b/tests/components/ridwell/snapshots/test_diagnostics.ambr @@ -0,0 +1,49 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'data': list([ + dict({ + '_async_request': None, + 'event_id': 'event_123', + 'pickup_date': dict({ + '__type': "", + 'isoformat': '2022-01-24', + }), + 'pickups': list([ + dict({ + 'category': dict({ + '__type': "", + 'repr': "", + }), + 'name': 'Plastic Film', + 'offer_id': 'offer_123', + 'priority': 1, + 'product_id': 'product_123', + 'quantity': 1, + }), + ]), + 'state': dict({ + '__type': "", + 'repr': "", + }), + }), + ]), + 'entry': dict({ + 'data': dict({ + 'password': '**REDACTED**', + 'username': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'ridwell', + 'entry_id': '11554ec901379b9cc8f5a6c1d11ce978', + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': '**REDACTED**', + 'unique_id': '**REDACTED**', + 'version': 2, + }), + }) +# --- diff --git a/tests/components/ridwell/test_diagnostics.py b/tests/components/ridwell/test_diagnostics.py index e73b352f3d9..c87004a8e76 100644 --- a/tests/components/ridwell/test_diagnostics.py +++ b/tests/components/ridwell/test_diagnostics.py @@ -1,5 +1,6 @@ """Test Ridwell diagnostics.""" -from homeassistant.components.diagnostics import REDACTED +from syrupy import SnapshotAssertion + from homeassistant.core import HomeAssistant from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -11,47 +12,10 @@ async def test_entry_diagnostics( config_entry, hass_client: ClientSessionGenerator, setup_config_entry, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "entry": { - "entry_id": config_entry.entry_id, - "version": 2, - "domain": "ridwell", - "title": REDACTED, - "data": {"username": REDACTED, "password": REDACTED}, - "options": {}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "unique_id": REDACTED, - "disabled_by": None, - }, - "data": [ - { - "_async_request": None, - "event_id": "event_123", - "pickup_date": { - "__type": "", - "isoformat": "2022-01-24", - }, - "pickups": [ - { - "name": "Plastic Film", - "offer_id": "offer_123", - "priority": 1, - "product_id": "product_123", - "quantity": 1, - "category": { - "__type": "", - "repr": "", - }, - } - ], - "state": { - "__type": "", - "repr": "", - }, - } - ], - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) From 8d576c900dd50c20b5559a0ea24298fde82d408c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 24 Aug 2023 09:18:25 +0200 Subject: [PATCH 026/124] Bump hass-nabucasa from 0.69.0 to 0.70.0 (#98935) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index d8fd2148b4d..a8e28d66291 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -8,5 +8,5 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["hass_nabucasa"], - "requirements": ["hass-nabucasa==0.69.0"] + "requirements": ["hass-nabucasa==0.70.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 878e1dabb47..9d9e98e0c26 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -19,7 +19,7 @@ cryptography==41.0.3 dbus-fast==1.93.0 fnv-hash-fast==0.4.0 ha-av==10.1.1 -hass-nabucasa==0.69.0 +hass-nabucasa==0.70.0 hassil==1.2.5 home-assistant-bluetooth==1.10.2 home-assistant-frontend==20230802.1 diff --git a/requirements_all.txt b/requirements_all.txt index 2c8f3b9f8f1..3310ca991be 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -952,7 +952,7 @@ ha-philipsjs==3.1.0 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.69.0 +hass-nabucasa==0.70.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7f7865d5b40..45aca4548f9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -747,7 +747,7 @@ ha-philipsjs==3.1.0 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.69.0 +hass-nabucasa==0.70.0 # homeassistant.components.conversation hassil==1.2.5 From 602a80c35c1fcaee7435758475a694f1c19b157d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 24 Aug 2023 09:19:36 +0200 Subject: [PATCH 027/124] Use snapshot assertion for EasyEnergy diagnostics test (#98909) --- .../snapshots/test_diagnostics.ambr | 63 ++++++++++++++++ .../components/easyenergy/test_diagnostics.py | 73 +++---------------- 2 files changed, 74 insertions(+), 62 deletions(-) create mode 100644 tests/components/easyenergy/snapshots/test_diagnostics.ambr diff --git a/tests/components/easyenergy/snapshots/test_diagnostics.ambr b/tests/components/easyenergy/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..805846832aa --- /dev/null +++ b/tests/components/easyenergy/snapshots/test_diagnostics.ambr @@ -0,0 +1,63 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'energy_return': dict({ + 'average_price': 0.14599, + 'current_hour_price': 0.18629, + 'highest_price_time': '2023-01-19T16:00:00+00:00', + 'lowest_price_time': '2023-01-19T02:00:00+00:00', + 'max_price': 0.20394, + 'min_price': 0.10172, + 'next_hour_price': 0.20394, + 'percentage_of_max': 91.35, + }), + 'energy_usage': dict({ + 'average_price': 0.17665, + 'current_hour_price': 0.22541, + 'highest_price_time': '2023-01-19T16:00:00+00:00', + 'lowest_price_time': '2023-01-19T02:00:00+00:00', + 'max_price': 0.24677, + 'min_price': 0.12308, + 'next_hour_price': 0.24677, + 'percentage_of_max': 91.34, + }), + 'entry': dict({ + 'title': 'energy', + }), + 'gas': dict({ + 'current_hour_price': 0.7253, + 'next_hour_price': 0.7253, + }), + }) +# --- +# name: test_diagnostics_no_gas_today + dict({ + 'energy_return': dict({ + 'average_price': 0.14599, + 'current_hour_price': 0.18629, + 'highest_price_time': '2023-01-19T16:00:00+00:00', + 'lowest_price_time': '2023-01-19T02:00:00+00:00', + 'max_price': 0.20394, + 'min_price': 0.10172, + 'next_hour_price': 0.20394, + 'percentage_of_max': 91.35, + }), + 'energy_usage': dict({ + 'average_price': 0.17665, + 'current_hour_price': 0.22541, + 'highest_price_time': '2023-01-19T16:00:00+00:00', + 'lowest_price_time': '2023-01-19T02:00:00+00:00', + 'max_price': 0.24677, + 'min_price': 0.12308, + 'next_hour_price': 0.24677, + 'percentage_of_max': 91.34, + }), + 'entry': dict({ + 'title': 'energy', + }), + 'gas': dict({ + 'current_hour_price': None, + 'next_hour_price': None, + }), + }) +# --- diff --git a/tests/components/easyenergy/test_diagnostics.py b/tests/components/easyenergy/test_diagnostics.py index 336f363e6a1..f76821cf265 100644 --- a/tests/components/easyenergy/test_diagnostics.py +++ b/tests/components/easyenergy/test_diagnostics.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock from easyenergy import EasyEnergyNoDataError import pytest +from syrupy import SnapshotAssertion from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY from homeassistant.const import ATTR_ENTITY_ID @@ -19,39 +20,13 @@ async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - assert await get_diagnostics_for_config_entry( - hass, hass_client, init_integration - ) == { - "entry": { - "title": "energy", - }, - "energy_usage": { - "current_hour_price": 0.22541, - "next_hour_price": 0.24677, - "average_price": 0.17665, - "max_price": 0.24677, - "min_price": 0.12308, - "highest_price_time": "2023-01-19T16:00:00+00:00", - "lowest_price_time": "2023-01-19T02:00:00+00:00", - "percentage_of_max": 91.34, - }, - "energy_return": { - "current_hour_price": 0.18629, - "next_hour_price": 0.20394, - "average_price": 0.14599, - "max_price": 0.20394, - "min_price": 0.10172, - "highest_price_time": "2023-01-19T16:00:00+00:00", - "lowest_price_time": "2023-01-19T02:00:00+00:00", - "percentage_of_max": 91.35, - }, - "gas": { - "current_hour_price": 0.7253, - "next_hour_price": 0.7253, - }, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) @pytest.mark.freeze_time("2023-01-19 15:00:00") @@ -60,6 +35,7 @@ async def test_diagnostics_no_gas_today( hass_client: ClientSessionGenerator, mock_easyenergy: MagicMock, init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics, no gas sensors available.""" await async_setup_component(hass, "homeassistant", {}) @@ -73,34 +49,7 @@ async def test_diagnostics_no_gas_today( ) await hass.async_block_till_done() - assert await get_diagnostics_for_config_entry( - hass, hass_client, init_integration - ) == { - "entry": { - "title": "energy", - }, - "energy_usage": { - "current_hour_price": 0.22541, - "next_hour_price": 0.24677, - "average_price": 0.17665, - "max_price": 0.24677, - "min_price": 0.12308, - "highest_price_time": "2023-01-19T16:00:00+00:00", - "lowest_price_time": "2023-01-19T02:00:00+00:00", - "percentage_of_max": 91.34, - }, - "energy_return": { - "current_hour_price": 0.18629, - "next_hour_price": 0.20394, - "average_price": 0.14599, - "max_price": 0.20394, - "min_price": 0.10172, - "highest_price_time": "2023-01-19T16:00:00+00:00", - "lowest_price_time": "2023-01-19T02:00:00+00:00", - "percentage_of_max": 91.35, - }, - "gas": { - "current_hour_price": None, - "next_hour_price": None, - }, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) From fe164d06a7e6a4344581d27aad8b653a2108735b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 24 Aug 2023 09:23:48 +0200 Subject: [PATCH 028/124] Add entity translations to Sabnzbd (#98923) --- homeassistant/components/sabnzbd/sensor.py | 33 +++++++---------- homeassistant/components/sabnzbd/strings.json | 37 +++++++++++++++++++ 2 files changed, 51 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/sabnzbd/sensor.py b/homeassistant/components/sabnzbd/sensor.py index 8edc579b7ab..d4920ef77f3 100644 --- a/homeassistant/components/sabnzbd/sensor.py +++ b/homeassistant/components/sabnzbd/sensor.py @@ -17,7 +17,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN, SIGNAL_SABNZBD_UPDATED -from .const import DEFAULT_NAME, KEY_API_DATA, KEY_NAME +from .const import DEFAULT_NAME, KEY_API_DATA @dataclass @@ -37,51 +37,51 @@ SPEED_KEY = "kbpersec" SENSOR_TYPES: tuple[SabnzbdSensorEntityDescription, ...] = ( SabnzbdSensorEntityDescription( key="status", - name="Status", + translation_key="status", ), SabnzbdSensorEntityDescription( key=SPEED_KEY, - name="Speed", + translation_key="speed", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, ), SabnzbdSensorEntityDescription( key="mb", - name="Queue", + translation_key="queue", native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, state_class=SensorStateClass.MEASUREMENT, ), SabnzbdSensorEntityDescription( key="mbleft", - name="Left", + translation_key="left", native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, state_class=SensorStateClass.MEASUREMENT, ), SabnzbdSensorEntityDescription( key="diskspacetotal1", - name="Disk", + translation_key="total_disk_space", native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, state_class=SensorStateClass.MEASUREMENT, ), SabnzbdSensorEntityDescription( key="diskspace1", - name="Disk Free", + translation_key="free_disk_space", native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, state_class=SensorStateClass.MEASUREMENT, ), SabnzbdSensorEntityDescription( key="noofslots_total", - name="Queue Count", + translation_key="queue_count", state_class=SensorStateClass.TOTAL, ), SabnzbdSensorEntityDescription( key="day_size", - name="Daily Total", + translation_key="daily_total", native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, entity_registry_enabled_default=False, @@ -89,7 +89,7 @@ SENSOR_TYPES: tuple[SabnzbdSensorEntityDescription, ...] = ( ), SabnzbdSensorEntityDescription( key="week_size", - name="Weekly Total", + translation_key="weekly_total", native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, entity_registry_enabled_default=False, @@ -97,7 +97,7 @@ SENSOR_TYPES: tuple[SabnzbdSensorEntityDescription, ...] = ( ), SabnzbdSensorEntityDescription( key="month_size", - name="Monthly Total", + translation_key="monthly_total", native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, entity_registry_enabled_default=False, @@ -105,7 +105,7 @@ SENSOR_TYPES: tuple[SabnzbdSensorEntityDescription, ...] = ( ), SabnzbdSensorEntityDescription( key="total_size", - name="Total", + translation_key="overall_total", native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, state_class=SensorStateClass.TOTAL_INCREASING, @@ -137,13 +137,9 @@ async def async_setup_entry( entry_id = config_entry.entry_id sab_api_data = hass.data[DOMAIN][entry_id][KEY_API_DATA] - client_name = hass.data[DOMAIN][entry_id][KEY_NAME] async_add_entities( - [ - SabnzbdSensor(sab_api_data, client_name, sensor, entry_id) - for sensor in SENSOR_TYPES - ] + [SabnzbdSensor(sab_api_data, sensor, entry_id) for sensor in SENSOR_TYPES] ) @@ -152,11 +148,11 @@ class SabnzbdSensor(SensorEntity): entity_description: SabnzbdSensorEntityDescription _attr_should_poll = False + _attr_has_entity_name = True def __init__( self, sabnzbd_api_data, - client_name, description: SabnzbdSensorEntityDescription, entry_id, ) -> None: @@ -165,7 +161,6 @@ class SabnzbdSensor(SensorEntity): self._attr_unique_id = f"{entry_id}_{description.key}" self.entity_description = description self._sabnzbd_api = sabnzbd_api_data - self._attr_name = f"{client_name} {description.name}" self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, entry_id)}, diff --git a/homeassistant/components/sabnzbd/strings.json b/homeassistant/components/sabnzbd/strings.json index a8e146eeb27..8d3fb84fb9f 100644 --- a/homeassistant/components/sabnzbd/strings.json +++ b/homeassistant/components/sabnzbd/strings.json @@ -14,6 +14,43 @@ "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]" } }, + "entity": { + "sensor": { + "status": { + "name": "Status" + }, + "speed": { + "name": "Speed" + }, + "queue": { + "name": "Queue" + }, + "left": { + "name": "Left" + }, + "total_disk_space": { + "name": "Total disk space" + }, + "free_disk_space": { + "name": "Free disk space" + }, + "queue_count": { + "name": "Queue count" + }, + "daily_total": { + "name": "Daily total" + }, + "weekly_total": { + "name": "Weekly total" + }, + "monthly_total": { + "name": "Monthly total" + }, + "overall_total": { + "name": "Overall total" + } + } + }, "services": { "pause": { "name": "[%key:common::action::pause%]", From 8b232047c4298aeb213acf0847bb9ed49aa03abc Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 24 Aug 2023 09:50:39 +0200 Subject: [PATCH 029/124] Add origin info support for MQTT discovered items (#98782) * Add integration info support for MQTT discovery. * Moving logs to discovery * Revert adding class property * Rename to origin * Follow up comments --- .../components/mqtt/abbreviations.py | 7 ++ homeassistant/components/mqtt/const.py | 14 ++++ homeassistant/components/mqtt/discovery.py | 71 ++++++++++++++--- homeassistant/components/mqtt/mixins.py | 24 +++--- homeassistant/components/mqtt/models.py | 10 +++ tests/components/mqtt/test_discovery.py | 79 +++++++++++++++++++ 6 files changed, 185 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index cc0f37ea145..43f14eba1c5 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -111,6 +111,7 @@ ABBREVIATIONS = { "mode_stat_tpl": "mode_state_template", "modes": "modes", "name": "name", + "o": "origin", "obj_id": "object_id", "off_dly": "off_delay", "on_cmd_type": "on_command_type", @@ -275,3 +276,9 @@ DEVICE_ABBREVIATIONS = { "sw": "sw_version", "sa": "suggested_area", } + +ORIGIN_ABBREVIATIONS = { + "name": "name", + "sw": "sw_version", + "url": "support_url", +} diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 97d2e1473f5..c0589f60cbe 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -17,6 +17,7 @@ CONF_COMMAND_TOPIC = "command_topic" CONF_DISCOVERY_PREFIX = "discovery_prefix" CONF_ENCODING = "encoding" CONF_KEEPALIVE = "keepalive" +CONF_ORIGIN = "origin" CONF_QOS = ATTR_QOS CONF_RETAIN = ATTR_RETAIN CONF_SCHEMA = "schema" @@ -57,6 +58,19 @@ CONF_CLIENT_KEY = "client_key" CONF_CLIENT_CERT = "client_cert" CONF_TLS_INSECURE = "tls_insecure" +# Device and integration info options +CONF_IDENTIFIERS = "identifiers" +CONF_CONNECTIONS = "connections" +CONF_MANUFACTURER = "manufacturer" +CONF_HW_VERSION = "hw_version" +CONF_SW_VERSION = "sw_version" +CONF_VIA_DEVICE = "via_device" +CONF_DEPRECATED_VIA_HUB = "via_hub" +CONF_SUGGESTED_AREA = "suggested_area" +CONF_CONFIGURATION_URL = "configuration_url" +CONF_OBJECT_ID = "object_id" +CONF_SUPPORT_URL = "support_url" + DATA_MQTT = "mqtt" DATA_MQTT_AVAILABLE = "mqtt_client_available" diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 8e563a48cdd..e701937a048 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -9,8 +9,10 @@ import re import time from typing import Any +import voluptuous as vol + from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEVICE, CONF_PLATFORM +from homeassistant.const import CONF_DEVICE, CONF_NAME, CONF_PLATFORM from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.config_validation as cv @@ -24,16 +26,19 @@ from homeassistant.loader import async_get_mqtt from homeassistant.util.json import json_loads_object from .. import mqtt -from .abbreviations import ABBREVIATIONS, DEVICE_ABBREVIATIONS +from .abbreviations import ABBREVIATIONS, DEVICE_ABBREVIATIONS, ORIGIN_ABBREVIATIONS from .const import ( ATTR_DISCOVERY_HASH, ATTR_DISCOVERY_PAYLOAD, ATTR_DISCOVERY_TOPIC, CONF_AVAILABILITY, + CONF_ORIGIN, + CONF_SUPPORT_URL, + CONF_SW_VERSION, CONF_TOPIC, DOMAIN, ) -from .models import ReceiveMessage +from .models import MqttOriginInfo, ReceiveMessage from .util import get_mqtt_data _LOGGER = logging.getLogger(__name__) @@ -77,6 +82,16 @@ MQTT_DISCOVERY_DONE = "mqtt_discovery_done_{}" TOPIC_BASE = "~" +MQTT_ORIGIN_INFO_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_SW_VERSION): cv.string, + vol.Optional(CONF_SUPPORT_URL): cv.configuration_url, + } + ), +) + class MQTTDiscoveryPayload(dict[str, Any]): """Class to hold and MQTT discovery payload and discovery data.""" @@ -94,6 +109,30 @@ def set_discovery_hash(hass: HomeAssistant, discovery_hash: tuple[str, str]) -> get_mqtt_data(hass).discovery_already_discovered.add(discovery_hash) +@callback +def async_log_discovery_origin_info( + message: str, discovery_payload: MQTTDiscoveryPayload +) -> None: + """Log information about the discovery and origin.""" + if CONF_ORIGIN not in discovery_payload: + _LOGGER.info(message) + return + origin_info: MqttOriginInfo = discovery_payload[CONF_ORIGIN] + sw_version_log = "" + if sw_version := origin_info.get("sw_version"): + sw_version_log = f", version: {sw_version}" + support_url_log = "" + if support_url := origin_info.get("support_url"): + support_url_log = f", support URL: {support_url}" + _LOGGER.info( + "%s from external application %s%s%s", + message, + origin_info["name"], + sw_version_log, + support_url_log, + ) + + async def async_start( # noqa: C901 hass: HomeAssistant, discovery_topic: str, config_entry: ConfigEntry ) -> None: @@ -149,6 +188,22 @@ async def async_start( # noqa: C901 key = DEVICE_ABBREVIATIONS.get(key, key) device[key] = device.pop(abbreviated_key) + if CONF_ORIGIN in discovery_payload: + origin_info: dict[str, Any] = discovery_payload[CONF_ORIGIN] + try: + for key in list(origin_info): + abbreviated_key = key + key = ORIGIN_ABBREVIATIONS.get(key, key) + origin_info[key] = origin_info.pop(abbreviated_key) + MQTT_ORIGIN_INFO_SCHEMA(discovery_payload[CONF_ORIGIN]) + except Exception: # pylint: disable=broad-except + _LOGGER.warning( + "Unable to parse origin information " + "from discovery message, got %s", + discovery_payload[CONF_ORIGIN], + ) + return + if CONF_AVAILABILITY in discovery_payload: for availability_conf in cv.ensure_list( discovery_payload[CONF_AVAILABILITY] @@ -246,17 +301,15 @@ async def async_start( # noqa: C901 if discovery_hash in mqtt_data.discovery_already_discovered: # Dispatch update - _LOGGER.info( - "Component has already been discovered: %s %s, sending update", - component, - discovery_id, - ) + message = f"Component has already been discovered: {component} {discovery_id}, sending update" + async_log_discovery_origin_info(message, payload) async_dispatcher_send( hass, MQTT_DISCOVERY_UPDATED.format(discovery_hash), payload ) elif payload: # Add component - _LOGGER.info("Found new component: %s %s", component, discovery_id) + message = f"Found new component: {component} {discovery_id}" + async_log_discovery_origin_info(message, payload) mqtt_data.discovery_already_discovered.add(discovery_hash) async_dispatcher_send( hass, MQTT_DISCOVERY_NEW.format(component, "mqtt"), payload diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 97ba96f0207..3b28bc8804f 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -69,9 +69,20 @@ from .const import ( ATTR_DISCOVERY_PAYLOAD, ATTR_DISCOVERY_TOPIC, CONF_AVAILABILITY, + CONF_CONFIGURATION_URL, + CONF_CONNECTIONS, + CONF_DEPRECATED_VIA_HUB, CONF_ENCODING, + CONF_HW_VERSION, + CONF_IDENTIFIERS, + CONF_MANUFACTURER, + CONF_OBJECT_ID, + CONF_ORIGIN, CONF_QOS, + CONF_SUGGESTED_AREA, + CONF_SW_VERSION, CONF_TOPIC, + CONF_VIA_DEVICE, DEFAULT_ENCODING, DEFAULT_PAYLOAD_AVAILABLE, DEFAULT_PAYLOAD_NOT_AVAILABLE, @@ -84,6 +95,7 @@ from .discovery import ( MQTT_DISCOVERY_DONE, MQTT_DISCOVERY_NEW, MQTT_DISCOVERY_UPDATED, + MQTT_ORIGIN_INFO_SCHEMA, MQTTDiscoveryPayload, clear_discovery_hash, set_discovery_hash, @@ -119,17 +131,6 @@ CONF_PAYLOAD_NOT_AVAILABLE = "payload_not_available" CONF_JSON_ATTRS_TOPIC = "json_attributes_topic" CONF_JSON_ATTRS_TEMPLATE = "json_attributes_template" -CONF_IDENTIFIERS = "identifiers" -CONF_CONNECTIONS = "connections" -CONF_MANUFACTURER = "manufacturer" -CONF_HW_VERSION = "hw_version" -CONF_SW_VERSION = "sw_version" -CONF_VIA_DEVICE = "via_device" -CONF_DEPRECATED_VIA_HUB = "via_hub" -CONF_SUGGESTED_AREA = "suggested_area" -CONF_CONFIGURATION_URL = "configuration_url" -CONF_OBJECT_ID = "object_id" - MQTT_ATTRIBUTES_BLOCKED = { "assumed_state", "available", @@ -228,6 +229,7 @@ MQTT_ENTITY_DEVICE_INFO_SCHEMA = vol.All( MQTT_ENTITY_COMMON_SCHEMA = MQTT_AVAILABILITY_SCHEMA.extend( { vol.Optional(CONF_DEVICE): MQTT_ENTITY_DEVICE_INFO_SCHEMA, + vol.Optional(CONF_ORIGIN): MQTT_ORIGIN_INFO_SCHEMA, vol.Optional(CONF_ENABLED_BY_DEFAULT, default=True): cv.boolean, vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA, vol.Optional(CONF_ICON): cv.icon, diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index 99267d9572a..d553274ab3e 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -99,6 +99,16 @@ class PendingDiscovered(TypedDict): unsub: CALLBACK_TYPE +class MqttOriginInfo(TypedDict, total=False): + """Integration info of discovered entity.""" + + name: str + manufacturer: str + sw_version: str + hw_version: str + support_url: str + + class MqttCommandTemplate: """Class for rendering MQTT payload with command templates.""" diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index f51d469bde7..c528687623b 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -168,6 +168,83 @@ async def test_correct_config_discovery( assert ("binary_sensor", "bla") in hass.data["mqtt"].discovery_already_discovered +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) +async def test_discovery_integration_info( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test logging discovery of new and updated items.""" + await mqtt_mock_entry() + async_fire_mqtt_message( + hass, + "homeassistant/binary_sensor/bla/config", + '{ "name": "Beer", "state_topic": "test-topic", "o": {"name": "bla2mqtt", "sw": "1.0" } }', + ) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.beer") + + assert state is not None + assert state.name == "Beer" + + assert ( + "Found new component: binary_sensor bla from external application bla2mqtt, version: 1.0" + in caplog.text + ) + caplog.clear() + + # Send an update and add support url + async_fire_mqtt_message( + hass, + "homeassistant/binary_sensor/bla/config", + '{ "name": "Milk", "state_topic": "test-topic", "o": {"name": "bla2mqtt", "sw": "1.1", "url": "https://bla2mqtt.example.com/support" } }', + ) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.beer") + + assert state is not None + assert state.name == "Milk" + + assert ( + "Component has already been discovered: binary_sensor bla, sending update from external application bla2mqtt, version: 1.1, support URL: https://bla2mqtt.example.com/support" + in caplog.text + ) + + +@pytest.mark.parametrize( + "config_message", + [ + '{ "name": "Beer", "state_topic": "test-topic", "o": "bla2mqtt" }', + '{ "name": "Beer", "state_topic": "test-topic", "o": 2.0 }', + '{ "name": "Beer", "state_topic": "test-topic", "o": null }', + '{ "name": "Beer", "state_topic": "test-topic", "o": {"sw": "bla2mqtt"} }', + ], +) +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) +async def test_discovery_with_invalid_integration_info( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, + config_message: str, +) -> None: + """Test sending in correct JSON.""" + await mqtt_mock_entry() + async_fire_mqtt_message( + hass, + "homeassistant/binary_sensor/bla/config", + config_message, + ) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.beer") + + assert state is None + assert ( + "Unable to parse origin information from discovery message, got" in caplog.text + ) + + @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.FAN]) async def test_discover_fan( hass: HomeAssistant, @@ -1266,6 +1343,8 @@ ABBREVIATIONS_WHITE_LIST = [ "CONF_WILL_MESSAGE", "CONF_WS_PATH", "CONF_WS_HEADERS", + # Integration info + "CONF_SUPPORT_URL", # Undocumented device configuration "CONF_DEPRECATED_VIA_HUB", "CONF_VIA_DEVICE", From 7926c5cea92a99cce18dc56a794fd912c5a603e4 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 24 Aug 2023 10:33:06 +0200 Subject: [PATCH 030/124] Add repair issue about the deprecation of home plus control (#98828) Co-authored-by: Joost Lekkerkerker Co-authored-by: Franck Nijhof --- .../components/home_plus_control/__init__.py | 37 +++++++++++++++++++ .../components/home_plus_control/strings.json | 6 +++ 2 files changed, 43 insertions(+) diff --git a/homeassistant/components/home_plus_control/__init__.py b/homeassistant/components/home_plus_control/__init__.py index b6a1fc68a17..0accf53970d 100644 --- a/homeassistant/components/home_plus_control/__init__.py +++ b/homeassistant/components/home_plus_control/__init__.py @@ -15,6 +15,11 @@ from homeassistant.helpers import ( dispatcher, ) from homeassistant.helpers.device_registry import async_get as async_get_device_registry +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -49,6 +54,8 @@ PLATFORMS = [Platform.SWITCH] _LOGGER = logging.getLogger(__name__) +_ISSUE_MOTE_TO_NETAMO = "move_to_netamo" + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Legrand Home+ Control component from configuration.yaml.""" @@ -57,6 +64,20 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if DOMAIN not in config: return True + async_create_issue( + hass, + DOMAIN, + _ISSUE_MOTE_TO_NETAMO, + is_fixable=False, + is_persistent=False, + breaks_in_ha_version="2023.12.0", # Netamo decided to shutdown the api in december + severity=IssueSeverity.WARNING, + translation_key=_ISSUE_MOTE_TO_NETAMO, + translation_placeholders={ + "url": "https://www.home-assistant.io/integrations/netatmo/" + }, + ) + # Register the implementation from the config information config_flow.HomePlusControlFlowHandler.async_register_implementation( hass, @@ -70,6 +91,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Legrand Home+ Control from a config entry.""" hass_entry_data = hass.data[DOMAIN].setdefault(entry.entry_id, {}) + async_create_issue( + hass, + DOMAIN, + _ISSUE_MOTE_TO_NETAMO, + is_fixable=False, + is_persistent=False, + breaks_in_ha_version="2023.12.0", # Netamo decided to shutdown the api in december + severity=IssueSeverity.WARNING, + translation_key=_ISSUE_MOTE_TO_NETAMO, + translation_placeholders={ + "url": "https://www.home-assistant.io/integrations/netatmo/" + }, + ) + # Retrieve the registered implementation implementation = ( await config_entry_oauth2_flow.async_get_config_entry_implementation( @@ -168,4 +203,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> # And finally unload the domain config entry data hass.data[DOMAIN].pop(config_entry.entry_id) + async_delete_issue(hass, DOMAIN, _ISSUE_MOTE_TO_NETAMO) + return unload_ok diff --git a/homeassistant/components/home_plus_control/strings.json b/homeassistant/components/home_plus_control/strings.json index 9e860b397fb..d795323586d 100644 --- a/homeassistant/components/home_plus_control/strings.json +++ b/homeassistant/components/home_plus_control/strings.json @@ -16,5 +16,11 @@ "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" } + }, + "issues": { + "move_to_netamo": { + "title": "Legrand Home+ Control deprecation", + "description": "Home Assistant has been informed that the platform the Legrand Home+ Control integration is using, will be shutting down upcoming December.\n\nOnce that happens, it means this integration is no longer functional. We advise you to remove this integration and switch to the [Netatmo]({url}) integration, which provides a replacement for controlling your Legrand Home+ Control devices." + } } } From 147351be6e3c2890af28fa8bf87db4c914d38681 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 24 Aug 2023 10:39:22 +0200 Subject: [PATCH 031/124] Add Trafikverket Camera integration (#79873) --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/brands/trafikverket.json | 1 + .../trafikverket_camera/__init__.py | 29 +++ .../components/trafikverket_camera/camera.py | 84 +++++++ .../trafikverket_camera/config_flow.py | 122 +++++++++ .../components/trafikverket_camera/const.py | 10 + .../trafikverket_camera/coordinator.py | 76 ++++++ .../trafikverket_camera/manifest.json | 10 + .../trafikverket_camera/recorder.py | 13 + .../trafikverket_camera/strings.json | 51 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 1 + requirements_test_all.txt | 1 + .../trafikverket_camera/__init__.py | 10 + .../trafikverket_camera/conftest.py | 69 ++++++ .../trafikverket_camera/test_camera.py | 75 ++++++ .../trafikverket_camera/test_config_flow.py | 234 ++++++++++++++++++ .../trafikverket_camera/test_coordinator.py | 151 +++++++++++ .../trafikverket_camera/test_init.py | 80 ++++++ .../trafikverket_camera/test_recorder.py | 46 ++++ 23 files changed, 1083 insertions(+) create mode 100644 homeassistant/components/trafikverket_camera/__init__.py create mode 100644 homeassistant/components/trafikverket_camera/camera.py create mode 100644 homeassistant/components/trafikverket_camera/config_flow.py create mode 100644 homeassistant/components/trafikverket_camera/const.py create mode 100644 homeassistant/components/trafikverket_camera/coordinator.py create mode 100644 homeassistant/components/trafikverket_camera/manifest.json create mode 100644 homeassistant/components/trafikverket_camera/recorder.py create mode 100644 homeassistant/components/trafikverket_camera/strings.json create mode 100644 tests/components/trafikverket_camera/__init__.py create mode 100644 tests/components/trafikverket_camera/conftest.py create mode 100644 tests/components/trafikverket_camera/test_camera.py create mode 100644 tests/components/trafikverket_camera/test_config_flow.py create mode 100644 tests/components/trafikverket_camera/test_coordinator.py create mode 100644 tests/components/trafikverket_camera/test_init.py create mode 100644 tests/components/trafikverket_camera/test_recorder.py diff --git a/.strict-typing b/.strict-typing index 41138c812ec..19cee069b42 100644 --- a/.strict-typing +++ b/.strict-typing @@ -326,6 +326,7 @@ homeassistant.components.tplink.* homeassistant.components.tplink_omada.* homeassistant.components.tractive.* homeassistant.components.tradfri.* +homeassistant.components.trafikverket_camera.* homeassistant.components.trafikverket_ferry.* homeassistant.components.trafikverket_train.* homeassistant.components.trafikverket_weatherstation.* diff --git a/CODEOWNERS b/CODEOWNERS index 427c8290b60..e3e42b75280 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1300,6 +1300,8 @@ build.json @home-assistant/supervisor /tests/components/trace/ @home-assistant/core /homeassistant/components/tractive/ @Danielhiversen @zhulik @bieniu /tests/components/tractive/ @Danielhiversen @zhulik @bieniu +/homeassistant/components/trafikverket_camera/ @gjohansson-ST +/tests/components/trafikverket_camera/ @gjohansson-ST /homeassistant/components/trafikverket_ferry/ @gjohansson-ST /tests/components/trafikverket_ferry/ @gjohansson-ST /homeassistant/components/trafikverket_train/ @endor-force @gjohansson-ST diff --git a/homeassistant/brands/trafikverket.json b/homeassistant/brands/trafikverket.json index df444cbeb60..4b925d5c633 100644 --- a/homeassistant/brands/trafikverket.json +++ b/homeassistant/brands/trafikverket.json @@ -2,6 +2,7 @@ "domain": "trafikverket", "name": "Trafikverket", "integrations": [ + "trafikverket_camera", "trafikverket_ferry", "trafikverket_train", "trafikverket_weatherstation" diff --git a/homeassistant/components/trafikverket_camera/__init__.py b/homeassistant/components/trafikverket_camera/__init__.py new file mode 100644 index 00000000000..0ee4fd5010e --- /dev/null +++ b/homeassistant/components/trafikverket_camera/__init__.py @@ -0,0 +1,29 @@ +"""The trafikverket_camera component.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN, PLATFORMS +from .coordinator import TVDataUpdateCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Trafikverket Camera from a config entry.""" + + coordinator = TVDataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Trafikverket Camera config entry.""" + + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/trafikverket_camera/camera.py b/homeassistant/components/trafikverket_camera/camera.py new file mode 100644 index 00000000000..936e460638f --- /dev/null +++ b/homeassistant/components/trafikverket_camera/camera.py @@ -0,0 +1,84 @@ +"""Camera for the Trafikverket Camera integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from homeassistant.components.camera import Camera +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_LOCATION +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ATTR_DESCRIPTION, ATTR_TYPE, DOMAIN +from .coordinator import TVDataUpdateCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a Trafikverket Camera.""" + + coordinator: TVDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + [ + TVCamera( + coordinator, + entry.title, + entry.entry_id, + ) + ], + ) + + +class TVCamera(CoordinatorEntity[TVDataUpdateCoordinator], Camera): + """Implement Trafikverket camera.""" + + _attr_has_entity_name = True + _attr_name = None + _attr_translation_key = "tv_camera" + coordinator: TVDataUpdateCoordinator + + def __init__( + self, + coordinator: TVDataUpdateCoordinator, + name: str, + entry_id: str, + ) -> None: + """Initialize the camera.""" + super().__init__(coordinator) + Camera.__init__(self) + self._attr_unique_id = entry_id + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, entry_id)}, + manufacturer="Trafikverket", + model="v1.0", + name=name, + configuration_url="https://api.trafikinfo.trafikverket.se/", + ) + + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: + """Return camera picture.""" + return self.coordinator.data.image + + @property + def is_on(self) -> bool: + """Return camera on.""" + return self.coordinator.data.data.active is True + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return additional attributes.""" + return { + ATTR_DESCRIPTION: self.coordinator.data.data.description, + ATTR_LOCATION: self.coordinator.data.data.location, + ATTR_TYPE: self.coordinator.data.data.camera_type, + } diff --git a/homeassistant/components/trafikverket_camera/config_flow.py b/homeassistant/components/trafikverket_camera/config_flow.py new file mode 100644 index 00000000000..b8a14a5424e --- /dev/null +++ b/homeassistant/components/trafikverket_camera/config_flow.py @@ -0,0 +1,122 @@ +"""Adds config flow for Trafikverket Camera integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from pytrafikverket.exceptions import ( + InvalidAuthentication, + MultipleCamerasFound, + NoCameraFound, + UnknownError, +) +from pytrafikverket.trafikverket_camera import TrafikverketCamera +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv + +from .const import CONF_LOCATION, DOMAIN + + +class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Trafikverket Camera integration.""" + + VERSION = 1 + + entry: config_entries.ConfigEntry | None + + async def validate_input(self, sensor_api: str, location: str) -> dict[str, str]: + """Validate input from user input.""" + errors: dict[str, str] = {} + + web_session = async_get_clientsession(self.hass) + camera_api = TrafikverketCamera(web_session, sensor_api) + try: + await camera_api.async_get_camera(location) + except NoCameraFound: + errors["location"] = "invalid_location" + except MultipleCamerasFound: + errors["location"] = "more_locations" + except InvalidAuthentication: + errors["base"] = "invalid_auth" + except UnknownError: + errors["base"] = "cannot_connect" + + return errors + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle re-authentication with Trafikverket.""" + + self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm re-authentication with Trafikverket.""" + errors = {} + + if user_input: + api_key = user_input[CONF_API_KEY] + + assert self.entry is not None + errors = await self.validate_input(api_key, self.entry.data[CONF_LOCATION]) + + if not errors: + self.hass.config_entries.async_update_entry( + self.entry, + data={ + **self.entry.data, + CONF_API_KEY: api_key, + }, + ) + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY): cv.string, + } + ), + errors=errors, + ) + + async def async_step_user( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors = {} + + if user_input: + api_key = user_input[CONF_API_KEY] + location = user_input[CONF_LOCATION] + + errors = await self.validate_input(api_key, location) + + if not errors: + await self.async_set_unique_id(f"{DOMAIN}-{location}") + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[CONF_LOCATION], + data={ + CONF_API_KEY: api_key, + CONF_LOCATION: location, + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_LOCATION): cv.string, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/trafikverket_camera/const.py b/homeassistant/components/trafikverket_camera/const.py new file mode 100644 index 00000000000..6657ab1a853 --- /dev/null +++ b/homeassistant/components/trafikverket_camera/const.py @@ -0,0 +1,10 @@ +"""Adds constants for Trafikverket Camera integration.""" +from homeassistant.const import Platform + +DOMAIN = "trafikverket_camera" +CONF_LOCATION = "location" +PLATFORMS = [Platform.CAMERA] +ATTRIBUTION = "Data provided by Trafikverket" + +ATTR_DESCRIPTION = "description" +ATTR_TYPE = "type" diff --git a/homeassistant/components/trafikverket_camera/coordinator.py b/homeassistant/components/trafikverket_camera/coordinator.py new file mode 100644 index 00000000000..eb5a047ca73 --- /dev/null +++ b/homeassistant/components/trafikverket_camera/coordinator.py @@ -0,0 +1,76 @@ +"""DataUpdateCoordinator for the Trafikverket Camera integration.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +from io import BytesIO +import logging + +from pytrafikverket.exceptions import ( + InvalidAuthentication, + MultipleCamerasFound, + NoCameraFound, + UnknownError, +) +from pytrafikverket.trafikverket_camera import CameraInfo, TrafikverketCamera + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_LOCATION, DOMAIN + +_LOGGER = logging.getLogger(__name__) +TIME_BETWEEN_UPDATES = timedelta(minutes=5) + + +@dataclass +class CameraData: + """Dataclass for Camera data.""" + + data: CameraInfo + image: bytes | None + + +class TVDataUpdateCoordinator(DataUpdateCoordinator[CameraData]): + """A Trafikverket Data Update Coordinator.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the Trafikverket coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=TIME_BETWEEN_UPDATES, + ) + self.session = async_get_clientsession(hass) + self._camera_api = TrafikverketCamera(self.session, entry.data[CONF_API_KEY]) + self._location = entry.data[CONF_LOCATION] + + async def _async_update_data(self) -> CameraData: + """Fetch data from Trafikverket.""" + camera_data: CameraInfo + image: bytes | None = None + try: + camera_data = await self._camera_api.async_get_camera(self._location) + except (NoCameraFound, MultipleCamerasFound, UnknownError) as error: + raise UpdateFailed from error + except InvalidAuthentication as error: + raise ConfigEntryAuthFailed from error + + if camera_data.photourl is None: + return CameraData(data=camera_data, image=None) + + image_url = camera_data.photourl + if camera_data.fullsizephoto: + image_url = f"{camera_data.photourl}?type=fullsize" + + async with self.session.get(image_url, timeout=10) as get_image: + if get_image.status not in range(200, 299): + raise UpdateFailed("Could not retrieve image") + image = BytesIO(await get_image.read()).getvalue() + + return CameraData(data=camera_data, image=image) diff --git a/homeassistant/components/trafikverket_camera/manifest.json b/homeassistant/components/trafikverket_camera/manifest.json new file mode 100644 index 00000000000..440d7237171 --- /dev/null +++ b/homeassistant/components/trafikverket_camera/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "trafikverket_camera", + "name": "Trafikverket Camera", + "codeowners": ["@gjohansson-ST"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/trafikverket_camera", + "iot_class": "cloud_polling", + "loggers": ["pytrafikverket"], + "requirements": ["pytrafikverket==0.3.5"] +} diff --git a/homeassistant/components/trafikverket_camera/recorder.py b/homeassistant/components/trafikverket_camera/recorder.py new file mode 100644 index 00000000000..b6b608749ad --- /dev/null +++ b/homeassistant/components/trafikverket_camera/recorder.py @@ -0,0 +1,13 @@ +"""Integration platform for recorder.""" +from __future__ import annotations + +from homeassistant.const import ATTR_LOCATION +from homeassistant.core import HomeAssistant, callback + +from .const import ATTR_DESCRIPTION + + +@callback +def exclude_attributes(hass: HomeAssistant) -> set[str]: + """Exclude description and location from being recorded in the database.""" + return {ATTR_DESCRIPTION, ATTR_LOCATION} diff --git a/homeassistant/components/trafikverket_camera/strings.json b/homeassistant/components/trafikverket_camera/strings.json new file mode 100644 index 00000000000..c128f7729bc --- /dev/null +++ b/homeassistant/components/trafikverket_camera/strings.json @@ -0,0 +1,51 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_location": "Could not find a camera location with the specified name", + "more_locations": "Found multiple camera locations with the specified name" + }, + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "location": "[%key:common::config_flow::data::location%]" + } + } + } + }, + "entity": { + "camera": { + "tv_camera": { + "state_attributes": { + "description": { + "name": "Description" + }, + "direction": { + "name": "Direction" + }, + "full_size_photo": { + "name": "Full size photo" + }, + "location": { + "name": "[%key:common::config_flow::data::location%]" + }, + "photo_url": { + "name": "Photo url" + }, + "status": { + "name": "Status" + }, + "type": { + "name": "Camera type" + } + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 0bfbf362eb3..82c2d82f423 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -480,6 +480,7 @@ FLOWS = { "traccar", "tractive", "tradfri", + "trafikverket_camera", "trafikverket_ferry", "trafikverket_train", "trafikverket_weatherstation", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 40883ef3d7c..75540a3af83 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5878,6 +5878,12 @@ "trafikverket": { "name": "Trafikverket", "integrations": { + "trafikverket_camera": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Trafikverket Camera" + }, "trafikverket_ferry": { "integration_type": "hub", "config_flow": true, diff --git a/mypy.ini b/mypy.ini index a4bf83dbf27..644fba0df89 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3023,6 +3023,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.trafikverket_camera.*] +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.trafikverket_ferry.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 3310ca991be..601be9507ac 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2191,6 +2191,7 @@ pytraccar==1.0.0 # homeassistant.components.tradfri pytradfri[async]==9.0.1 +# homeassistant.components.trafikverket_camera # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 45aca4548f9..43ccd45c09c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1608,6 +1608,7 @@ pytraccar==1.0.0 # homeassistant.components.tradfri pytradfri[async]==9.0.1 +# homeassistant.components.trafikverket_camera # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation diff --git a/tests/components/trafikverket_camera/__init__.py b/tests/components/trafikverket_camera/__init__.py new file mode 100644 index 00000000000..026c122fb57 --- /dev/null +++ b/tests/components/trafikverket_camera/__init__.py @@ -0,0 +1,10 @@ +"""Tests for the Trafikverket Camera integration.""" +from __future__ import annotations + +from homeassistant.components.trafikverket_camera.const import CONF_LOCATION +from homeassistant.const import CONF_API_KEY + +ENTRY_CONFIG = { + CONF_API_KEY: "1234567890", + CONF_LOCATION: "Test location", +} diff --git a/tests/components/trafikverket_camera/conftest.py b/tests/components/trafikverket_camera/conftest.py new file mode 100644 index 00000000000..2bbc888b31d --- /dev/null +++ b/tests/components/trafikverket_camera/conftest.py @@ -0,0 +1,69 @@ +"""Fixtures for Trafikverket Camera integration tests.""" +from __future__ import annotations + +from datetime import datetime +from unittest.mock import patch + +import pytest +from pytrafikverket.trafikverket_camera import CameraInfo + +from homeassistant.components.trafikverket_camera.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from . import ENTRY_CONFIG + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +@pytest.fixture(name="load_int") +async def load_integration_from_entry( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, get_camera: CameraInfo +) -> MockConfigEntry: + """Set up the Trafikverket Ferry integration in Home Assistant.""" + aioclient_mock.get( + "https://www.testurl.com/test_photo.jpg?type=fullsize", content=b"0123456789" + ) + + config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + entry_id="1", + unique_id="123", + title="Test location", + ) + + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_camera.coordinator.TrafikverketCamera.async_get_camera", + return_value=get_camera, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +@pytest.fixture(name="get_camera") +def fixture_get_camera() -> CameraInfo: + """Construct Camera Mock.""" + + return CameraInfo( + camera_name="Test_camera", + camera_id="1234", + active=True, + deleted=False, + description="Test Camera for testing", + direction="180", + fullsizephoto=True, + location="Test location", + modified=datetime(2022, 4, 4, 4, 4, 4, tzinfo=dt_util.UTC), + phototime=datetime(2022, 4, 4, 4, 4, 4, tzinfo=dt_util.UTC), + photourl="https://www.testurl.com/test_photo.jpg", + status="Running", + camera_type="Road", + ) diff --git a/tests/components/trafikverket_camera/test_camera.py b/tests/components/trafikverket_camera/test_camera.py new file mode 100644 index 00000000000..57451ae93a9 --- /dev/null +++ b/tests/components/trafikverket_camera/test_camera.py @@ -0,0 +1,75 @@ +"""The test for the Trafikverket camera platform.""" +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import patch + +import pytest +from pytrafikverket.trafikverket_camera import CameraInfo + +from homeassistant.components.camera import async_get_image +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.util import dt as dt_util + +from tests.common import async_fire_time_changed +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_camera( + hass: HomeAssistant, + load_int: ConfigEntry, + monkeypatch: pytest.MonkeyPatch, + aioclient_mock: AiohttpClientMocker, + get_camera: CameraInfo, +) -> None: + """Test the Trafikverket Camera sensor.""" + state1 = hass.states.get("camera.test_location") + assert state1.state == "idle" + assert state1.attributes["description"] == "Test Camera for testing" + assert state1.attributes["location"] == "Test location" + assert state1.attributes["type"] == "Road" + + with patch( + "homeassistant.components.trafikverket_camera.coordinator.TrafikverketCamera.async_get_camera", + return_value=get_camera, + ): + aioclient_mock.get( + "https://www.testurl.com/test_photo.jpg?type=fullsize", + content=b"9876543210", + ) + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(minutes=6), + ) + await hass.async_block_till_done() + + state1 = hass.states.get("camera.test_location") + assert state1.state == "idle" + assert state1.attributes != {} + + assert await async_get_image(hass, "camera.test_location") + + monkeypatch.setattr( + get_camera, + "photourl", + None, + ) + aioclient_mock.get( + "https://www.testurl.com/test_photo.jpg?type=fullsize", + status=404, + ) + + with patch( + "homeassistant.components.trafikverket_camera.coordinator.TrafikverketCamera.async_get_camera", + return_value=get_camera, + ): + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(minutes=6), + ) + await hass.async_block_till_done() + + with pytest.raises(HomeAssistantError): + await async_get_image(hass, "camera.test_location") diff --git a/tests/components/trafikverket_camera/test_config_flow.py b/tests/components/trafikverket_camera/test_config_flow.py new file mode 100644 index 00000000000..38c49d54208 --- /dev/null +++ b/tests/components/trafikverket_camera/test_config_flow.py @@ -0,0 +1,234 @@ +"""Test the Trafikverket Camera config flow.""" +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from pytrafikverket.exceptions import ( + InvalidAuthentication, + MultipleCamerasFound, + NoCameraFound, + UnknownError, +) + +from homeassistant import config_entries +from homeassistant.components.trafikverket_camera.const import CONF_LOCATION, DOMAIN +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_camera", + ), patch( + "homeassistant.components.trafikverket_camera.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "1234567890", + CONF_LOCATION: "Test location", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Test location" + assert result2["data"] == { + "api_key": "1234567890", + "location": "Test location", + } + assert len(mock_setup_entry.mock_calls) == 1 + assert result2["result"].unique_id == "trafikverket_camera-Test location" + + +@pytest.mark.parametrize( + ("side_effect", "error_key", "base_error"), + [ + ( + InvalidAuthentication, + "base", + "invalid_auth", + ), + ( + NoCameraFound, + "location", + "invalid_location", + ), + ( + MultipleCamerasFound, + "location", + "more_locations", + ), + ( + UnknownError, + "base", + "cannot_connect", + ), + ], +) +async def test_flow_fails( + hass: HomeAssistant, side_effect: Exception, error_key: str, base_error: str +) -> None: + """Test config flow errors.""" + result4 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result4["type"] == FlowResultType.FORM + assert result4["step_id"] == config_entries.SOURCE_USER + + with patch( + "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_camera", + side_effect=side_effect, + ): + result4 = await hass.config_entries.flow.async_configure( + result4["flow_id"], + user_input={ + CONF_API_KEY: "1234567890", + CONF_LOCATION: "incorrect", + }, + ) + + assert result4["errors"] == {error_key: base_error} + + +async def test_reauth_flow(hass: HomeAssistant) -> None: + """Test a reauthentication flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: "1234567890", + CONF_LOCATION: "Test location", + }, + unique_id="1234", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": entry.unique_id, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + assert result["step_id"] == "reauth_confirm" + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_camera", + ), patch( + "homeassistant.components.trafikverket_camera.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "1234567891"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert entry.data == { + "api_key": "1234567891", + "location": "Test location", + } + + +@pytest.mark.parametrize( + ("side_effect", "error_key", "p_error"), + [ + ( + InvalidAuthentication, + "base", + "invalid_auth", + ), + ( + NoCameraFound, + "location", + "invalid_location", + ), + ( + MultipleCamerasFound, + "location", + "more_locations", + ), + ( + UnknownError, + "base", + "cannot_connect", + ), + ], +) +async def test_reauth_flow_error( + hass: HomeAssistant, side_effect: Exception, error_key: str, p_error: str +) -> None: + """Test a reauthentication flow with error.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: "1234567890", + CONF_LOCATION: "Test location", + }, + unique_id="1234", + ) + entry.add_to_hass(hass) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": entry.unique_id, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + + with patch( + "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_camera", + side_effect=side_effect, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "1234567890"}, + ) + await hass.async_block_till_done() + + assert result2["step_id"] == "reauth_confirm" + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {error_key: p_error} + + with patch( + "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_camera", + ), patch( + "homeassistant.components.trafikverket_camera.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "1234567891"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert entry.data == { + "api_key": "1234567891", + "location": "Test location", + } diff --git a/tests/components/trafikverket_camera/test_coordinator.py b/tests/components/trafikverket_camera/test_coordinator.py new file mode 100644 index 00000000000..2b21ce935b2 --- /dev/null +++ b/tests/components/trafikverket_camera/test_coordinator.py @@ -0,0 +1,151 @@ +"""The test for the Trafikverket Camera coordinator.""" +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from pytrafikverket.exceptions import ( + InvalidAuthentication, + MultipleCamerasFound, + NoCameraFound, + UnknownError, +) + +from homeassistant import config_entries +from homeassistant.components.trafikverket_camera.const import DOMAIN +from homeassistant.components.trafikverket_camera.coordinator import CameraData +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import UpdateFailed + +from . import ENTRY_CONFIG + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_coordinator( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + get_camera: CameraData, +) -> None: + """Test the Trafikverket Camera coordinator.""" + aioclient_mock.get( + "https://www.testurl.com/test_photo.jpg?type=fullsize", content=b"0123456789" + ) + + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + entry_id="1", + unique_id="123", + title="Test location", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_camera.coordinator.TrafikverketCamera.async_get_camera", + return_value=get_camera, + ) as mock_data: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + mock_data.assert_called_once() + state1 = hass.states.get("camera.test_location") + assert state1.state == "idle" + + +@pytest.mark.parametrize( + ("sideeffect", "p_error", "entry_state"), + [ + ( + InvalidAuthentication, + ConfigEntryAuthFailed, + config_entries.ConfigEntryState.SETUP_ERROR, + ), + ( + NoCameraFound, + UpdateFailed, + config_entries.ConfigEntryState.SETUP_RETRY, + ), + ( + MultipleCamerasFound, + UpdateFailed, + config_entries.ConfigEntryState.SETUP_RETRY, + ), + ( + UnknownError, + UpdateFailed, + config_entries.ConfigEntryState.SETUP_RETRY, + ), + ], +) +async def test_coordinator_failed_update( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + get_camera: CameraData, + sideeffect: str, + p_error: Exception, + entry_state: str, +) -> None: + """Test the Trafikverket Camera coordinator.""" + aioclient_mock.get( + "https://www.testurl.com/test_photo.jpg?type=fullsize", content=b"0123456789" + ) + + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + entry_id="1", + unique_id="123", + title="Test location", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_camera.coordinator.TrafikverketCamera.async_get_camera", + side_effect=sideeffect, + ) as mock_data: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + mock_data.assert_called_once() + state = hass.states.get("camera.test_location") + assert state is None + assert entry.state == entry_state + + +async def test_coordinator_failed_get_image( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + get_camera: CameraData, +) -> None: + """Test the Trafikverket Camera coordinator.""" + aioclient_mock.get( + "https://www.testurl.com/test_photo.jpg?type=fullsize", status=404 + ) + + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + entry_id="1", + unique_id="123", + title="Test location", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_camera.coordinator.TrafikverketCamera.async_get_camera", + return_value=get_camera, + ) as mock_data: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + mock_data.assert_called_once() + state = hass.states.get("camera.test_location") + assert state is None + assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY diff --git a/tests/components/trafikverket_camera/test_init.py b/tests/components/trafikverket_camera/test_init.py new file mode 100644 index 00000000000..d9de0a830a6 --- /dev/null +++ b/tests/components/trafikverket_camera/test_init.py @@ -0,0 +1,80 @@ +"""Test for Trafikverket Ferry component Init.""" +from __future__ import annotations + +from unittest.mock import patch + +from pytrafikverket.trafikverket_camera import CameraInfo + +from homeassistant import config_entries +from homeassistant.components.trafikverket_camera.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant + +from . import ENTRY_CONFIG + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_setup_entry( + hass: HomeAssistant, + get_camera: CameraInfo, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test setup entry.""" + aioclient_mock.get( + "https://www.testurl.com/test_photo.jpg?type=fullsize", content=b"0123456789" + ) + + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + entry_id="1", + unique_id="123", + title="Test location", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_camera.coordinator.TrafikverketCamera.async_get_camera", + return_value=get_camera, + ) as mock_tvt_camera: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.LOADED + assert len(mock_tvt_camera.mock_calls) == 1 + + +async def test_unload_entry( + hass: HomeAssistant, + get_camera: CameraInfo, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test unload an entry.""" + aioclient_mock.get( + "https://www.testurl.com/test_photo.jpg?type=fullsize", content=b"0123456789" + ) + + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + entry_id="1", + unique_id="321", + title="Test location", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_camera.coordinator.TrafikverketCamera.async_get_camera", + return_value=get_camera, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED diff --git a/tests/components/trafikverket_camera/test_recorder.py b/tests/components/trafikverket_camera/test_recorder.py new file mode 100644 index 00000000000..021433b33e7 --- /dev/null +++ b/tests/components/trafikverket_camera/test_recorder.py @@ -0,0 +1,46 @@ +"""The tests for Trafikcerket Camera recorder.""" +from __future__ import annotations + +import pytest +from pytrafikverket.trafikverket_camera import CameraInfo + +from homeassistant.components.recorder import Recorder +from homeassistant.components.recorder.history import get_significant_states +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from tests.components.recorder.common import async_wait_recording_done +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_exclude_attributes( + recorder_mock: Recorder, + hass: HomeAssistant, + load_int: ConfigEntry, + monkeypatch: pytest.MonkeyPatch, + aioclient_mock: AiohttpClientMocker, + get_camera: CameraInfo, +) -> None: + """Test camera has description and location excluded from recording.""" + state1 = hass.states.get("camera.test_location") + assert state1.state == "idle" + assert state1.attributes["description"] == "Test Camera for testing" + assert state1.attributes["location"] == "Test location" + assert state1.attributes["type"] == "Road" + await async_wait_recording_done(hass) + + states = await hass.async_add_executor_job( + get_significant_states, + hass, + dt_util.now(), + None, + hass.states.async_entity_ids(), + ) + assert len(states) == 1 + assert states.get("camera.test_location") + for entity_states in states.values(): + for state in entity_states: + assert "location" not in state.attributes + assert "description" not in state.attributes + assert "type" in state.attributes From 0a1ad8a1199fa5da465a8b748fbd983dc96d9f6a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 24 Aug 2023 10:42:05 +0200 Subject: [PATCH 032/124] Add entity translations to Ridwell (#98918) --- homeassistant/components/ridwell/sensor.py | 2 +- homeassistant/components/ridwell/strings.json | 12 ++++++++++++ homeassistant/components/ridwell/switch.py | 6 ++---- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/ridwell/sensor.py b/homeassistant/components/ridwell/sensor.py index 1eba555e955..e4626831d7d 100644 --- a/homeassistant/components/ridwell/sensor.py +++ b/homeassistant/components/ridwell/sensor.py @@ -27,7 +27,7 @@ ATTR_QUANTITY = "quantity" SENSOR_DESCRIPTION = SensorEntityDescription( key=SENSOR_TYPE_NEXT_PICKUP, - name="Next Ridwell pickup", + translation_key="next_pickup", device_class=SensorDeviceClass.DATE, ) diff --git a/homeassistant/components/ridwell/strings.json b/homeassistant/components/ridwell/strings.json index 3f4cc1806a4..c3cf6365860 100644 --- a/homeassistant/components/ridwell/strings.json +++ b/homeassistant/components/ridwell/strings.json @@ -24,5 +24,17 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "entity": { + "sensor": { + "next_pickup": { + "name": "Next pickup" + } + }, + "switch": { + "opt_in": { + "name": "Opt-in to next pickup" + } + } } } diff --git a/homeassistant/components/ridwell/switch.py b/homeassistant/components/ridwell/switch.py index 7a948f8b883..f47fc1ca0af 100644 --- a/homeassistant/components/ridwell/switch.py +++ b/homeassistant/components/ridwell/switch.py @@ -16,11 +16,9 @@ from .const import DOMAIN from .coordinator import RidwellDataUpdateCoordinator from .entity import RidwellEntity -SWITCH_TYPE_OPT_IN = "opt_in" - SWITCH_DESCRIPTION = SwitchEntityDescription( - key=SWITCH_TYPE_OPT_IN, - name="Opt-in to next pickup", + key="opt_in", + translation_key="opt_in", icon="mdi:calendar-check", ) From f44215d28605144b415fb0f0e7121fed53713fd8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 24 Aug 2023 11:19:16 +0200 Subject: [PATCH 033/124] Use snapshot assertion for Brother diagnostics test (#98904) --- .../brother/fixtures/diagnostics_data.json | 66 ---------------- .../brother/snapshots/test_diagnostics.ambr | 75 +++++++++++++++++++ tests/components/brother/test_diagnostics.py | 10 ++- 3 files changed, 81 insertions(+), 70 deletions(-) delete mode 100644 tests/components/brother/fixtures/diagnostics_data.json create mode 100644 tests/components/brother/snapshots/test_diagnostics.ambr diff --git a/tests/components/brother/fixtures/diagnostics_data.json b/tests/components/brother/fixtures/diagnostics_data.json deleted file mode 100644 index fd22f861e8d..00000000000 --- a/tests/components/brother/fixtures/diagnostics_data.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "black_counter": null, - "black_ink": null, - "black_ink_remaining": null, - "black_ink_status": null, - "cyan_counter": null, - "bw_counter": 709, - "belt_unit_remaining_life": 97, - "belt_unit_remaining_pages": 48436, - "black_drum_counter": 1611, - "black_drum_remaining_life": 92, - "black_drum_remaining_pages": 16389, - "black_toner": 80, - "black_toner_remaining": 75, - "black_toner_status": 1, - "color_counter": 902, - "cyan_drum_counter": 1611, - "cyan_drum_remaining_life": 92, - "cyan_drum_remaining_pages": 16389, - "cyan_ink": null, - "cyan_ink_remaining": null, - "cyan_ink_status": null, - "cyan_toner": 10, - "cyan_toner_remaining": 10, - "cyan_toner_status": 1, - "drum_counter": 986, - "drum_remaining_life": 92, - "drum_remaining_pages": 11014, - "drum_status": 1, - "duplex_unit_pages_counter": 538, - "firmware": "1.17", - "fuser_remaining_life": 97, - "fuser_unit_remaining_pages": null, - "image_counter": null, - "laser_remaining_life": null, - "laser_unit_remaining_pages": 48389, - "magenta_counter": null, - "magenta_drum_counter": 1611, - "magenta_drum_remaining_life": 92, - "magenta_drum_remaining_pages": 16389, - "magenta_ink": null, - "magenta_ink_remaining": null, - "magenta_ink_status": null, - "magenta_toner": 10, - "magenta_toner_remaining": 8, - "magenta_toner_status": 2, - "model": "HL-L2340DW", - "page_counter": 986, - "pf_kit_1_remaining_life": 98, - "pf_kit_1_remaining_pages": 48741, - "pf_kit_mp_remaining_life": null, - "pf_kit_mp_remaining_pages": null, - "serial": "0123456789", - "status": "waiting", - "uptime": "2019-09-24T12:14:56+00:00", - "yellow_counter": null, - "yellow_drum_counter": 1611, - "yellow_drum_remaining_life": 92, - "yellow_drum_remaining_pages": 16389, - "yellow_ink": null, - "yellow_ink_remaining": null, - "yellow_ink_status": null, - "yellow_toner": 10, - "yellow_toner_remaining": 2, - "yellow_toner_status": 2 -} diff --git a/tests/components/brother/snapshots/test_diagnostics.ambr b/tests/components/brother/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..1bff613e557 --- /dev/null +++ b/tests/components/brother/snapshots/test_diagnostics.ambr @@ -0,0 +1,75 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'data': dict({ + 'belt_unit_remaining_life': 97, + 'belt_unit_remaining_pages': 48436, + 'black_counter': None, + 'black_drum_counter': 1611, + 'black_drum_remaining_life': 92, + 'black_drum_remaining_pages': 16389, + 'black_ink': None, + 'black_ink_remaining': None, + 'black_ink_status': None, + 'black_toner': 80, + 'black_toner_remaining': 75, + 'black_toner_status': 1, + 'bw_counter': 709, + 'color_counter': 902, + 'cyan_counter': None, + 'cyan_drum_counter': 1611, + 'cyan_drum_remaining_life': 92, + 'cyan_drum_remaining_pages': 16389, + 'cyan_ink': None, + 'cyan_ink_remaining': None, + 'cyan_ink_status': None, + 'cyan_toner': 10, + 'cyan_toner_remaining': 10, + 'cyan_toner_status': 1, + 'drum_counter': 986, + 'drum_remaining_life': 92, + 'drum_remaining_pages': 11014, + 'drum_status': 1, + 'duplex_unit_pages_counter': 538, + 'firmware': '1.17', + 'fuser_remaining_life': 97, + 'fuser_unit_remaining_pages': None, + 'image_counter': None, + 'laser_remaining_life': None, + 'laser_unit_remaining_pages': 48389, + 'magenta_counter': None, + 'magenta_drum_counter': 1611, + 'magenta_drum_remaining_life': 92, + 'magenta_drum_remaining_pages': 16389, + 'magenta_ink': None, + 'magenta_ink_remaining': None, + 'magenta_ink_status': None, + 'magenta_toner': 10, + 'magenta_toner_remaining': 8, + 'magenta_toner_status': 2, + 'model': 'HL-L2340DW', + 'page_counter': 986, + 'pf_kit_1_remaining_life': 98, + 'pf_kit_1_remaining_pages': 48741, + 'pf_kit_mp_remaining_life': None, + 'pf_kit_mp_remaining_pages': None, + 'serial': '0123456789', + 'status': 'waiting', + 'uptime': '2019-09-24T12:14:56+00:00', + 'yellow_counter': None, + 'yellow_drum_counter': 1611, + 'yellow_drum_remaining_life': 92, + 'yellow_drum_remaining_pages': 16389, + 'yellow_ink': None, + 'yellow_ink_remaining': None, + 'yellow_ink_status': None, + 'yellow_toner': 10, + 'yellow_toner_remaining': 2, + 'yellow_toner_status': 2, + }), + 'info': dict({ + 'host': 'localhost', + 'type': 'laser', + }), + }) +# --- diff --git a/tests/components/brother/test_diagnostics.py b/tests/components/brother/test_diagnostics.py index ce09fe13d1a..26ed77931b4 100644 --- a/tests/components/brother/test_diagnostics.py +++ b/tests/components/brother/test_diagnostics.py @@ -3,6 +3,8 @@ from datetime import datetime import json from unittest.mock import Mock, patch +from syrupy import SnapshotAssertion + from homeassistant.core import HomeAssistant from homeassistant.util.dt import UTC @@ -14,12 +16,13 @@ from tests.typing import ClientSessionGenerator async def test_entry_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" entry = await init_integration(hass, skip_setup=True) - diagnostics_data = json.loads(load_fixture("diagnostics_data.json", "brother")) test_time = datetime(2019, 11, 11, 9, 10, 32, tzinfo=UTC) with patch("brother.Brother.initialize"), patch( "brother.datetime", now=Mock(return_value=test_time) @@ -32,5 +35,4 @@ async def test_entry_diagnostics( result = await get_diagnostics_for_config_entry(hass, hass_client, entry) - assert result["info"] == {"host": "localhost", "type": "laser"} - assert result["data"] == diagnostics_data + assert result == snapshot From f395147f7c8db37f1f85524241689eb3b53e1581 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 24 Aug 2023 11:27:24 +0200 Subject: [PATCH 034/124] Move platform specifics out of Solaredge const (#98941) --- homeassistant/components/solaredge/const.py | 172 ---------------- homeassistant/components/solaredge/models.py | 20 -- homeassistant/components/solaredge/sensor.py | 192 +++++++++++++++++- .../components/solaredge/test_coordinator.py | 2 +- 4 files changed, 190 insertions(+), 196 deletions(-) delete mode 100644 homeassistant/components/solaredge/models.py diff --git a/homeassistant/components/solaredge/const.py b/homeassistant/components/solaredge/const.py index 6d95f8b6aec..aa6251ff433 100644 --- a/homeassistant/components/solaredge/const.py +++ b/homeassistant/components/solaredge/const.py @@ -2,11 +2,6 @@ from datetime import timedelta import logging -from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass -from homeassistant.const import PERCENTAGE, UnitOfEnergy, UnitOfPower - -from .models import SolarEdgeSensorEntityDescription - DOMAIN = "solaredge" LOGGER = logging.getLogger(__package__) @@ -24,170 +19,3 @@ POWER_FLOW_UPDATE_DELAY = timedelta(minutes=15) ENERGY_DETAILS_DELAY = timedelta(minutes=15) SCAN_INTERVAL = timedelta(minutes=15) - - -# Supported overview sensors -SENSOR_TYPES = [ - SolarEdgeSensorEntityDescription( - key="lifetime_energy", - json_key="lifeTimeData", - name="Lifetime energy", - icon="mdi:solar-power", - state_class=SensorStateClass.TOTAL, - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - ), - SolarEdgeSensorEntityDescription( - key="energy_this_year", - json_key="lastYearData", - name="Energy this year", - entity_registry_enabled_default=False, - icon="mdi:solar-power", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - ), - SolarEdgeSensorEntityDescription( - key="energy_this_month", - json_key="lastMonthData", - name="Energy this month", - entity_registry_enabled_default=False, - icon="mdi:solar-power", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - ), - SolarEdgeSensorEntityDescription( - key="energy_today", - json_key="lastDayData", - name="Energy today", - entity_registry_enabled_default=False, - icon="mdi:solar-power", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - ), - SolarEdgeSensorEntityDescription( - key="current_power", - json_key="currentPower", - name="Current Power", - icon="mdi:solar-power", - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - ), - SolarEdgeSensorEntityDescription( - key="site_details", - json_key="status", - name="Site details", - entity_registry_enabled_default=False, - ), - SolarEdgeSensorEntityDescription( - key="meters", - json_key="meters", - name="Meters", - entity_registry_enabled_default=False, - ), - SolarEdgeSensorEntityDescription( - key="sensors", - json_key="sensors", - name="Sensors", - entity_registry_enabled_default=False, - ), - SolarEdgeSensorEntityDescription( - key="gateways", - json_key="gateways", - name="Gateways", - entity_registry_enabled_default=False, - ), - SolarEdgeSensorEntityDescription( - key="batteries", - json_key="batteries", - name="Batteries", - entity_registry_enabled_default=False, - ), - SolarEdgeSensorEntityDescription( - key="inverters", - json_key="inverters", - name="Inverters", - entity_registry_enabled_default=False, - ), - SolarEdgeSensorEntityDescription( - key="power_consumption", - json_key="LOAD", - name="Power Consumption", - entity_registry_enabled_default=False, - icon="mdi:flash", - ), - SolarEdgeSensorEntityDescription( - key="solar_power", - json_key="PV", - name="Solar Power", - entity_registry_enabled_default=False, - icon="mdi:solar-power", - ), - SolarEdgeSensorEntityDescription( - key="grid_power", - json_key="GRID", - name="Grid Power", - entity_registry_enabled_default=False, - icon="mdi:power-plug", - ), - SolarEdgeSensorEntityDescription( - key="storage_power", - json_key="STORAGE", - name="Storage Power", - entity_registry_enabled_default=False, - icon="mdi:car-battery", - ), - SolarEdgeSensorEntityDescription( - key="purchased_energy", - json_key="Purchased", - name="Imported Energy", - entity_registry_enabled_default=False, - state_class=SensorStateClass.TOTAL_INCREASING, - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - ), - SolarEdgeSensorEntityDescription( - key="production_energy", - json_key="Production", - name="Production Energy", - entity_registry_enabled_default=False, - state_class=SensorStateClass.TOTAL_INCREASING, - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - ), - SolarEdgeSensorEntityDescription( - key="consumption_energy", - json_key="Consumption", - name="Consumption Energy", - entity_registry_enabled_default=False, - state_class=SensorStateClass.TOTAL_INCREASING, - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - ), - SolarEdgeSensorEntityDescription( - key="selfconsumption_energy", - json_key="SelfConsumption", - name="SelfConsumption Energy", - entity_registry_enabled_default=False, - state_class=SensorStateClass.TOTAL_INCREASING, - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - ), - SolarEdgeSensorEntityDescription( - key="feedin_energy", - json_key="FeedIn", - name="Exported Energy", - entity_registry_enabled_default=False, - state_class=SensorStateClass.TOTAL_INCREASING, - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - ), - SolarEdgeSensorEntityDescription( - key="storage_level", - json_key="STORAGE", - name="Storage Level", - entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=PERCENTAGE, - ), -] diff --git a/homeassistant/components/solaredge/models.py b/homeassistant/components/solaredge/models.py deleted file mode 100644 index 57efb88023c..00000000000 --- a/homeassistant/components/solaredge/models.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Models for the SolarEdge integration.""" -from __future__ import annotations - -from dataclasses import dataclass - -from homeassistant.components.sensor import SensorEntityDescription - - -@dataclass -class SolarEdgeSensorEntityRequiredKeyMixin: - """Sensor entity description with json_key for SolarEdge.""" - - json_key: str - - -@dataclass -class SolarEdgeSensorEntityDescription( - SensorEntityDescription, SolarEdgeSensorEntityRequiredKeyMixin -): - """Sensor entity description for SolarEdge.""" diff --git a/homeassistant/components/solaredge/sensor.py b/homeassistant/components/solaredge/sensor.py index 3a4b5ad90c2..e1ea7960086 100644 --- a/homeassistant/components/solaredge/sensor.py +++ b/homeassistant/components/solaredge/sensor.py @@ -1,12 +1,19 @@ """Support for SolarEdge Monitoring API.""" from __future__ import annotations +from dataclasses import dataclass from typing import Any from solaredge import Solaredge -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( @@ -14,7 +21,7 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) -from .const import CONF_SITE_ID, DATA_API_CLIENT, DOMAIN, SENSOR_TYPES +from .const import CONF_SITE_ID, DATA_API_CLIENT, DOMAIN from .coordinator import ( SolarEdgeDataService, SolarEdgeDetailsDataService, @@ -23,7 +30,186 @@ from .coordinator import ( SolarEdgeOverviewDataService, SolarEdgePowerFlowDataService, ) -from .models import SolarEdgeSensorEntityDescription + + +@dataclass +class SolarEdgeSensorEntityRequiredKeyMixin: + """Sensor entity description with json_key for SolarEdge.""" + + json_key: str + + +@dataclass +class SolarEdgeSensorEntityDescription( + SensorEntityDescription, SolarEdgeSensorEntityRequiredKeyMixin +): + """Sensor entity description for SolarEdge.""" + + +SENSOR_TYPES = [ + SolarEdgeSensorEntityDescription( + key="lifetime_energy", + json_key="lifeTimeData", + name="Lifetime energy", + icon="mdi:solar-power", + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + SolarEdgeSensorEntityDescription( + key="energy_this_year", + json_key="lastYearData", + name="Energy this year", + entity_registry_enabled_default=False, + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + SolarEdgeSensorEntityDescription( + key="energy_this_month", + json_key="lastMonthData", + name="Energy this month", + entity_registry_enabled_default=False, + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + SolarEdgeSensorEntityDescription( + key="energy_today", + json_key="lastDayData", + name="Energy today", + entity_registry_enabled_default=False, + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + SolarEdgeSensorEntityDescription( + key="current_power", + json_key="currentPower", + name="Current Power", + icon="mdi:solar-power", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + ), + SolarEdgeSensorEntityDescription( + key="site_details", + json_key="status", + name="Site details", + entity_registry_enabled_default=False, + ), + SolarEdgeSensorEntityDescription( + key="meters", + json_key="meters", + name="Meters", + entity_registry_enabled_default=False, + ), + SolarEdgeSensorEntityDescription( + key="sensors", + json_key="sensors", + name="Sensors", + entity_registry_enabled_default=False, + ), + SolarEdgeSensorEntityDescription( + key="gateways", + json_key="gateways", + name="Gateways", + entity_registry_enabled_default=False, + ), + SolarEdgeSensorEntityDescription( + key="batteries", + json_key="batteries", + name="Batteries", + entity_registry_enabled_default=False, + ), + SolarEdgeSensorEntityDescription( + key="inverters", + json_key="inverters", + name="Inverters", + entity_registry_enabled_default=False, + ), + SolarEdgeSensorEntityDescription( + key="power_consumption", + json_key="LOAD", + name="Power Consumption", + entity_registry_enabled_default=False, + icon="mdi:flash", + ), + SolarEdgeSensorEntityDescription( + key="solar_power", + json_key="PV", + name="Solar Power", + entity_registry_enabled_default=False, + icon="mdi:solar-power", + ), + SolarEdgeSensorEntityDescription( + key="grid_power", + json_key="GRID", + name="Grid Power", + entity_registry_enabled_default=False, + icon="mdi:power-plug", + ), + SolarEdgeSensorEntityDescription( + key="storage_power", + json_key="STORAGE", + name="Storage Power", + entity_registry_enabled_default=False, + icon="mdi:car-battery", + ), + SolarEdgeSensorEntityDescription( + key="purchased_energy", + json_key="Purchased", + name="Imported Energy", + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + SolarEdgeSensorEntityDescription( + key="production_energy", + json_key="Production", + name="Production Energy", + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + SolarEdgeSensorEntityDescription( + key="consumption_energy", + json_key="Consumption", + name="Consumption Energy", + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + SolarEdgeSensorEntityDescription( + key="selfconsumption_energy", + json_key="SelfConsumption", + name="SelfConsumption Energy", + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + SolarEdgeSensorEntityDescription( + key="feedin_energy", + json_key="FeedIn", + name="Exported Energy", + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + SolarEdgeSensorEntityDescription( + key="storage_level", + json_key="STORAGE", + name="Storage Level", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + ), +] async def async_setup_entry( diff --git a/tests/components/solaredge/test_coordinator.py b/tests/components/solaredge/test_coordinator.py index 5d9656b05d8..7b746a2ae05 100644 --- a/tests/components/solaredge/test_coordinator.py +++ b/tests/components/solaredge/test_coordinator.py @@ -6,8 +6,8 @@ from homeassistant.components.solaredge.const import ( DEFAULT_NAME, DOMAIN, OVERVIEW_UPDATE_DELAY, - SENSOR_TYPES, ) +from homeassistant.components.solaredge.sensor import SENSOR_TYPES from homeassistant.const import CONF_API_KEY, CONF_NAME, STATE_UNKNOWN from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util From c47983621c4c28dcd9418ae3f83653ed2edc774d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 24 Aug 2023 11:28:20 +0200 Subject: [PATCH 035/124] Teach CoordinatorWeatherEntity about multiple coordinators (#98830) --- homeassistant/components/aemet/weather.py | 12 +- .../components/environment_canada/weather.py | 12 +- homeassistant/components/met/weather.py | 12 +- .../components/met_eireann/weather.py | 12 +- homeassistant/components/nws/__init__.py | 22 +- homeassistant/components/nws/weather.py | 148 ++++-------- .../components/open_meteo/weather.py | 9 +- .../components/tomorrowio/weather.py | 12 +- homeassistant/components/weather/__init__.py | 228 +++++++++++++++++- homeassistant/helpers/update_coordinator.py | 23 ++ 10 files changed, 332 insertions(+), 158 deletions(-) diff --git a/homeassistant/components/aemet/weather.py b/homeassistant/components/aemet/weather.py index 6affc39c7a8..60289f4723a 100644 --- a/homeassistant/components/aemet/weather.py +++ b/homeassistant/components/aemet/weather.py @@ -11,8 +11,8 @@ from homeassistant.components.weather import ( ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, DOMAIN as WEATHER_DOMAIN, - CoordinatorWeatherEntity, Forecast, + SingleCoordinatorWeatherEntity, WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -110,7 +110,7 @@ async def async_setup_entry( async_add_entities(entities, False) -class AemetWeather(CoordinatorWeatherEntity[WeatherUpdateCoordinator]): +class AemetWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator]): """Implementation of an AEMET OpenData sensor.""" _attr_attribution = ATTRIBUTION @@ -160,11 +160,13 @@ class AemetWeather(CoordinatorWeatherEntity[WeatherUpdateCoordinator]): """Return the forecast array.""" return self._forecast(self._forecast_mode) - async def async_forecast_daily(self) -> list[Forecast]: + @callback + def _async_forecast_daily(self) -> list[Forecast]: """Return the daily forecast in native units.""" return self._forecast(FORECAST_MODE_DAILY) - async def async_forecast_hourly(self) -> list[Forecast]: + @callback + def _async_forecast_hourly(self) -> list[Forecast]: """Return the hourly forecast in native units.""" return self._forecast(FORECAST_MODE_HOURLY) diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py index 67cb2df5473..b4b5d27f45f 100644 --- a/homeassistant/components/environment_canada/weather.py +++ b/homeassistant/components/environment_canada/weather.py @@ -22,8 +22,8 @@ from homeassistant.components.weather import ( ATTR_FORECAST_PRECIPITATION_PROBABILITY, ATTR_FORECAST_TIME, DOMAIN as WEATHER_DOMAIN, - CoordinatorWeatherEntity, Forecast, + SingleCoordinatorWeatherEntity, WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry @@ -33,7 +33,7 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util @@ -86,7 +86,7 @@ def _calculate_unique_id(config_entry_unique_id: str | None, hourly: bool) -> st return f"{config_entry_unique_id}{'-hourly' if hourly else '-daily'}" -class ECWeather(CoordinatorWeatherEntity): +class ECWeather(SingleCoordinatorWeatherEntity): """Representation of a weather condition.""" _attr_has_entity_name = True @@ -182,11 +182,13 @@ class ECWeather(CoordinatorWeatherEntity): """Return the forecast array.""" return get_forecast(self.ec_data, self._hourly) - async def async_forecast_daily(self) -> list[Forecast] | None: + @callback + def _async_forecast_daily(self) -> list[Forecast] | None: """Return the daily forecast in native units.""" return get_forecast(self.ec_data, False) - async def async_forecast_hourly(self) -> list[Forecast] | None: + @callback + def _async_forecast_hourly(self) -> list[Forecast] | None: """Return the hourly forecast in native units.""" return get_forecast(self.ec_data, True) diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index c697befd01f..a5a0d34d4eb 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -16,8 +16,8 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_GUST_SPEED, ATTR_WEATHER_WIND_SPEED, DOMAIN as WEATHER_DOMAIN, - CoordinatorWeatherEntity, Forecast, + SingleCoordinatorWeatherEntity, WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry @@ -30,7 +30,7 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -91,7 +91,7 @@ def format_condition(condition: str) -> str: return condition -class MetWeather(CoordinatorWeatherEntity[MetDataUpdateCoordinator]): +class MetWeather(SingleCoordinatorWeatherEntity[MetDataUpdateCoordinator]): """Implementation of a Met.no weather condition.""" _attr_attribution = ( @@ -239,11 +239,13 @@ class MetWeather(CoordinatorWeatherEntity[MetDataUpdateCoordinator]): """Return the forecast array.""" return self._forecast(self._hourly) - async def async_forecast_daily(self) -> list[Forecast] | None: + @callback + def _async_forecast_daily(self) -> list[Forecast] | None: """Return the daily forecast in native units.""" return self._forecast(False) - async def async_forecast_hourly(self) -> list[Forecast] | None: + @callback + def _async_forecast_hourly(self) -> list[Forecast] | None: """Return the hourly forecast in native units.""" return self._forecast(True) diff --git a/homeassistant/components/met_eireann/weather.py b/homeassistant/components/met_eireann/weather.py index a69c1f24c08..3a45a74c36b 100644 --- a/homeassistant/components/met_eireann/weather.py +++ b/homeassistant/components/met_eireann/weather.py @@ -7,8 +7,8 @@ from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, ATTR_FORECAST_TIME, DOMAIN as WEATHER_DOMAIN, - CoordinatorWeatherEntity, Forecast, + SingleCoordinatorWeatherEntity, WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry @@ -21,7 +21,7 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -75,7 +75,7 @@ def _calculate_unique_id(config: MappingProxyType[str, Any], hourly: bool) -> st class MetEireannWeather( - CoordinatorWeatherEntity[DataUpdateCoordinator[MetEireannWeatherData]] + SingleCoordinatorWeatherEntity[DataUpdateCoordinator[MetEireannWeatherData]] ): """Implementation of a Met Éireann weather condition.""" @@ -182,11 +182,13 @@ class MetEireannWeather( """Return the forecast array.""" return self._forecast(self._hourly) - async def async_forecast_daily(self) -> list[Forecast]: + @callback + def _async_forecast_daily(self) -> list[Forecast]: """Return the daily forecast in native units.""" return self._forecast(False) - async def async_forecast_hourly(self) -> list[Forecast]: + @callback + def _async_forecast_hourly(self) -> list[Forecast]: """Return the hourly forecast in native units.""" return self._forecast(True) diff --git a/homeassistant/components/nws/__init__.py b/homeassistant/components/nws/__init__.py index a6af045776f..063ecdabab2 100644 --- a/homeassistant/components/nws/__init__.py +++ b/homeassistant/components/nws/__init__.py @@ -16,7 +16,7 @@ from homeassistant.helpers import debounce from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.event import async_track_point_in_utc_time -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator from homeassistant.util.dt import utcnow from .const import CONF_STATION, DOMAIN, UPDATE_TIME_PERIOD @@ -45,7 +45,7 @@ class NWSData: coordinator_forecast_hourly: NwsDataUpdateCoordinator -class NwsDataUpdateCoordinator(DataUpdateCoordinator[None]): +class NwsDataUpdateCoordinator(TimestampDataUpdateCoordinator[None]): """NWS data update coordinator. Implements faster data update intervals for failed updates and exposes a last successful update time. @@ -72,7 +72,6 @@ class NwsDataUpdateCoordinator(DataUpdateCoordinator[None]): request_refresh_debouncer=request_refresh_debouncer, ) self.failed_update_interval = failed_update_interval - self.last_update_success_time: datetime.datetime | None = None @callback def _schedule_refresh(self) -> None: @@ -98,23 +97,6 @@ class NwsDataUpdateCoordinator(DataUpdateCoordinator[None]): utcnow().replace(microsecond=0) + update_interval, ) - async def _async_refresh( - self, - log_failures: bool = True, - raise_on_auth_failed: bool = False, - scheduled: bool = False, - raise_on_entry_error: bool = False, - ) -> None: - """Refresh data.""" - await super()._async_refresh( - log_failures, - raise_on_auth_failed, - scheduled, - raise_on_entry_error, - ) - if self.last_update_success: - self.last_update_success_time = utcnow() - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a National Weather Service entry.""" diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index dec7e9bf3b3..0f594133f69 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -1,9 +1,8 @@ """Support for NWS weather service.""" from __future__ import annotations -from collections.abc import Callable from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Literal, cast +from typing import TYPE_CHECKING, Any, cast from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, @@ -18,8 +17,8 @@ from homeassistant.components.weather import ( ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, DOMAIN as WEATHER_DOMAIN, + CoordinatorWeatherEntity, Forecast, - WeatherEntity, WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry @@ -38,13 +37,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import utcnow from homeassistant.util.unit_conversion import SpeedConverter, TemperatureConverter -from . import ( - DEFAULT_SCAN_INTERVAL, - NWSData, - NwsDataUpdateCoordinator, - base_unique_id, - device_info, -) +from . import NWSData, base_unique_id, device_info from .const import ( ATTR_FORECAST_DETAILED_DESCRIPTION, ATTRIBUTION, @@ -120,7 +113,7 @@ def _calculate_unique_id(entry_data: MappingProxyType[str, Any], mode: str) -> s return f"{base_unique_id(latitude, longitude)}_{mode}" -class NWSWeather(WeatherEntity): +class NWSWeather(CoordinatorWeatherEntity): """Representation of a weather condition.""" _attr_attribution = ATTRIBUTION @@ -136,19 +129,21 @@ class NWSWeather(WeatherEntity): mode: str, ) -> None: """Initialise the platform with a data instance and station name.""" + super().__init__( + observation_coordinator=nws_data.coordinator_observation, + hourly_coordinator=nws_data.coordinator_forecast_hourly, + twice_daily_coordinator=nws_data.coordinator_forecast, + hourly_forecast_valid=FORECAST_VALID_TIME, + twice_daily_forecast_valid=FORECAST_VALID_TIME, + ) self.nws = nws_data.api self.latitude = entry_data[CONF_LATITUDE] self.longitude = entry_data[CONF_LONGITUDE] - self.coordinator_forecast_hourly = nws_data.coordinator_forecast_hourly - self.coordinator_forecast_twice_daily = nws_data.coordinator_forecast - self.coordinator_observation = nws_data.coordinator_observation if mode == DAYNIGHT: self.coordinator_forecast_legacy = nws_data.coordinator_forecast else: self.coordinator_forecast_legacy = nws_data.coordinator_forecast_hourly self.station = self.nws.station - self._unsub_hourly_forecast: Callable[[], None] | None = None - self._unsub_twice_daily_forecast: Callable[[], None] | None = None self.mode = mode @@ -161,76 +156,42 @@ class NWSWeather(WeatherEntity): async def async_added_to_hass(self) -> None: """Set up a listener and load data.""" + await super().async_added_to_hass() self.async_on_remove( - self.coordinator_observation.async_add_listener(self._update_callback) - ) - self.async_on_remove( - self.coordinator_forecast_legacy.async_add_listener(self._update_callback) - ) - self.async_on_remove(self._remove_hourly_forecast_listener) - self.async_on_remove(self._remove_twice_daily_forecast_listener) - self._update_callback() - - def _remove_hourly_forecast_listener(self) -> None: - """Remove hourly forecast listener.""" - if self._unsub_hourly_forecast: - self._unsub_hourly_forecast() - self._unsub_hourly_forecast = None - - def _remove_twice_daily_forecast_listener(self) -> None: - """Remove hourly forecast listener.""" - if self._unsub_twice_daily_forecast: - self._unsub_twice_daily_forecast() - self._unsub_twice_daily_forecast = None - - @callback - def _async_subscription_started( - self, - forecast_type: Literal["daily", "hourly", "twice_daily"], - ) -> None: - """Start subscription to forecast_type.""" - if forecast_type == "hourly" and self.mode == DAYNIGHT: - self._unsub_hourly_forecast = ( - self.coordinator_forecast_hourly.async_add_listener( - self._update_callback - ) + self.coordinator_forecast_legacy.async_add_listener( + self._handle_legacy_forecast_coordinator_update ) - return - if forecast_type == "twice_daily" and self.mode == HOURLY: - self._unsub_twice_daily_forecast = ( - self.coordinator_forecast_twice_daily.async_add_listener( - self._update_callback - ) - ) - return + ) + # Load initial data from coordinators + self._handle_coordinator_update() + self._handle_hourly_forecast_coordinator_update() + self._handle_twice_daily_forecast_coordinator_update() + self._handle_legacy_forecast_coordinator_update() @callback - def _async_subscription_ended( - self, - forecast_type: Literal["daily", "hourly", "twice_daily"], - ) -> None: - """End subscription to forecast_type.""" - if forecast_type == "hourly" and self.mode == DAYNIGHT: - self._remove_hourly_forecast_listener() - if forecast_type == "twice_daily" and self.mode == HOURLY: - self._remove_twice_daily_forecast_listener() - - @callback - def _update_callback(self) -> None: + def _handle_coordinator_update(self) -> None: """Load data from integration.""" self.observation = self.nws.observation + self.async_write_ha_state() + + @callback + def _handle_hourly_forecast_coordinator_update(self) -> None: + """Handle updated data from the hourly forecast coordinator.""" self._forecast_hourly = self.nws.forecast_hourly + + @callback + def _handle_twice_daily_forecast_coordinator_update(self) -> None: + """Handle updated data from the twice daily forecast coordinator.""" self._forecast_twice_daily = self.nws.forecast + + @callback + def _handle_legacy_forecast_coordinator_update(self) -> None: + """Handle updated data from the legacy forecast coordinator.""" if self.mode == DAYNIGHT: self._forecast_legacy = self.nws.forecast else: self._forecast_legacy = self.nws.forecast_hourly - self.async_write_ha_state() - assert self.platform.config_entry - self.platform.config_entry.async_create_task( - self.hass, self.async_update_listeners(("hourly", "twice_daily")) - ) @property def name(self) -> str: @@ -373,50 +334,29 @@ class NWSWeather(WeatherEntity): """Return forecast.""" return self._forecast(self._forecast_legacy, self.mode) - async def _async_forecast( - self, - coordinator: NwsDataUpdateCoordinator, - nws_forecast: list[dict[str, Any]] | None, - mode: str, - ) -> list[Forecast] | None: - """Refresh stale forecast and return it in native units.""" - if ( - not (last_success_time := coordinator.last_update_success_time) - or utcnow() - last_success_time >= DEFAULT_SCAN_INTERVAL - ): - await coordinator.async_refresh() - if ( - not (last_success_time := coordinator.last_update_success_time) - or utcnow() - last_success_time >= FORECAST_VALID_TIME - ): - return None - return self._forecast(nws_forecast, mode) - - async def async_forecast_hourly(self) -> list[Forecast] | None: + @callback + def _async_forecast_hourly(self) -> list[Forecast] | None: """Return the hourly forecast in native units.""" - coordinator = self.coordinator_forecast_hourly - return await self._async_forecast(coordinator, self._forecast_hourly, HOURLY) + return self._forecast(self._forecast_hourly, HOURLY) - async def async_forecast_twice_daily(self) -> list[Forecast] | None: + @callback + def _async_forecast_twice_daily(self) -> list[Forecast] | None: """Return the twice daily forecast in native units.""" - coordinator = self.coordinator_forecast_twice_daily - return await self._async_forecast( - coordinator, self._forecast_twice_daily, DAYNIGHT - ) + return self._forecast(self._forecast_twice_daily, DAYNIGHT) @property def available(self) -> bool: """Return if state is available.""" last_success = ( - self.coordinator_observation.last_update_success + self.coordinator.last_update_success and self.coordinator_forecast_legacy.last_update_success ) if ( - self.coordinator_observation.last_update_success_time + self.coordinator.last_update_success_time and self.coordinator_forecast_legacy.last_update_success_time ): last_success_time = ( - utcnow() - self.coordinator_observation.last_update_success_time + utcnow() - self.coordinator.last_update_success_time < OBSERVATION_VALID_TIME and utcnow() - self.coordinator_forecast_legacy.last_update_success_time < FORECAST_VALID_TIME @@ -430,7 +370,7 @@ class NWSWeather(WeatherEntity): Only used by the generic entity update service. """ - await self.coordinator_observation.async_request_refresh() + await self.coordinator.async_request_refresh() await self.coordinator_forecast_legacy.async_request_refresh() @property diff --git a/homeassistant/components/open_meteo/weather.py b/homeassistant/components/open_meteo/weather.py index b874e066031..3d66422fd60 100644 --- a/homeassistant/components/open_meteo/weather.py +++ b/homeassistant/components/open_meteo/weather.py @@ -4,13 +4,13 @@ from __future__ import annotations from open_meteo import Forecast as OpenMeteoForecast from homeassistant.components.weather import ( - CoordinatorWeatherEntity, Forecast, + SingleCoordinatorWeatherEntity, WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfPrecipitationDepth, UnitOfSpeed, UnitOfTemperature -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -29,7 +29,7 @@ async def async_setup_entry( class OpenMeteoWeatherEntity( - CoordinatorWeatherEntity[DataUpdateCoordinator[OpenMeteoForecast]] + SingleCoordinatorWeatherEntity[DataUpdateCoordinator[OpenMeteoForecast]] ): """Defines an Open-Meteo weather entity.""" @@ -124,6 +124,7 @@ class OpenMeteoWeatherEntity( return forecasts - async def async_forecast_daily(self) -> list[Forecast] | None: + @callback + def _async_forecast_daily(self) -> list[Forecast] | None: """Return the daily forecast in native units.""" return self.forecast diff --git a/homeassistant/components/tomorrowio/weather.py b/homeassistant/components/tomorrowio/weather.py index f88887e64dd..b0b82d81463 100644 --- a/homeassistant/components/tomorrowio/weather.py +++ b/homeassistant/components/tomorrowio/weather.py @@ -17,8 +17,8 @@ from homeassistant.components.weather import ( ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, DOMAIN as WEATHER_DOMAIN, - CoordinatorWeatherEntity, Forecast, + SingleCoordinatorWeatherEntity, WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry @@ -31,7 +31,7 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.sun import is_up @@ -93,7 +93,7 @@ def _calculate_unique_id(config_entry_unique_id: str | None, forecast_type: str) return f"{config_entry_unique_id}_{forecast_type}" -class TomorrowioWeatherEntity(TomorrowioEntity, CoordinatorWeatherEntity): +class TomorrowioWeatherEntity(TomorrowioEntity, SingleCoordinatorWeatherEntity): """Entity that talks to Tomorrow.io v4 API to retrieve weather data.""" _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS @@ -303,10 +303,12 @@ class TomorrowioWeatherEntity(TomorrowioEntity, CoordinatorWeatherEntity): """Return the forecast array.""" return self._forecast(self.forecast_type) - async def async_forecast_daily(self) -> list[Forecast] | None: + @callback + def _async_forecast_daily(self) -> list[Forecast] | None: """Return the daily forecast in native units.""" return self._forecast(DAILY) - async def async_forecast_hourly(self) -> list[Forecast] | None: + @callback + def _async_forecast_hourly(self) -> list[Forecast] | None: """Return the hourly forecast in native units.""" return self._forecast(HOURLY) diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index eb137f06d7b..d73d00ec9df 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -6,9 +6,20 @@ from collections.abc import Callable, Iterable from contextlib import suppress from dataclasses import dataclass from datetime import timedelta +from functools import partial import inspect import logging -from typing import Any, Final, Literal, Required, TypedDict, TypeVar, final +from typing import ( + Any, + Final, + Generic, + Literal, + Required, + TypedDict, + TypeVar, + cast, + final, +) import voluptuous as vol @@ -40,7 +51,9 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, + TimestampDataUpdateCoordinator, ) +from homeassistant.util.dt import utcnow from homeassistant.util.json import JsonValueType from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -121,8 +134,22 @@ ROUNDING_PRECISION = 2 SERVICE_GET_FORECAST: Final = "get_forecast" -_DataUpdateCoordinatorT = TypeVar( - "_DataUpdateCoordinatorT", bound="DataUpdateCoordinator[Any]" +_ObservationUpdateCoordinatorT = TypeVar( + "_ObservationUpdateCoordinatorT", bound="DataUpdateCoordinator[Any]" +) + +# Note: +# Mypy bug https://github.com/python/mypy/issues/9424 prevents us from making the +# forecast cooordinators optional, bound=TimestampDataUpdateCoordinator[Any] | None + +_DailyForecastUpdateCoordinatorT = TypeVar( + "_DailyForecastUpdateCoordinatorT", bound="TimestampDataUpdateCoordinator[Any]" +) +_HourlyForecastUpdateCoordinatorT = TypeVar( + "_HourlyForecastUpdateCoordinatorT", bound="TimestampDataUpdateCoordinator[Any]" +) +_TwiceDailyForecastUpdateCoordinatorT = TypeVar( + "_TwiceDailyForecastUpdateCoordinatorT", bound="TimestampDataUpdateCoordinator[Any]" ) # mypy: disallow-any-generics @@ -1187,9 +1214,200 @@ async def async_get_forecast_service( class CoordinatorWeatherEntity( - CoordinatorEntity[_DataUpdateCoordinatorT], WeatherEntity + CoordinatorEntity[_ObservationUpdateCoordinatorT], + WeatherEntity, + Generic[ + _ObservationUpdateCoordinatorT, + _DailyForecastUpdateCoordinatorT, + _HourlyForecastUpdateCoordinatorT, + _TwiceDailyForecastUpdateCoordinatorT, + ], ): - """A class for weather entities using a single DataUpdateCoordinator.""" + """A class for weather entities using DataUpdateCoordinators.""" + + def __init__( + self, + observation_coordinator: _ObservationUpdateCoordinatorT, + *, + context: Any = None, + daily_coordinator: _DailyForecastUpdateCoordinatorT | None = None, + hourly_coordinator: _DailyForecastUpdateCoordinatorT | None = None, + twice_daily_coordinator: _DailyForecastUpdateCoordinatorT | None = None, + daily_forecast_valid: timedelta | None = None, + hourly_forecast_valid: timedelta | None = None, + twice_daily_forecast_valid: timedelta | None = None, + ) -> None: + """Initialize.""" + super().__init__(observation_coordinator, context) + self.forecast_coordinators = { + "daily": daily_coordinator, + "hourly": hourly_coordinator, + "twice_daily": twice_daily_coordinator, + } + self.forecast_valid = { + "daily": daily_forecast_valid, + "hourly": hourly_forecast_valid, + "twice_daily": twice_daily_forecast_valid, + } + self.unsub_forecast: dict[str, Callable[[], None] | None] = { + "daily": None, + "hourly": None, + "twice_daily": None, + } + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove(partial(self._remove_forecast_listener, "daily")) + self.async_on_remove(partial(self._remove_forecast_listener, "hourly")) + self.async_on_remove(partial(self._remove_forecast_listener, "twice_daily")) + + def _remove_forecast_listener( + self, forecast_type: Literal["daily", "hourly", "twice_daily"] + ) -> None: + """Remove weather forecast listener.""" + if unsub_fn := self.unsub_forecast[forecast_type]: + unsub_fn() + self.unsub_forecast[forecast_type] = None + + @callback + def _async_subscription_started( + self, + forecast_type: Literal["daily", "hourly", "twice_daily"], + ) -> None: + """Start subscription to forecast_type.""" + if not (coordinator := self.forecast_coordinators[forecast_type]): + return + self.unsub_forecast[forecast_type] = coordinator.async_add_listener( + partial(self._handle_forecast_update, forecast_type) + ) + + @callback + def _handle_daily_forecast_coordinator_update(self) -> None: + """Handle updated data from the daily forecast coordinator.""" + + @callback + def _handle_hourly_forecast_coordinator_update(self) -> None: + """Handle updated data from the hourly forecast coordinator.""" + + @callback + def _handle_twice_daily_forecast_coordinator_update(self) -> None: + """Handle updated data from the twice daily forecast coordinator.""" + + @final + @callback + def _handle_forecast_update( + self, forecast_type: Literal["daily", "hourly", "twice_daily"] + ) -> None: + """Update forecast data.""" + coordinator = self.forecast_coordinators[forecast_type] + assert coordinator + assert coordinator.config_entry is not None + getattr(self, f"_handle_{forecast_type}_forecast_coordinator_update")() + coordinator.config_entry.async_create_task( + self.hass, self.async_update_listeners((forecast_type,)) + ) + + @callback + def _async_subscription_ended( + self, + forecast_type: Literal["daily", "hourly", "twice_daily"], + ) -> None: + """End subscription to forecast_type.""" + self._remove_forecast_listener(forecast_type) + + @final + async def _async_refresh_forecast( + self, + coordinator: TimestampDataUpdateCoordinator[Any], + forecast_valid_time: timedelta | None, + ) -> bool: + """Refresh stale forecast if needed.""" + if coordinator.update_interval is None: + return True + if forecast_valid_time is None: + forecast_valid_time = coordinator.update_interval + if ( + not (last_success_time := coordinator.last_update_success_time) + or utcnow() - last_success_time >= coordinator.update_interval + ): + await coordinator.async_refresh() + if ( + not (last_success_time := coordinator.last_update_success_time) + or utcnow() - last_success_time >= forecast_valid_time + ): + return False + return True + + @callback + def _async_forecast_daily(self) -> list[Forecast] | None: + """Return the daily forecast in native units.""" + raise NotImplementedError + + @callback + def _async_forecast_hourly(self) -> list[Forecast] | None: + """Return the hourly forecast in native units.""" + raise NotImplementedError + + @callback + def _async_forecast_twice_daily(self) -> list[Forecast] | None: + """Return the twice daily forecast in native units.""" + raise NotImplementedError + + @final + async def _async_forecast( + self, forecast_type: Literal["daily", "hourly", "twice_daily"] + ) -> list[Forecast] | None: + """Return the forecast in native units.""" + coordinator = self.forecast_coordinators[forecast_type] + if coordinator and not await self._async_refresh_forecast( + coordinator, self.forecast_valid[forecast_type] + ): + return None + return cast( + list[Forecast] | None, getattr(self, f"_async_forecast_{forecast_type}")() + ) + + @final + async def async_forecast_daily(self) -> list[Forecast] | None: + """Return the daily forecast in native units.""" + return await self._async_forecast("daily") + + @final + async def async_forecast_hourly(self) -> list[Forecast] | None: + """Return the hourly forecast in native units.""" + return await self._async_forecast("hourly") + + @final + async def async_forecast_twice_daily(self) -> list[Forecast] | None: + """Return the twice daily forecast in native units.""" + return await self._async_forecast("twice_daily") + + +class SingleCoordinatorWeatherEntity( + CoordinatorWeatherEntity[ + _ObservationUpdateCoordinatorT, + TimestampDataUpdateCoordinator[None], + TimestampDataUpdateCoordinator[None], + TimestampDataUpdateCoordinator[None], + ], +): + """A class for weather entities using a single DataUpdateCoordinators. + + This class is added as a convenience because: + - Deriving from CoordinatorWeatherEntity requires specifying all type parameters + until we upgrade to Python 3.12 which supports defaults + - Mypy bug https://github.com/python/mypy/issues/9424 prevents us from making the + forecast cooordinator type vars optional + """ + + def __init__( + self, + coordinator: _ObservationUpdateCoordinatorT, + context: Any = None, + ) -> None: + """Initialize.""" + super().__init__(coordinator, context=context) @callback def _handle_coordinator_update(self) -> None: diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 8057e77de4f..a050c0da9e4 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -419,6 +419,29 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): self.async_update_listeners() +class TimestampDataUpdateCoordinator(DataUpdateCoordinator[_DataT]): + """DataUpdateCoordinator which keeps track of the last successful update.""" + + last_update_success_time: datetime | None = None + + async def _async_refresh( + self, + log_failures: bool = True, + raise_on_auth_failed: bool = False, + scheduled: bool = False, + raise_on_entry_error: bool = False, + ) -> None: + """Refresh data.""" + await super()._async_refresh( + log_failures, + raise_on_auth_failed, + scheduled, + raise_on_entry_error, + ) + if self.last_update_success: + self.last_update_success_time = utcnow() + + class BaseCoordinatorEntity(entity.Entity, Generic[_BaseDataUpdateCoordinatorT]): """Base class for all Coordinator entities.""" From 577f545113659f77b5d96d942e1c04ec87512b2a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 24 Aug 2023 11:43:10 +0200 Subject: [PATCH 036/124] Add entity translations to Rachio (#98917) --- .../components/rachio/binary_sensor.py | 34 +++---------------- homeassistant/components/rachio/strings.json | 15 ++++++++ homeassistant/components/rachio/switch.py | 33 ++++-------------- 3 files changed, 27 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/rachio/binary_sensor.py b/homeassistant/components/rachio/binary_sensor.py index f1c515d37f7..029b1bac6e3 100644 --- a/homeassistant/components/rachio/binary_sensor.py +++ b/homeassistant/components/rachio/binary_sensor.py @@ -43,7 +43,6 @@ async def async_setup_entry( """Set up the Rachio binary sensors.""" entities = await hass.async_add_executor_job(_create_entities, hass, config_entry) async_add_entities(entities) - _LOGGER.debug("%d Rachio binary sensor(s) added", len(entities)) def _create_entities(hass: HomeAssistant, config_entry: ConfigEntry) -> list[Entity]: @@ -58,6 +57,8 @@ def _create_entities(hass: HomeAssistant, config_entry: ConfigEntry) -> list[Ent class RachioControllerBinarySensor(RachioDevice, BinarySensorEntity): """Represent a binary sensor that reflects a Rachio state.""" + _attr_has_entity_name = True + def __init__(self, controller): """Set up a new Rachio controller binary sensor.""" super().__init__(controller) @@ -86,26 +87,13 @@ class RachioControllerBinarySensor(RachioDevice, BinarySensorEntity): class RachioControllerOnlineBinarySensor(RachioControllerBinarySensor): """Represent a binary sensor that reflects if the controller is online.""" - @property - def name(self) -> str: - """Return the name of this sensor including the controller name.""" - return self._controller.name + _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY @property def unique_id(self) -> str: """Return a unique id for this entity.""" return f"{self._controller.controller_id}-online" - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this device, from BinarySensorDeviceClass.""" - return BinarySensorDeviceClass.CONNECTIVITY - - @property - def icon(self) -> str: - """Return the name of an icon for this sensor.""" - return "mdi:wifi-strength-4" if self.is_on else "mdi:wifi-strength-off-outline" - @callback def _async_handle_update(self, *args, **kwargs) -> None: """Handle an update to the state of this sensor.""" @@ -132,26 +120,14 @@ class RachioControllerOnlineBinarySensor(RachioControllerBinarySensor): class RachioRainSensor(RachioControllerBinarySensor): """Represent a binary sensor that reflects the status of the rain sensor.""" - @property - def name(self) -> str: - """Return the name of this sensor including the controller name.""" - return f"{self._controller.name} rain sensor" + _attr_device_class = BinarySensorDeviceClass.MOISTURE + _attr_translation_key = "rain" @property def unique_id(self) -> str: """Return a unique id for this entity.""" return f"{self._controller.controller_id}-rain_sensor" - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this device.""" - return BinarySensorDeviceClass.MOISTURE - - @property - def icon(self) -> str: - """Return the icon for this sensor.""" - return "mdi:water" if self.is_on else "mdi:water-off" - @callback def _async_handle_update(self, *args, **kwargs) -> None: """Handle an update to the state of this sensor.""" diff --git a/homeassistant/components/rachio/strings.json b/homeassistant/components/rachio/strings.json index 2132cab8682..560c300db17 100644 --- a/homeassistant/components/rachio/strings.json +++ b/homeassistant/components/rachio/strings.json @@ -27,6 +27,21 @@ } } }, + "entity": { + "binary_sensor": { + "rain": { + "name": "Rain" + } + }, + "switch": { + "standby": { + "name": "Standby" + }, + "rain_delay": { + "name": "Rain delay" + } + } + }, "services": { "set_zone_moisture_percent": { "name": "Set zone moisture percent", diff --git a/homeassistant/components/rachio/switch.py b/homeassistant/components/rachio/switch.py index c04a1a09f81..0557a2bdb19 100644 --- a/homeassistant/components/rachio/switch.py +++ b/homeassistant/components/rachio/switch.py @@ -109,7 +109,6 @@ async def async_setup_entry( has_flex_sched = True async_add_entities(entities) - _LOGGER.debug("%d Rachio switch(es) added", len(entities)) def start_multiple(service: ServiceCall) -> None: """Service to start multiple zones in sequence.""" @@ -173,7 +172,6 @@ def _create_entities(hass: HomeAssistant, config_entry: ConfigEntry) -> list[Ent entities.append(RachioZone(person, controller, zone, current_schedule)) for sched in schedules + flex_schedules: entities.append(RachioSchedule(person, controller, sched, current_schedule)) - _LOGGER.debug("Added %s", entities) return entities @@ -185,11 +183,6 @@ class RachioSwitch(RachioDevice, SwitchEntity): super().__init__(controller) self._state = None - @property - def name(self) -> str: - """Get a name for this switch.""" - return f"Switch on {self._controller.name}" - @property def is_on(self) -> bool: """Return whether the switch is currently on.""" @@ -213,21 +206,15 @@ class RachioSwitch(RachioDevice, SwitchEntity): class RachioStandbySwitch(RachioSwitch): """Representation of a standby status/button.""" - @property - def name(self) -> str: - """Return the name of the standby switch.""" - return f"{self._controller.name} in standby mode" + _attr_has_entity_name = True + _attr_translation_key = "standby" + _attr_icon = "mdi:power" @property def unique_id(self) -> str: """Return a unique id by combining controller id and purpose.""" return f"{self._controller.controller_id}-standby" - @property - def icon(self) -> str: - """Return an icon for the standby switch.""" - return "mdi:power" - @callback def _async_handle_update(self, *args, **kwargs) -> None: """Update the state using webhook data.""" @@ -263,26 +250,20 @@ class RachioStandbySwitch(RachioSwitch): class RachioRainDelay(RachioSwitch): """Representation of a rain delay status/switch.""" + _attr_has_entity_name = True + _attr_translation_key = "rain_delay" + _attr_icon = "mdi:camera-timer" + def __init__(self, controller): """Set up a Rachio rain delay switch.""" self._cancel_update = None super().__init__(controller) - @property - def name(self) -> str: - """Return the name of the switch.""" - return f"{self._controller.name} rain delay" - @property def unique_id(self) -> str: """Return a unique id by combining controller id and purpose.""" return f"{self._controller.controller_id}-delay" - @property - def icon(self) -> str: - """Return an icon for rain delay.""" - return "mdi:camera-timer" - @callback def _async_handle_update(self, *args, **kwargs) -> None: """Update the state using webhook data.""" From 3b31c58ebae037d1c257be0ebc4bdc65e86fd8c8 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 24 Aug 2023 11:44:04 +0200 Subject: [PATCH 037/124] Add coordinator test for Yale Smart Living (#98638) --- .coveragerc | 1 - tests/components/yale_smart_alarm/conftest.py | 6 +- .../yale_smart_alarm/fixtures/get_all.json | 908 +++++++++++++++- .../snapshots/test_diagnostics.ambr | 968 ++++++++++++++++++ .../yale_smart_alarm/test_coordinator.py | 123 +++ .../yale_smart_alarm/test_diagnostics.py | 8 +- 6 files changed, 2001 insertions(+), 13 deletions(-) create mode 100644 tests/components/yale_smart_alarm/test_coordinator.py diff --git a/.coveragerc b/.coveragerc index 7d8147ab648..5155cac79f1 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1507,7 +1507,6 @@ omit = homeassistant/components/yale_smart_alarm/alarm_control_panel.py homeassistant/components/yale_smart_alarm/binary_sensor.py homeassistant/components/yale_smart_alarm/button.py - homeassistant/components/yale_smart_alarm/coordinator.py homeassistant/components/yale_smart_alarm/entity.py homeassistant/components/yale_smart_alarm/lock.py homeassistant/components/yalexs_ble/__init__.py diff --git a/tests/components/yale_smart_alarm/conftest.py b/tests/components/yale_smart_alarm/conftest.py index 144a24a4897..c3f5fcf74b8 100644 --- a/tests/components/yale_smart_alarm/conftest.py +++ b/tests/components/yale_smart_alarm/conftest.py @@ -3,7 +3,7 @@ from __future__ import annotations import json from typing import Any -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest from yalesmartalarmclient.const import YALE_STATE_ARM_FULL @@ -26,7 +26,7 @@ OPTIONS_CONFIG = {"lock_code_digits": 6} @pytest.fixture async def load_config_entry( hass: HomeAssistant, load_json: dict[str, Any] -) -> MockConfigEntry: +) -> tuple[MockConfigEntry, Mock]: """Set up the Yale Smart Living integration in Home Assistant.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -52,7 +52,7 @@ async def load_config_entry( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - return config_entry + return (config_entry, client) @pytest.fixture(name="load_json", scope="session") diff --git a/tests/components/yale_smart_alarm/fixtures/get_all.json b/tests/components/yale_smart_alarm/fixtures/get_all.json index 08f60fafd3f..0878cbf9c6a 100644 --- a/tests/components/yale_smart_alarm/fixtures/get_all.json +++ b/tests/components/yale_smart_alarm/fixtures/get_all.json @@ -4,7 +4,7 @@ "area": "1", "no": "1", "rf": null, - "address": "123", + "address": "1111", "type": "device_type.door_lock", "name": "Device1", "status1": "device_status.lock", @@ -48,13 +48,461 @@ "group_id": null, "group_name": null, "bypass": "0", - "device_id": "123", + "device_id": "1111", "status_temp_format": "C", "type_no": "72", "device_group": "002", "status_fault": [], "status_open": ["device_status.lock"], "trigger_by_zone": [] + }, + { + "area": "1", + "no": "2", + "rf": null, + "address": "2222", + "type": "device_type.door_lock", + "name": "Device2", + "status1": "device_status.unlock", + "status2": null, + "status_switch": null, + "status_power": null, + "status_temp": null, + "status_humi": null, + "status_dim_level": null, + "status_lux": "", + "status_hue": null, + "status_saturation": null, + "rssi": "9", + "mac": "00:00:00:00:02", + "scene_trigger": "0", + "status_total_energy": null, + "device_id2": "", + "extension": null, + "minigw_protocol": "DM", + "minigw_syncing": "0", + "minigw_configuration_data": "02FF000001000000000000000000001E000100", + "minigw_product_data": "21020120", + "minigw_lock_status": null, + "minigw_number_of_credentials_supported": "10", + "sresp_button_3": null, + "sresp_button_1": null, + "sresp_button_2": null, + "sresp_button_4": null, + "ipcam_trigger_by_zone1": null, + "ipcam_trigger_by_zone2": null, + "ipcam_trigger_by_zone3": null, + "ipcam_trigger_by_zone4": null, + "scene_restore": null, + "thermo_mode": null, + "thermo_setpoint": null, + "thermo_c_setpoint": null, + "thermo_setpoint_away": null, + "thermo_c_setpoint_away": null, + "thermo_fan_mode": null, + "thermo_schd_setting": null, + "group_id": null, + "group_name": null, + "bypass": "0", + "device_id": "2222", + "status_temp_format": "C", + "type_no": "72", + "device_group": "002", + "status_fault": [], + "status_open": [], + "trigger_by_zone": [] + }, + { + "area": "1", + "no": "3", + "rf": null, + "address": "3333", + "type": "device_type.door_lock", + "name": "Device3", + "status1": "device_status.lock", + "status2": null, + "status_switch": null, + "status_power": null, + "status_temp": null, + "status_humi": null, + "status_dim_level": null, + "status_lux": "", + "status_hue": null, + "status_saturation": null, + "rssi": "9", + "mac": "00:00:00:00:03", + "scene_trigger": "0", + "status_total_energy": null, + "device_id2": "", + "extension": null, + "minigw_protocol": "DM", + "minigw_syncing": "0", + "minigw_configuration_data": "02FF000001000000000000000000001E000100", + "minigw_product_data": "21020120", + "minigw_lock_status": null, + "minigw_number_of_credentials_supported": "10", + "sresp_button_3": null, + "sresp_button_1": null, + "sresp_button_2": null, + "sresp_button_4": null, + "ipcam_trigger_by_zone1": null, + "ipcam_trigger_by_zone2": null, + "ipcam_trigger_by_zone3": null, + "ipcam_trigger_by_zone4": null, + "scene_restore": null, + "thermo_mode": null, + "thermo_setpoint": null, + "thermo_c_setpoint": null, + "thermo_setpoint_away": null, + "thermo_c_setpoint_away": null, + "thermo_fan_mode": null, + "thermo_schd_setting": null, + "group_id": null, + "group_name": null, + "bypass": "0", + "device_id": "3333", + "status_temp_format": "C", + "type_no": "72", + "device_group": "002", + "status_fault": [], + "status_open": ["device_status.lock"], + "trigger_by_zone": [] + }, + { + "area": "1", + "no": "4", + "rf": null, + "address": "RF4", + "type": "device_type.door_contact", + "name": "Device4", + "status1": "device_status.dc_close", + "status2": null, + "status_switch": null, + "status_power": null, + "status_temp": null, + "status_humi": null, + "status_dim_level": null, + "status_lux": "", + "status_hue": null, + "status_saturation": null, + "rssi": "0", + "mac": "00:00:00:00:04", + "scene_trigger": "0", + "status_total_energy": null, + "device_id2": "", + "extension": null, + "minigw_protocol": "", + "minigw_syncing": "", + "minigw_configuration_data": "", + "minigw_product_data": "", + "minigw_lock_status": "", + "minigw_number_of_credentials_supported": "", + "sresp_button_3": null, + "sresp_button_1": null, + "sresp_button_2": null, + "sresp_button_4": null, + "ipcam_trigger_by_zone1": null, + "ipcam_trigger_by_zone2": null, + "ipcam_trigger_by_zone3": null, + "ipcam_trigger_by_zone4": null, + "scene_restore": null, + "thermo_mode": null, + "thermo_setpoint": null, + "thermo_c_setpoint": null, + "thermo_setpoint_away": null, + "thermo_c_setpoint_away": null, + "thermo_fan_mode": null, + "thermo_schd_setting": null, + "group_id": null, + "group_name": null, + "bypass": "0", + "device_id": "4444", + "status_temp_format": "C", + "type_no": "4", + "device_group": "000", + "status_fault": [], + "status_open": ["device_status.dc_close"], + "trigger_by_zone": [] + }, + { + "area": "1", + "no": "5", + "rf": null, + "address": "RF5", + "type": "device_type.door_contact", + "name": "Device5", + "status1": "device_status.dc_open", + "status2": null, + "status_switch": null, + "status_power": null, + "status_temp": null, + "status_humi": null, + "status_dim_level": null, + "status_lux": "", + "status_hue": null, + "status_saturation": null, + "rssi": "0", + "mac": "00:00:00:00:05", + "scene_trigger": "0", + "status_total_energy": null, + "device_id2": "", + "extension": null, + "minigw_protocol": "", + "minigw_syncing": "", + "minigw_configuration_data": "", + "minigw_product_data": "", + "minigw_lock_status": "", + "minigw_number_of_credentials_supported": "", + "sresp_button_3": null, + "sresp_button_1": null, + "sresp_button_2": null, + "sresp_button_4": null, + "ipcam_trigger_by_zone1": null, + "ipcam_trigger_by_zone2": null, + "ipcam_trigger_by_zone3": null, + "ipcam_trigger_by_zone4": null, + "scene_restore": null, + "thermo_mode": null, + "thermo_setpoint": null, + "thermo_c_setpoint": null, + "thermo_setpoint_away": null, + "thermo_c_setpoint_away": null, + "thermo_fan_mode": null, + "thermo_schd_setting": null, + "group_id": null, + "group_name": null, + "bypass": "0", + "device_id": "5555", + "status_temp_format": "C", + "type_no": "4", + "device_group": "000", + "status_fault": [], + "status_open": ["device_status.dc_open"], + "trigger_by_zone": [] + }, + { + "area": "1", + "no": "6", + "rf": null, + "address": "RF6", + "type": "device_type.door_contact", + "name": "Device6", + "status1": "unknwon", + "status2": null, + "status_switch": null, + "status_power": null, + "status_temp": null, + "status_humi": null, + "status_dim_level": null, + "status_lux": "", + "status_hue": null, + "status_saturation": null, + "rssi": "0", + "mac": "REDACTED", + "scene_trigger": "0", + "status_total_energy": null, + "device_id2": "", + "extension": null, + "minigw_protocol": "", + "minigw_syncing": "", + "minigw_configuration_data": "", + "minigw_product_data": "", + "minigw_lock_status": "", + "minigw_number_of_credentials_supported": "", + "sresp_button_3": null, + "sresp_button_1": null, + "sresp_button_2": null, + "sresp_button_4": null, + "ipcam_trigger_by_zone1": null, + "ipcam_trigger_by_zone2": null, + "ipcam_trigger_by_zone3": null, + "ipcam_trigger_by_zone4": null, + "scene_restore": null, + "thermo_mode": null, + "thermo_setpoint": null, + "thermo_c_setpoint": null, + "thermo_setpoint_away": null, + "thermo_c_setpoint_away": null, + "thermo_fan_mode": null, + "thermo_schd_setting": null, + "group_id": null, + "group_name": null, + "bypass": "0", + "device_id": "6666", + "status_temp_format": "C", + "type_no": "4", + "device_group": "000", + "status_fault": [], + "status_open": [], + "trigger_by_zone": [] + }, + { + "area": "1", + "no": "7", + "rf": null, + "address": "7777", + "type": "device_type.door_lock", + "name": "Device7", + "status1": "device_status.lock", + "status2": null, + "status_switch": null, + "status_power": null, + "status_temp": null, + "status_humi": null, + "status_dim_level": null, + "status_lux": "", + "status_hue": null, + "status_saturation": null, + "rssi": "9", + "mac": "00:00:00:00:07", + "scene_trigger": "0", + "status_total_energy": null, + "device_id2": "", + "extension": null, + "minigw_protocol": "DM", + "minigw_syncing": "0", + "minigw_configuration_data": "02FF000001000000000000000000001E000100", + "minigw_product_data": "21020120", + "minigw_lock_status": "36", + "minigw_number_of_credentials_supported": "10", + "sresp_button_3": null, + "sresp_button_1": null, + "sresp_button_2": null, + "sresp_button_4": null, + "ipcam_trigger_by_zone1": null, + "ipcam_trigger_by_zone2": null, + "ipcam_trigger_by_zone3": null, + "ipcam_trigger_by_zone4": null, + "scene_restore": null, + "thermo_mode": null, + "thermo_setpoint": null, + "thermo_c_setpoint": null, + "thermo_setpoint_away": null, + "thermo_c_setpoint_away": null, + "thermo_fan_mode": null, + "thermo_schd_setting": null, + "group_id": null, + "group_name": null, + "bypass": "0", + "device_id": "7777", + "status_temp_format": "C", + "type_no": "72", + "device_group": "002", + "status_fault": [], + "status_open": ["device_status.lock"], + "trigger_by_zone": [] + }, + { + "area": "1", + "no": "8", + "rf": null, + "address": "8888", + "type": "device_type.door_lock", + "name": "Device8", + "status1": "device_status.unlock", + "status2": null, + "status_switch": null, + "status_power": null, + "status_temp": null, + "status_humi": null, + "status_dim_level": null, + "status_lux": "", + "status_hue": null, + "status_saturation": null, + "rssi": "9", + "mac": "00:00:00:00:08", + "scene_trigger": "0", + "status_total_energy": null, + "device_id2": "", + "extension": null, + "minigw_protocol": "DM", + "minigw_syncing": "0", + "minigw_configuration_data": "02FF000001000000000000000000001E000100", + "minigw_product_data": "21020120", + "minigw_lock_status": "4", + "minigw_number_of_credentials_supported": "10", + "sresp_button_3": null, + "sresp_button_1": null, + "sresp_button_2": null, + "sresp_button_4": null, + "ipcam_trigger_by_zone1": null, + "ipcam_trigger_by_zone2": null, + "ipcam_trigger_by_zone3": null, + "ipcam_trigger_by_zone4": null, + "scene_restore": null, + "thermo_mode": null, + "thermo_setpoint": null, + "thermo_c_setpoint": null, + "thermo_setpoint_away": null, + "thermo_c_setpoint_away": null, + "thermo_fan_mode": null, + "thermo_schd_setting": null, + "group_id": null, + "group_name": null, + "bypass": "0", + "device_id": "8888", + "status_temp_format": "C", + "type_no": "72", + "device_group": "002", + "status_fault": [], + "status_open": ["device_status.unlock"], + "trigger_by_zone": [] + }, + { + "area": "1", + "no": "9", + "rf": null, + "address": "9999", + "type": "device_type.door_lock", + "name": "Device9", + "status1": "device_status.error", + "status2": null, + "status_switch": null, + "status_power": null, + "status_temp": null, + "status_humi": null, + "status_dim_level": null, + "status_lux": "", + "status_hue": null, + "status_saturation": null, + "rssi": "9", + "mac": "00:00:00:00:09", + "scene_trigger": "0", + "status_total_energy": null, + "device_id2": "", + "extension": null, + "minigw_protocol": "DM", + "minigw_syncing": "0", + "minigw_configuration_data": "02FF000001000000000000000000001E000100", + "minigw_product_data": "21020120", + "minigw_lock_status": "10", + "minigw_number_of_credentials_supported": "10", + "sresp_button_3": null, + "sresp_button_1": null, + "sresp_button_2": null, + "sresp_button_4": null, + "ipcam_trigger_by_zone1": null, + "ipcam_trigger_by_zone2": null, + "ipcam_trigger_by_zone3": null, + "ipcam_trigger_by_zone4": null, + "scene_restore": null, + "thermo_mode": null, + "thermo_setpoint": null, + "thermo_c_setpoint": null, + "thermo_setpoint_away": null, + "thermo_c_setpoint_away": null, + "thermo_fan_mode": null, + "thermo_schd_setting": null, + "group_id": null, + "group_name": null, + "bypass": "0", + "device_id": "9999", + "status_temp_format": "C", + "type_no": "72", + "device_group": "002", + "status_fault": [], + "status_open": ["device_status.error"], + "trigger_by_zone": [] } ], "MODE": [ @@ -88,9 +536,9 @@ "area": "1", "no": "1", "rf": null, - "address": "124", + "address": "1111", "type": "device_type.door_lock", - "name": "Device2", + "name": "Device1", "status1": "device_status.lock", "status2": null, "status_switch": null, @@ -102,7 +550,7 @@ "status_hue": null, "status_saturation": null, "rssi": "9", - "mac": "00:00:00:00:02", + "mac": "00:00:00:00:01", "scene_trigger": "0", "status_total_energy": null, "device_id2": "", @@ -132,13 +580,461 @@ "group_id": null, "group_name": null, "bypass": "0", - "device_id": "124", + "device_id": "1111", "status_temp_format": "C", "type_no": "72", "device_group": "002", "status_fault": [], "status_open": ["device_status.lock"], "trigger_by_zone": [] + }, + { + "area": "1", + "no": "2", + "rf": null, + "address": "2222", + "type": "device_type.door_lock", + "name": "Device2", + "status1": "device_status.unlock", + "status2": null, + "status_switch": null, + "status_power": null, + "status_temp": null, + "status_humi": null, + "status_dim_level": null, + "status_lux": "", + "status_hue": null, + "status_saturation": null, + "rssi": "9", + "mac": "00:00:00:00:02", + "scene_trigger": "0", + "status_total_energy": null, + "device_id2": "", + "extension": null, + "minigw_protocol": "DM", + "minigw_syncing": "0", + "minigw_configuration_data": "02FF000001000000000000000000001E000100", + "minigw_product_data": "21020120", + "minigw_lock_status": null, + "minigw_number_of_credentials_supported": "10", + "sresp_button_3": null, + "sresp_button_1": null, + "sresp_button_2": null, + "sresp_button_4": null, + "ipcam_trigger_by_zone1": null, + "ipcam_trigger_by_zone2": null, + "ipcam_trigger_by_zone3": null, + "ipcam_trigger_by_zone4": null, + "scene_restore": null, + "thermo_mode": null, + "thermo_setpoint": null, + "thermo_c_setpoint": null, + "thermo_setpoint_away": null, + "thermo_c_setpoint_away": null, + "thermo_fan_mode": null, + "thermo_schd_setting": null, + "group_id": null, + "group_name": null, + "bypass": "0", + "device_id": "2222", + "status_temp_format": "C", + "type_no": "72", + "device_group": "002", + "status_fault": [], + "status_open": [], + "trigger_by_zone": [] + }, + { + "area": "1", + "no": "3", + "rf": null, + "address": "3333", + "type": "device_type.door_lock", + "name": "Device3", + "status1": "device_status.lock", + "status2": null, + "status_switch": null, + "status_power": null, + "status_temp": null, + "status_humi": null, + "status_dim_level": null, + "status_lux": "", + "status_hue": null, + "status_saturation": null, + "rssi": "9", + "mac": "00:00:00:00:03", + "scene_trigger": "0", + "status_total_energy": null, + "device_id2": "", + "extension": null, + "minigw_protocol": "DM", + "minigw_syncing": "0", + "minigw_configuration_data": "02FF000001000000000000000000001E000100", + "minigw_product_data": "21020120", + "minigw_lock_status": null, + "minigw_number_of_credentials_supported": "10", + "sresp_button_3": null, + "sresp_button_1": null, + "sresp_button_2": null, + "sresp_button_4": null, + "ipcam_trigger_by_zone1": null, + "ipcam_trigger_by_zone2": null, + "ipcam_trigger_by_zone3": null, + "ipcam_trigger_by_zone4": null, + "scene_restore": null, + "thermo_mode": null, + "thermo_setpoint": null, + "thermo_c_setpoint": null, + "thermo_setpoint_away": null, + "thermo_c_setpoint_away": null, + "thermo_fan_mode": null, + "thermo_schd_setting": null, + "group_id": null, + "group_name": null, + "bypass": "0", + "device_id": "3333", + "status_temp_format": "C", + "type_no": "72", + "device_group": "002", + "status_fault": [], + "status_open": ["device_status.lock"], + "trigger_by_zone": [] + }, + { + "area": "1", + "no": "4", + "rf": null, + "address": "RF4", + "type": "device_type.door_contact", + "name": "Device4", + "status1": "device_status.dc_close", + "status2": null, + "status_switch": null, + "status_power": null, + "status_temp": null, + "status_humi": null, + "status_dim_level": null, + "status_lux": "", + "status_hue": null, + "status_saturation": null, + "rssi": "0", + "mac": "00:00:00:00:04", + "scene_trigger": "0", + "status_total_energy": null, + "device_id2": "", + "extension": null, + "minigw_protocol": "", + "minigw_syncing": "", + "minigw_configuration_data": "", + "minigw_product_data": "", + "minigw_lock_status": "", + "minigw_number_of_credentials_supported": "", + "sresp_button_3": null, + "sresp_button_1": null, + "sresp_button_2": null, + "sresp_button_4": null, + "ipcam_trigger_by_zone1": null, + "ipcam_trigger_by_zone2": null, + "ipcam_trigger_by_zone3": null, + "ipcam_trigger_by_zone4": null, + "scene_restore": null, + "thermo_mode": null, + "thermo_setpoint": null, + "thermo_c_setpoint": null, + "thermo_setpoint_away": null, + "thermo_c_setpoint_away": null, + "thermo_fan_mode": null, + "thermo_schd_setting": null, + "group_id": null, + "group_name": null, + "bypass": "0", + "device_id": "4444", + "status_temp_format": "C", + "type_no": "4", + "device_group": "000", + "status_fault": [], + "status_open": ["device_status.dc_close"], + "trigger_by_zone": [] + }, + { + "area": "1", + "no": "5", + "rf": null, + "address": "RF5", + "type": "device_type.door_contact", + "name": "Device5", + "status1": "device_status.dc_open", + "status2": null, + "status_switch": null, + "status_power": null, + "status_temp": null, + "status_humi": null, + "status_dim_level": null, + "status_lux": "", + "status_hue": null, + "status_saturation": null, + "rssi": "0", + "mac": "00:00:00:00:05", + "scene_trigger": "0", + "status_total_energy": null, + "device_id2": "", + "extension": null, + "minigw_protocol": "", + "minigw_syncing": "", + "minigw_configuration_data": "", + "minigw_product_data": "", + "minigw_lock_status": "", + "minigw_number_of_credentials_supported": "", + "sresp_button_3": null, + "sresp_button_1": null, + "sresp_button_2": null, + "sresp_button_4": null, + "ipcam_trigger_by_zone1": null, + "ipcam_trigger_by_zone2": null, + "ipcam_trigger_by_zone3": null, + "ipcam_trigger_by_zone4": null, + "scene_restore": null, + "thermo_mode": null, + "thermo_setpoint": null, + "thermo_c_setpoint": null, + "thermo_setpoint_away": null, + "thermo_c_setpoint_away": null, + "thermo_fan_mode": null, + "thermo_schd_setting": null, + "group_id": null, + "group_name": null, + "bypass": "0", + "device_id": "5555", + "status_temp_format": "C", + "type_no": "4", + "device_group": "000", + "status_fault": [], + "status_open": ["device_status.dc_open"], + "trigger_by_zone": [] + }, + { + "area": "1", + "no": "6", + "rf": null, + "address": "RF6", + "type": "device_type.door_contact", + "name": "Device6", + "status1": "unknwon", + "status2": null, + "status_switch": null, + "status_power": null, + "status_temp": null, + "status_humi": null, + "status_dim_level": null, + "status_lux": "", + "status_hue": null, + "status_saturation": null, + "rssi": "0", + "mac": "REDACTED", + "scene_trigger": "0", + "status_total_energy": null, + "device_id2": "", + "extension": null, + "minigw_protocol": "", + "minigw_syncing": "", + "minigw_configuration_data": "", + "minigw_product_data": "", + "minigw_lock_status": "", + "minigw_number_of_credentials_supported": "", + "sresp_button_3": null, + "sresp_button_1": null, + "sresp_button_2": null, + "sresp_button_4": null, + "ipcam_trigger_by_zone1": null, + "ipcam_trigger_by_zone2": null, + "ipcam_trigger_by_zone3": null, + "ipcam_trigger_by_zone4": null, + "scene_restore": null, + "thermo_mode": null, + "thermo_setpoint": null, + "thermo_c_setpoint": null, + "thermo_setpoint_away": null, + "thermo_c_setpoint_away": null, + "thermo_fan_mode": null, + "thermo_schd_setting": null, + "group_id": null, + "group_name": null, + "bypass": "0", + "device_id": "6666", + "status_temp_format": "C", + "type_no": "4", + "device_group": "000", + "status_fault": [], + "status_open": [], + "trigger_by_zone": [] + }, + { + "area": "1", + "no": "7", + "rf": null, + "address": "7777", + "type": "device_type.door_lock", + "name": "Device7", + "status1": "device_status.lock", + "status2": null, + "status_switch": null, + "status_power": null, + "status_temp": null, + "status_humi": null, + "status_dim_level": null, + "status_lux": "", + "status_hue": null, + "status_saturation": null, + "rssi": "9", + "mac": "00:00:00:00:07", + "scene_trigger": "0", + "status_total_energy": null, + "device_id2": "", + "extension": null, + "minigw_protocol": "DM", + "minigw_syncing": "0", + "minigw_configuration_data": "02FF000001000000000000000000001E000100", + "minigw_product_data": "21020120", + "minigw_lock_status": "36", + "minigw_number_of_credentials_supported": "10", + "sresp_button_3": null, + "sresp_button_1": null, + "sresp_button_2": null, + "sresp_button_4": null, + "ipcam_trigger_by_zone1": null, + "ipcam_trigger_by_zone2": null, + "ipcam_trigger_by_zone3": null, + "ipcam_trigger_by_zone4": null, + "scene_restore": null, + "thermo_mode": null, + "thermo_setpoint": null, + "thermo_c_setpoint": null, + "thermo_setpoint_away": null, + "thermo_c_setpoint_away": null, + "thermo_fan_mode": null, + "thermo_schd_setting": null, + "group_id": null, + "group_name": null, + "bypass": "0", + "device_id": "7777", + "status_temp_format": "C", + "type_no": "72", + "device_group": "002", + "status_fault": [], + "status_open": ["device_status.lock"], + "trigger_by_zone": [] + }, + { + "area": "1", + "no": "8", + "rf": null, + "address": "8888", + "type": "device_type.door_lock", + "name": "Device8", + "status1": "device_status.unlock", + "status2": null, + "status_switch": null, + "status_power": null, + "status_temp": null, + "status_humi": null, + "status_dim_level": null, + "status_lux": "", + "status_hue": null, + "status_saturation": null, + "rssi": "9", + "mac": "00:00:00:00:08", + "scene_trigger": "0", + "status_total_energy": null, + "device_id2": "", + "extension": null, + "minigw_protocol": "DM", + "minigw_syncing": "0", + "minigw_configuration_data": "02FF000001000000000000000000001E000100", + "minigw_product_data": "21020120", + "minigw_lock_status": "4", + "minigw_number_of_credentials_supported": "10", + "sresp_button_3": null, + "sresp_button_1": null, + "sresp_button_2": null, + "sresp_button_4": null, + "ipcam_trigger_by_zone1": null, + "ipcam_trigger_by_zone2": null, + "ipcam_trigger_by_zone3": null, + "ipcam_trigger_by_zone4": null, + "scene_restore": null, + "thermo_mode": null, + "thermo_setpoint": null, + "thermo_c_setpoint": null, + "thermo_setpoint_away": null, + "thermo_c_setpoint_away": null, + "thermo_fan_mode": null, + "thermo_schd_setting": null, + "group_id": null, + "group_name": null, + "bypass": "0", + "device_id": "8888", + "status_temp_format": "C", + "type_no": "72", + "device_group": "002", + "status_fault": [], + "status_open": ["device_status.unlock"], + "trigger_by_zone": [] + }, + { + "area": "1", + "no": "9", + "rf": null, + "address": "9999", + "type": "device_type.door_lock", + "name": "Device9", + "status1": "device_status.error", + "status2": null, + "status_switch": null, + "status_power": null, + "status_temp": null, + "status_humi": null, + "status_dim_level": null, + "status_lux": "", + "status_hue": null, + "status_saturation": null, + "rssi": "9", + "mac": "00:00:00:00:09", + "scene_trigger": "0", + "status_total_energy": null, + "device_id2": "", + "extension": null, + "minigw_protocol": "DM", + "minigw_syncing": "0", + "minigw_configuration_data": "02FF000001000000000000000000001E000100", + "minigw_product_data": "21020120", + "minigw_lock_status": "10", + "minigw_number_of_credentials_supported": "10", + "sresp_button_3": null, + "sresp_button_1": null, + "sresp_button_2": null, + "sresp_button_4": null, + "ipcam_trigger_by_zone1": null, + "ipcam_trigger_by_zone2": null, + "ipcam_trigger_by_zone3": null, + "ipcam_trigger_by_zone4": null, + "scene_restore": null, + "thermo_mode": null, + "thermo_setpoint": null, + "thermo_c_setpoint": null, + "thermo_setpoint_away": null, + "thermo_c_setpoint_away": null, + "thermo_fan_mode": null, + "thermo_schd_setting": null, + "group_id": null, + "group_name": null, + "bypass": "0", + "device_id": "9999", + "status_temp_format": "C", + "type_no": "72", + "device_group": "002", + "status_fault": [], + "status_open": ["device_status.error"], + "trigger_by_zone": [] } ], "capture_latest": null, diff --git a/tests/components/yale_smart_alarm/snapshots/test_diagnostics.ambr b/tests/components/yale_smart_alarm/snapshots/test_diagnostics.ambr index faff1c5103a..ae720a611e3 100644 --- a/tests/components/yale_smart_alarm/snapshots/test_diagnostics.ambr +++ b/tests/components/yale_smart_alarm/snapshots/test_diagnostics.ambr @@ -82,6 +82,496 @@ 'type': 'device_type.door_lock', 'type_no': '72', }), + dict({ + '_state': 'unlocked', + '_state2': 'unknown', + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '002', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '02FF000001000000000000000000001E000100', + 'minigw_lock_status': None, + 'minigw_number_of_credentials_supported': '10', + 'minigw_product_data': '21020120', + 'minigw_protocol': 'DM', + 'minigw_syncing': '0', + 'name': '**REDACTED**', + 'no': '2', + 'rf': None, + 'rssi': '9', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.unlock', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_lock', + 'type_no': '72', + }), + dict({ + '_state': 'locked', + '_state2': 'unknown', + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '002', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '02FF000001000000000000000000001E000100', + 'minigw_lock_status': None, + 'minigw_number_of_credentials_supported': '10', + 'minigw_product_data': '21020120', + 'minigw_protocol': 'DM', + 'minigw_syncing': '0', + 'name': '**REDACTED**', + 'no': '3', + 'rf': None, + 'rssi': '9', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.lock', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + 'device_status.lock', + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_lock', + 'type_no': '72', + }), + dict({ + '_state': 'closed', + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '000', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '', + 'minigw_lock_status': '', + 'minigw_number_of_credentials_supported': '', + 'minigw_product_data': '', + 'minigw_protocol': '', + 'minigw_syncing': '', + 'name': '**REDACTED**', + 'no': '4', + 'rf': None, + 'rssi': '0', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.dc_close', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + 'device_status.dc_close', + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_contact', + 'type_no': '4', + }), + dict({ + '_state': 'open', + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '000', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '', + 'minigw_lock_status': '', + 'minigw_number_of_credentials_supported': '', + 'minigw_product_data': '', + 'minigw_protocol': '', + 'minigw_syncing': '', + 'name': '**REDACTED**', + 'no': '5', + 'rf': None, + 'rssi': '0', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.dc_open', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + 'device_status.dc_open', + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_contact', + 'type_no': '4', + }), + dict({ + '_state': 'unavailable', + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '000', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '', + 'minigw_lock_status': '', + 'minigw_number_of_credentials_supported': '', + 'minigw_product_data': '', + 'minigw_protocol': '', + 'minigw_syncing': '', + 'name': '**REDACTED**', + 'no': '6', + 'rf': None, + 'rssi': '0', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'unknwon', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_contact', + 'type_no': '4', + }), + dict({ + '_state': 'unlocked', + '_state2': 'closed', + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '002', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '02FF000001000000000000000000001E000100', + 'minigw_lock_status': '36', + 'minigw_number_of_credentials_supported': '10', + 'minigw_product_data': '21020120', + 'minigw_protocol': 'DM', + 'minigw_syncing': '0', + 'name': '**REDACTED**', + 'no': '7', + 'rf': None, + 'rssi': '9', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.lock', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + 'device_status.lock', + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_lock', + 'type_no': '72', + }), + dict({ + '_state': 'unlocked', + '_state2': 'open', + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '002', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '02FF000001000000000000000000001E000100', + 'minigw_lock_status': '4', + 'minigw_number_of_credentials_supported': '10', + 'minigw_product_data': '21020120', + 'minigw_protocol': 'DM', + 'minigw_syncing': '0', + 'name': '**REDACTED**', + 'no': '8', + 'rf': None, + 'rssi': '9', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.unlock', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + 'device_status.unlock', + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_lock', + 'type_no': '72', + }), + dict({ + '_state': 'unavailable', + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '002', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '02FF000001000000000000000000001E000100', + 'minigw_lock_status': '10', + 'minigw_number_of_credentials_supported': '10', + 'minigw_product_data': '21020120', + 'minigw_protocol': 'DM', + 'minigw_syncing': '0', + 'name': '**REDACTED**', + 'no': '9', + 'rf': None, + 'rssi': '9', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.error', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + 'device_status.error', + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_lock', + 'type_no': '72', + }), ]), 'model': list([ dict({ @@ -162,6 +652,484 @@ 'type': 'device_type.door_lock', 'type_no': '72', }), + dict({ + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '002', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '02FF000001000000000000000000001E000100', + 'minigw_lock_status': None, + 'minigw_number_of_credentials_supported': '10', + 'minigw_product_data': '21020120', + 'minigw_protocol': 'DM', + 'minigw_syncing': '0', + 'name': '**REDACTED**', + 'no': '2', + 'rf': None, + 'rssi': '9', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.unlock', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_lock', + 'type_no': '72', + }), + dict({ + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '002', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '02FF000001000000000000000000001E000100', + 'minigw_lock_status': None, + 'minigw_number_of_credentials_supported': '10', + 'minigw_product_data': '21020120', + 'minigw_protocol': 'DM', + 'minigw_syncing': '0', + 'name': '**REDACTED**', + 'no': '3', + 'rf': None, + 'rssi': '9', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.lock', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + 'device_status.lock', + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_lock', + 'type_no': '72', + }), + dict({ + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '000', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '', + 'minigw_lock_status': '', + 'minigw_number_of_credentials_supported': '', + 'minigw_product_data': '', + 'minigw_protocol': '', + 'minigw_syncing': '', + 'name': '**REDACTED**', + 'no': '4', + 'rf': None, + 'rssi': '0', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.dc_close', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + 'device_status.dc_close', + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_contact', + 'type_no': '4', + }), + dict({ + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '000', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '', + 'minigw_lock_status': '', + 'minigw_number_of_credentials_supported': '', + 'minigw_product_data': '', + 'minigw_protocol': '', + 'minigw_syncing': '', + 'name': '**REDACTED**', + 'no': '5', + 'rf': None, + 'rssi': '0', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.dc_open', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + 'device_status.dc_open', + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_contact', + 'type_no': '4', + }), + dict({ + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '000', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '', + 'minigw_lock_status': '', + 'minigw_number_of_credentials_supported': '', + 'minigw_product_data': '', + 'minigw_protocol': '', + 'minigw_syncing': '', + 'name': '**REDACTED**', + 'no': '6', + 'rf': None, + 'rssi': '0', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'unknwon', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_contact', + 'type_no': '4', + }), + dict({ + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '002', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '02FF000001000000000000000000001E000100', + 'minigw_lock_status': '36', + 'minigw_number_of_credentials_supported': '10', + 'minigw_product_data': '21020120', + 'minigw_protocol': 'DM', + 'minigw_syncing': '0', + 'name': '**REDACTED**', + 'no': '7', + 'rf': None, + 'rssi': '9', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.lock', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + 'device_status.lock', + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_lock', + 'type_no': '72', + }), + dict({ + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '002', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '02FF000001000000000000000000001E000100', + 'minigw_lock_status': '4', + 'minigw_number_of_credentials_supported': '10', + 'minigw_product_data': '21020120', + 'minigw_protocol': 'DM', + 'minigw_syncing': '0', + 'name': '**REDACTED**', + 'no': '8', + 'rf': None, + 'rssi': '9', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.unlock', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + 'device_status.unlock', + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_lock', + 'type_no': '72', + }), + dict({ + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '002', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '02FF000001000000000000000000001E000100', + 'minigw_lock_status': '10', + 'minigw_number_of_credentials_supported': '10', + 'minigw_product_data': '21020120', + 'minigw_protocol': 'DM', + 'minigw_syncing': '0', + 'name': '**REDACTED**', + 'no': '9', + 'rf': None, + 'rssi': '9', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.error', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + 'device_status.error', + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_lock', + 'type_no': '72', + }), ]), 'HISTORY': list([ dict({ diff --git a/tests/components/yale_smart_alarm/test_coordinator.py b/tests/components/yale_smart_alarm/test_coordinator.py new file mode 100644 index 00000000000..9ee09e9c0f2 --- /dev/null +++ b/tests/components/yale_smart_alarm/test_coordinator.py @@ -0,0 +1,123 @@ +"""The test for the sensibo coordinator.""" +from __future__ import annotations + +from datetime import timedelta +from typing import Any +from unittest.mock import Mock, patch + +import pytest +from yalesmartalarmclient.const import YALE_STATE_ARM_FULL +from yalesmartalarmclient.exceptions import AuthenticationError, UnknownError + +from homeassistant.components.yale_smart_alarm.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import STATE_ALARM_ARMED_AWAY, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from .conftest import ENTRY_CONFIG, OPTIONS_CONFIG + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.mark.parametrize( + "p_error", + [ + AuthenticationError(), + UnknownError(), + ConnectionError("Could not connect"), + TimeoutError(), + ], +) +async def test_coordinator_setup_errors( + hass: HomeAssistant, + load_json: dict[str, Any], + p_error: Exception, +) -> None: + """Test the Yale Smart Living coordinator with errors.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + unique_id="username", + version=1, + ) + + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.yale_smart_alarm.coordinator.YaleSmartAlarmClient", + autospec=True, + ) as mock_client_class: + mock_client_class.side_effect = p_error + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("alarm_control_panel.yale_smart_alarm") + assert not state + + +async def test_coordinator_setup_and_update_errors( + hass: HomeAssistant, + load_config_entry: tuple[MockConfigEntry, Mock], + load_json: dict[str, Any], +) -> None: + """Test the Yale Smart Living coordinator with errors.""" + + client = load_config_entry[1] + + state = hass.states.get("alarm_control_panel.yale_smart_alarm") + assert state.state == STATE_ALARM_ARMED_AWAY + client.reset_mock() + + client.get_all.side_effect = ConnectionError("Could not connect") + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=1)) + await hass.async_block_till_done() + client.get_all.assert_called_once() + state = hass.states.get("alarm_control_panel.yale_smart_alarm") + assert state.state == STATE_UNAVAILABLE + client.reset_mock() + + client.get_all.side_effect = ConnectionError("Could not connect") + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=2)) + await hass.async_block_till_done() + client.get_all.assert_called_once() + state = hass.states.get("alarm_control_panel.yale_smart_alarm") + assert state.state == STATE_UNAVAILABLE + client.reset_mock() + + client.get_all.side_effect = TimeoutError("Could not connect") + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=3)) + await hass.async_block_till_done() + client.get_all.assert_called_once() + state = hass.states.get("alarm_control_panel.yale_smart_alarm") + assert state.state == STATE_UNAVAILABLE + client.reset_mock() + + client.get_all.side_effect = UnknownError("info") + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=4)) + await hass.async_block_till_done() + client.get_all.assert_called_once() + state = hass.states.get("alarm_control_panel.yale_smart_alarm") + assert state.state == STATE_UNAVAILABLE + client.reset_mock() + + client.get_all.side_effect = None + client.get_all.return_value = load_json + client.get_armed_status.return_value = YALE_STATE_ARM_FULL + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) + await hass.async_block_till_done() + client.get_all.assert_called_once() + state = hass.states.get("alarm_control_panel.yale_smart_alarm") + assert state.state == STATE_ALARM_ARMED_AWAY + client.reset_mock() + + client.get_all.side_effect = AuthenticationError("Can not authenticate") + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=6)) + await hass.async_block_till_done() + client.get_all.assert_called_once() + state = hass.states.get("alarm_control_panel.yale_smart_alarm") + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/yale_smart_alarm/test_diagnostics.py b/tests/components/yale_smart_alarm/test_diagnostics.py index 8796eeb465b..dc4c5e8c8d7 100644 --- a/tests/components/yale_smart_alarm/test_diagnostics.py +++ b/tests/components/yale_smart_alarm/test_diagnostics.py @@ -1,11 +1,13 @@ """Test Yale Smart Living diagnostics.""" from __future__ import annotations +from unittest.mock import Mock + from syrupy.assertion import SnapshotAssertion -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -13,11 +15,11 @@ from tests.typing import ClientSessionGenerator async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, - load_config_entry: ConfigEntry, + load_config_entry: tuple[MockConfigEntry, Mock], snapshot: SnapshotAssertion, ) -> None: """Test generating diagnostics for a config entry.""" - entry = load_config_entry + entry = load_config_entry[0] diag = await get_diagnostics_for_config_entry(hass, hass_client, entry) From 31a8a62165829fc481b0a28452f53c8f100aed25 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 24 Aug 2023 11:45:14 +0200 Subject: [PATCH 038/124] SNMP sensor refactor to ManualTriggerSensorEntity (#98630) * SNMP to ManualTriggerSensorEntity * Mods --- homeassistant/components/snmp/sensor.py | 68 ++++++++++++++++++------- 1 file changed, 50 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/snmp/sensor.py b/homeassistant/components/snmp/sensor.py index fc8068fb532..85c69ddf76b 100644 --- a/homeassistant/components/snmp/sensor.py +++ b/homeassistant/components/snmp/sensor.py @@ -19,11 +19,15 @@ from pysnmp.hlapi.asyncio import ( ) import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.components.sensor import CONF_STATE_CLASS, PLATFORM_SCHEMA from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_HOST, + CONF_ICON, + CONF_NAME, CONF_PORT, CONF_UNIQUE_ID, + CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, CONF_VALUE_TEMPLATE, STATE_UNKNOWN, @@ -31,9 +35,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.template import Template from homeassistant.helpers.template_entity import ( + CONF_AVAILABILITY, + CONF_PICTURE, TEMPLATE_SENSOR_BASE_SCHEMA, - TemplateSensor, + ManualTriggerSensorEntity, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -64,6 +71,16 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=10) +TRIGGER_ENTITY_OPTIONS = ( + CONF_AVAILABILITY, + CONF_DEVICE_CLASS, + CONF_ICON, + CONF_PICTURE, + CONF_UNIQUE_ID, + CONF_STATE_CLASS, + CONF_UNIT_OF_MEASUREMENT, +) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_BASEOID): cv.string, @@ -106,7 +123,6 @@ async def async_setup_platform( privproto = config[CONF_PRIV_PROTOCOL] accept_errors = config.get(CONF_ACCEPT_ERRORS) default_value = config.get(CONF_DEFAULT_VALUE) - unique_id = config.get(CONF_UNIQUE_ID) try: # Try IPv4 first. @@ -151,35 +167,50 @@ async def async_setup_platform( _LOGGER.error("Please check the details in the configuration file") return + name = config.get(CONF_NAME, Template(DEFAULT_NAME, hass)) + trigger_entity_config = {CONF_NAME: name} + for key in TRIGGER_ENTITY_OPTIONS: + if key not in config: + continue + trigger_entity_config[key] = config[key] + + value_template: Template | None = config.get(CONF_VALUE_TEMPLATE) + if value_template is not None: + value_template.hass = hass + data = SnmpData(request_args, baseoid, accept_errors, default_value) - async_add_entities([SnmpSensor(hass, data, config, unique_id)], True) + async_add_entities([SnmpSensor(hass, data, trigger_entity_config, value_template)]) -class SnmpSensor(TemplateSensor): +class SnmpSensor(ManualTriggerSensorEntity): """Representation of a SNMP sensor.""" _attr_should_poll = True - def __init__(self, hass, data, config, unique_id): + def __init__( + self, + hass: HomeAssistant, + data: SnmpData, + config: ConfigType, + value_template: Template | None, + ) -> None: """Initialize the sensor.""" - super().__init__( - hass, config=config, unique_id=unique_id, fallback_name=DEFAULT_NAME - ) + super().__init__(hass, config) self.data = data self._state = None - self._value_template = config.get(CONF_VALUE_TEMPLATE) - if (value_template := self._value_template) is not None: - value_template.hass = hass + self._value_template = value_template - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state + async def async_added_to_hass(self) -> None: + """Handle adding to Home Assistant.""" + await super().async_added_to_hass() + await self.async_update() async def async_update(self) -> None: """Get the latest data and updates the states.""" await self.data.async_update() + raw_value = self.data.value + if (value := self.data.value) is None: value = STATE_UNKNOWN elif self._value_template is not None: @@ -187,13 +218,14 @@ class SnmpSensor(TemplateSensor): value, STATE_UNKNOWN ) - self._state = value + self._attr_native_value = value + self._process_manual_data(raw_value) class SnmpData: """Get the latest data and update the states.""" - def __init__(self, request_args, baseoid, accept_errors, default_value): + def __init__(self, request_args, baseoid, accept_errors, default_value) -> None: """Initialize the data object.""" self._request_args = request_args self._baseoid = baseoid From d282ba6bac32053daf76a7cfd333e160fdd426c6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 24 Aug 2023 11:59:24 +0200 Subject: [PATCH 039/124] Use a single WS command for group preview (#98903) * Use a single WS command for group preview * Fix tests --- .../components/group/binary_sensor.py | 16 ++ homeassistant/components/group/config_flow.py | 159 ++++++------------ homeassistant/components/group/sensor.py | 17 ++ homeassistant/config_entries.py | 2 +- homeassistant/data_entry_flow.py | 5 +- .../helpers/schema_config_entry_flow.py | 6 +- tests/components/group/test_config_flow.py | 17 +- tests/test_config_entries.py | 3 +- 8 files changed, 102 insertions(+), 123 deletions(-) diff --git a/homeassistant/components/group/binary_sensor.py b/homeassistant/components/group/binary_sensor.py index 53bf1affe00..d1e91db8f86 100644 --- a/homeassistant/components/group/binary_sensor.py +++ b/homeassistant/components/group/binary_sensor.py @@ -1,6 +1,8 @@ """Platform allowing several binary sensor to be grouped into one binary sensor.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.binary_sensor import ( @@ -85,6 +87,20 @@ async def async_setup_entry( ) +@callback +def async_create_preview_binary_sensor( + name: str, validated_config: dict[str, Any] +) -> BinarySensorGroup: + """Create a preview sensor.""" + return BinarySensorGroup( + None, + name, + None, + validated_config[CONF_ENTITIES], + validated_config[CONF_ALL], + ) + + class BinarySensorGroup(GroupEntity, BinarySensorEntity): """Representation of a BinarySensorGroup.""" diff --git a/homeassistant/components/group/config_flow.py b/homeassistant/components/group/config_flow.py index 869a4d33b5f..1d820b516af 100644 --- a/homeassistant/components/group/config_flow.py +++ b/homeassistant/components/group/config_flow.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine, Mapping from functools import partial -from typing import Any, Literal, cast +from typing import Any, cast import voluptuous as vol @@ -21,10 +21,10 @@ from homeassistant.helpers.schema_config_entry_flow import ( entity_selector_without_own_entities, ) -from . import DOMAIN -from .binary_sensor import CONF_ALL, BinarySensorGroup +from . import DOMAIN, GroupEntity +from .binary_sensor import CONF_ALL, async_create_preview_binary_sensor from .const import CONF_HIDE_MEMBERS, CONF_IGNORE_NON_NUMERIC -from .sensor import SensorGroup +from .sensor import async_create_preview_sensor _STATISTIC_MEASURES = [ "min", @@ -171,8 +171,8 @@ CONFIG_FLOW = { "user": SchemaFlowMenuStep(GROUP_TYPES), "binary_sensor": SchemaFlowFormStep( BINARY_SENSOR_CONFIG_SCHEMA, + preview="group", validate_user_input=set_group_type("binary_sensor"), - preview="group_binary_sensor", ), "cover": SchemaFlowFormStep( basic_group_config_schema("cover"), @@ -196,8 +196,8 @@ CONFIG_FLOW = { ), "sensor": SchemaFlowFormStep( SENSOR_CONFIG_SCHEMA, + preview="group", validate_user_input=set_group_type("sensor"), - preview="group_sensor", ), "switch": SchemaFlowFormStep( basic_group_config_schema("switch"), @@ -210,22 +210,33 @@ OPTIONS_FLOW = { "init": SchemaFlowFormStep(next_step=choose_options_step), "binary_sensor": SchemaFlowFormStep( binary_sensor_options_schema, - preview="group_binary_sensor", + preview="group", ), "cover": SchemaFlowFormStep(partial(basic_group_options_schema, "cover")), "fan": SchemaFlowFormStep(partial(basic_group_options_schema, "fan")), "light": SchemaFlowFormStep(partial(light_switch_options_schema, "light")), "lock": SchemaFlowFormStep(partial(basic_group_options_schema, "lock")), "media_player": SchemaFlowFormStep( - partial(basic_group_options_schema, "media_player") + partial(basic_group_options_schema, "media_player"), + preview="group", ), "sensor": SchemaFlowFormStep( partial(sensor_options_schema, "sensor"), - preview="group_sensor", + preview="group", ), "switch": SchemaFlowFormStep(partial(light_switch_options_schema, "switch")), } +PREVIEW_OPTIONS_SCHEMA: dict[str, vol.Schema] = {} + +CREATE_PREVIEW_ENTITY: dict[ + str, + Callable[[str, dict[str, Any]], GroupEntity], +] = { + "binary_sensor": async_create_preview_binary_sensor, + "sensor": async_create_preview_sensor, +} + class GroupConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config or options flow for groups.""" @@ -261,12 +272,20 @@ class GroupConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): ) _async_hide_members(hass, options[CONF_ENTITIES], hidden_by) - @callback @staticmethod - def async_setup_preview(hass: HomeAssistant) -> None: + async def async_setup_preview(hass: HomeAssistant) -> None: """Set up preview WS API.""" - websocket_api.async_register_command(hass, ws_preview_sensor) - websocket_api.async_register_command(hass, ws_preview_binary_sensor) + for group_type, form_step in OPTIONS_FLOW.items(): + if group_type not in GROUP_TYPES: + continue + schema = cast( + Callable[ + [SchemaCommonFlowHandler | None], Coroutine[Any, Any, vol.Schema] + ], + form_step.schema, + ) + PREVIEW_OPTIONS_SCHEMA[group_type] = await schema(None) + websocket_api.async_register_command(hass, ws_start_preview) def _async_hide_members( @@ -282,127 +301,51 @@ def _async_hide_members( registry.async_update_entity(entity_id, hidden_by=hidden_by) +@websocket_api.websocket_command( + { + vol.Required("type"): "group/start_preview", + vol.Required("flow_id"): str, + vol.Required("flow_type"): vol.Any("config_flow", "options_flow"), + vol.Required("user_input"): dict, + } +) @callback -def _async_handle_ws_preview( +def ws_start_preview( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], - config_schema: vol.Schema, - options_schema: vol.Schema, - create_preview_entity: Callable[ - [Literal["config_flow", "options_flow"], str, dict[str, Any]], - BinarySensorGroup | SensorGroup, - ], ) -> None: """Generate a preview.""" if msg["flow_type"] == "config_flow": - validated = config_schema(msg["user_input"]) + flow_status = hass.config_entries.flow.async_get(msg["flow_id"]) + group_type = flow_status["step_id"] + form_step = cast(SchemaFlowFormStep, CONFIG_FLOW[group_type]) + schema = cast(vol.Schema, form_step.schema) + validated = schema(msg["user_input"]) name = validated["name"] else: - validated = options_schema(msg["user_input"]) flow_status = hass.config_entries.options.async_get(msg["flow_id"]) config_entry = hass.config_entries.async_get_entry(flow_status["handler"]) if not config_entry: raise HomeAssistantError + group_type = config_entry.options["group_type"] name = config_entry.options["name"] + validated = PREVIEW_OPTIONS_SCHEMA[group_type](msg["user_input"]) @callback def async_preview_updated(state: str, attributes: Mapping[str, Any]) -> None: """Forward config entry state events to websocket.""" connection.send_message( websocket_api.event_message( - msg["id"], {"state": state, "attributes": attributes} + msg["id"], + {"attributes": attributes, "group_type": group_type, "state": state}, ) ) - preview_entity = create_preview_entity(msg["flow_type"], name, validated) + preview_entity = CREATE_PREVIEW_ENTITY[group_type](name, validated) preview_entity.hass = hass connection.send_result(msg["id"]) connection.subscriptions[msg["id"]] = preview_entity.async_start_preview( async_preview_updated ) - - -@websocket_api.websocket_command( - { - vol.Required("type"): "group/binary_sensor/start_preview", - vol.Required("flow_id"): str, - vol.Required("flow_type"): vol.Any("config_flow", "options_flow"), - vol.Required("user_input"): dict, - } -) -@websocket_api.async_response -async def ws_preview_binary_sensor( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] -) -> None: - """Generate a preview.""" - - def create_preview_binary_sensor( - flow_type: Literal["config_flow", "options_flow"], - name: str, - validated_config: dict[str, Any], - ) -> BinarySensorGroup: - """Create a preview sensor.""" - return BinarySensorGroup( - None, - name, - None, - validated_config[CONF_ENTITIES], - validated_config[CONF_ALL], - ) - - _async_handle_ws_preview( - hass, - connection, - msg, - BINARY_SENSOR_CONFIG_SCHEMA, - await binary_sensor_options_schema(None), - create_preview_binary_sensor, - ) - - -@websocket_api.websocket_command( - { - vol.Required("type"): "group/sensor/start_preview", - vol.Required("flow_id"): str, - vol.Required("flow_type"): vol.Any("config_flow", "options_flow"), - vol.Required("user_input"): dict, - } -) -@websocket_api.async_response -async def ws_preview_sensor( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] -) -> None: - """Generate a preview.""" - - def create_preview_sensor( - flow_type: Literal["config_flow", "options_flow"], - name: str, - validated_config: dict[str, Any], - ) -> SensorGroup: - """Create a preview sensor.""" - ignore_non_numeric = ( - False - if flow_type == "config_flow" - else validated_config[CONF_IGNORE_NON_NUMERIC] - ) - return SensorGroup( - None, - name, - validated_config[CONF_ENTITIES], - ignore_non_numeric, - validated_config[CONF_TYPE], - None, - None, - None, - ) - - _async_handle_ws_preview( - hass, - connection, - msg, - SENSOR_CONFIG_SCHEMA, - await sensor_options_schema("sensor", None), - create_preview_sensor, - ) diff --git a/homeassistant/components/group/sensor.py b/homeassistant/components/group/sensor.py index 57ada314707..10030ab647f 100644 --- a/homeassistant/components/group/sensor.py +++ b/homeassistant/components/group/sensor.py @@ -136,6 +136,23 @@ async def async_setup_entry( ) +@callback +def async_create_preview_sensor( + name: str, validated_config: dict[str, Any] +) -> SensorGroup: + """Create a preview sensor.""" + return SensorGroup( + None, + name, + validated_config[CONF_ENTITIES], + validated_config.get(CONF_IGNORE_NON_NUMERIC, False), + validated_config[CONF_TYPE], + None, + None, + None, + ) + + def calc_min( sensor_values: list[tuple[str, float, State]] ) -> tuple[dict[str, str | None], float | None]: diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index d3ff741e3e6..78b54929015 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1864,7 +1864,7 @@ class OptionsFlowManager(data_entry_flow.FlowManager): await _load_integration(self.hass, entry.domain, {}) if entry.domain not in self._preview: self._preview.add(entry.domain) - flow.async_setup_preview(self.hass) + await flow.async_setup_preview(self.hass) class OptionsFlow(data_entry_flow.FlowHandler): diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 04876590d2b..467fc3b5228 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -439,7 +439,7 @@ class FlowManager(abc.ABC): """Set up preview for a flow handler.""" if flow.handler not in self._preview: self._preview.add(flow.handler) - flow.async_setup_preview(self.hass) + await flow.async_setup_preview(self.hass) class FlowHandler: @@ -649,9 +649,8 @@ class FlowHandler: def async_remove(self) -> None: """Notification that the flow has been removed.""" - @callback @staticmethod - def async_setup_preview(hass: HomeAssistant) -> None: + async def async_setup_preview(hass: HomeAssistant) -> None: """Set up preview.""" diff --git a/homeassistant/helpers/schema_config_entry_flow.py b/homeassistant/helpers/schema_config_entry_flow.py index e9d86f79eec..20a5d8de5a8 100644 --- a/homeassistant/helpers/schema_config_entry_flow.py +++ b/homeassistant/helpers/schema_config_entry_flow.py @@ -292,9 +292,8 @@ class SchemaConfigFlowHandler(config_entries.ConfigFlow, ABC): """Initialize config flow.""" self._common_handler = SchemaCommonFlowHandler(self, self.config_flow, None) - @callback @staticmethod - def async_setup_preview(hass: HomeAssistant) -> None: + async def async_setup_preview(hass: HomeAssistant) -> None: """Set up preview.""" @classmethod @@ -369,7 +368,8 @@ class SchemaOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): options_flow: Mapping[str, SchemaFlowStep], async_options_flow_finished: Callable[[HomeAssistant, Mapping[str, Any]], None] | None = None, - async_setup_preview: Callable[[HomeAssistant], None] | None = None, + async_setup_preview: Callable[[HomeAssistant], Coroutine[Any, Any, None]] + | None = None, ) -> None: """Initialize options flow. diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index ad084786366..a2845f098d3 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -490,11 +490,11 @@ async def test_config_flow_preview( assert result["type"] == FlowResultType.FORM assert result["step_id"] == domain assert result["errors"] is None - assert result["preview"] == f"group_{domain}" + assert result["preview"] == "group" await client.send_json_auto_id( { - "type": f"group/{domain}/start_preview", + "type": "group/start_preview", "flow_id": result["flow_id"], "flow_type": "config_flow", "user_input": {"name": "My group", "entities": input_entities} @@ -508,6 +508,7 @@ async def test_config_flow_preview( msg = await client.receive_json() assert msg["event"] == { "attributes": {"friendly_name": "My group"} | extra_attributes[0], + "group_type": domain, "state": "unavailable", } @@ -522,8 +523,10 @@ async def test_config_flow_preview( } | extra_attributes[0] | extra_attributes[1], + "group_type": domain, "state": group_state, } + assert len(hass.states.async_all()) == 2 @pytest.mark.parametrize( @@ -582,14 +585,14 @@ async def test_option_flow_preview( result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] == FlowResultType.FORM assert result["errors"] is None - assert result["preview"] == f"group_{domain}" + assert result["preview"] == "group" hass.states.async_set(input_entities[0], input_states[0]) hass.states.async_set(input_entities[1], input_states[1]) await client.send_json_auto_id( { - "type": f"group/{domain}/start_preview", + "type": "group/start_preview", "flow_id": result["flow_id"], "flow_type": "options_flow", "user_input": {"entities": input_entities} | extra_user_input, @@ -603,8 +606,10 @@ async def test_option_flow_preview( assert msg["event"] == { "attributes": {"entity_id": input_entities, "friendly_name": "My group"} | extra_attributes, + "group_type": domain, "state": group_state, } + assert len(hass.states.async_all()) == 3 async def test_option_flow_sensor_preview_config_entry_removed( @@ -635,13 +640,13 @@ async def test_option_flow_sensor_preview_config_entry_removed( result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] == FlowResultType.FORM assert result["errors"] is None - assert result["preview"] == "group_sensor" + assert result["preview"] == "group" await hass.config_entries.async_remove(config_entry.entry_id) await client.send_json_auto_id( { - "type": "group/sensor/start_preview", + "type": "group/start_preview", "flow_id": result["flow_id"], "flow_type": "options_flow", "user_input": { diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index f04f033b49f..680adcf1202 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -3962,9 +3962,8 @@ async def test_preview_supported( """Mock Reauth.""" return self.async_show_form(step_id="next", preview="test") - @callback @staticmethod - def async_setup_preview(hass: HomeAssistant) -> None: + async def async_setup_preview(hass: HomeAssistant) -> None: """Set up preview.""" preview_calls.append(None) From 9a0507af3c5b969a3a05cd2c4019ea31ec1e86ab Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 24 Aug 2023 12:01:22 +0200 Subject: [PATCH 040/124] Bump reolink-aio to 0.7.8 (#98942) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index f350bb4f948..3ff25d1e7a0 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.7.7"] + "requirements": ["reolink-aio==0.7.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 601be9507ac..89088840dd6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2288,7 +2288,7 @@ renault-api==0.2.0 renson-endura-delta==1.5.0 # homeassistant.components.reolink -reolink-aio==0.7.7 +reolink-aio==0.7.8 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 43ccd45c09c..212132857ba 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1681,7 +1681,7 @@ renault-api==0.2.0 renson-endura-delta==1.5.0 # homeassistant.components.reolink -reolink-aio==0.7.7 +reolink-aio==0.7.8 # homeassistant.components.rflink rflink==0.0.65 From 849cfa3af818867c5d377e9e1337fb7d977a1468 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Aug 2023 05:04:00 -0500 Subject: [PATCH 041/124] Retry yeelight setup later if the wrong device is found (#98884) --- homeassistant/components/yeelight/__init__.py | 14 +++++++++++ homeassistant/components/yeelight/device.py | 20 ++++++++++----- tests/components/yeelight/test_init.py | 25 +++++++++++++++++++ 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index c07852629a9..cc9faa33194 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -144,6 +144,7 @@ async def _async_initialize( entry: ConfigEntry, device: YeelightDevice, ) -> None: + """Initialize a Yeelight device.""" entry_data = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][entry.entry_id] = {} await device.async_setup() entry_data[DATA_DEVICE] = device @@ -216,6 +217,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except (asyncio.TimeoutError, OSError, BulbException) as ex: raise ConfigEntryNotReady from ex + found_unique_id = device.unique_id + expected_unique_id = entry.unique_id + if expected_unique_id and found_unique_id and found_unique_id != expected_unique_id: + # If the id of the device does not match the unique_id + # of the config entry, it likely means the DHCP lease has expired + # and the device has been assigned a new IP address. We need to + # wait for the next discovery to find the device at its new address + # and update the config entry so we do not mix up devices. + raise ConfigEntryNotReady( + f"Unexpected device found at {device.host}; " + f"expected {expected_unique_id}, found {found_unique_id}" + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # Wait to install the reload listener until everything was successfully initialized diff --git a/homeassistant/components/yeelight/device.py b/homeassistant/components/yeelight/device.py index 0fabe693aa9..811a1904b04 100644 --- a/homeassistant/components/yeelight/device.py +++ b/homeassistant/components/yeelight/device.py @@ -3,12 +3,13 @@ from __future__ import annotations import asyncio import logging +from typing import Any from yeelight import BulbException -from yeelight.aio import KEY_CONNECTED +from yeelight.aio import KEY_CONNECTED, AsyncBulb from homeassistant.const import CONF_ID, CONF_NAME -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later @@ -63,17 +64,19 @@ def update_needs_bg_power_workaround(data): class YeelightDevice: """Represents single Yeelight device.""" - def __init__(self, hass, host, config, bulb): + def __init__( + self, hass: HomeAssistant, host: str, config: dict[str, Any], bulb: AsyncBulb + ) -> None: """Initialize device.""" self._hass = hass self._config = config self._host = host self._bulb_device = bulb - self.capabilities = {} - self._device_type = None + self.capabilities: dict[str, Any] = {} + self._device_type: str | None = None self._available = True self._initialized = False - self._name = None + self._name: str | None = None @property def bulb(self): @@ -115,6 +118,11 @@ class YeelightDevice: """Return the firmware version.""" return self.capabilities.get("fw_ver") + @property + def unique_id(self) -> str | None: + """Return the unique ID of the device.""" + return self.capabilities.get("id") + @property def is_nightlight_supported(self) -> bool: """Return true / false if nightlight is supported. diff --git a/tests/components/yeelight/test_init.py b/tests/components/yeelight/test_init.py index 906dbf50ace..b439ce04c25 100644 --- a/tests/components/yeelight/test_init.py +++ b/tests/components/yeelight/test_init.py @@ -618,3 +618,28 @@ async def test_async_setup_with_discovery_not_working(hass: HomeAssistant) -> No assert config_entry.state is ConfigEntryState.LOADED assert hass.states.get("light.yeelight_color_0x15243f").state == STATE_ON + + +async def test_async_setup_retries_with_wrong_device( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test the config entry enters a retry state with the wrong device.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: IP_ADDRESS, CONF_ID: "0x0000000000999999"}, + options={}, + unique_id="0x0000000000999999", + ) + config_entry.add_to_hass(hass) + + with _patch_discovery(), _patch_discovery_timeout(), _patch_discovery_interval(), patch( + f"{MODULE}.AsyncBulb", return_value=_mocked_bulb() + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + assert ( + "Unexpected device found at 192.168.1.239; expected 0x0000000000999999, " + "found 0x000000000015243f; Retrying in background" + ) in caplog.text From b69e8fda7778a99cbee0ac5b4804f10881d01c4d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 24 Aug 2023 12:14:39 +0200 Subject: [PATCH 042/124] Remove `TemplateSensor` from the `template_entity` helper (#98945) Clean off TemplateSensor --- homeassistant/components/template/sensor.py | 8 ++++++-- homeassistant/helpers/template_entity.py | 21 --------------------- 2 files changed, 6 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index 7a5df84a207..aa6788109ff 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -14,6 +14,7 @@ from homeassistant.components.sensor import ( PLATFORM_SCHEMA, RestoreSensor, SensorDeviceClass, + SensorEntity, ) from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.const import ( @@ -39,7 +40,7 @@ from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template_entity import ( TEMPLATE_SENSOR_BASE_SCHEMA, - TemplateSensor, + TemplateEntity, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -196,7 +197,7 @@ async def async_setup_platform( ) -class SensorTemplate(TemplateSensor): +class SensorTemplate(TemplateEntity, SensorEntity): """Representation of a Template Sensor.""" _attr_should_poll = False @@ -209,6 +210,9 @@ class SensorTemplate(TemplateSensor): ) -> None: """Initialize the sensor.""" super().__init__(hass, config=config, fallback_name=None, unique_id=unique_id) + self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) + self._attr_device_class = config.get(CONF_DEVICE_CLASS) + self._attr_state_class = config.get(CONF_STATE_CLASS) self._template: template.Template = config[CONF_STATE] if (object_id := config.get(CONF_OBJECT_ID)) is not None: self.entity_id = async_generate_entity_id( diff --git a/homeassistant/helpers/template_entity.py b/homeassistant/helpers/template_entity.py index 70a0ee1d16c..16dc212e8cc 100644 --- a/homeassistant/helpers/template_entity.py +++ b/homeassistant/helpers/template_entity.py @@ -454,27 +454,6 @@ class TemplateEntity(Entity): ) -class TemplateSensor(TemplateEntity, SensorEntity): - """Representation of a Template Sensor.""" - - def __init__( - self, - hass: HomeAssistant, - *, - config: dict[str, Any], - fallback_name: str | None, - unique_id: str | None, - ) -> None: - """Initialize the sensor.""" - super().__init__( - hass, config=config, fallback_name=fallback_name, unique_id=unique_id - ) - - self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) - self._attr_device_class = config.get(CONF_DEVICE_CLASS) - self._attr_state_class = config.get(CONF_STATE_CLASS) - - class TriggerBaseEntity(Entity): """Template Base entity based on trigger data.""" From 87dd18cc2e249b45fe1838e37b7988c460dd986b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 24 Aug 2023 12:35:11 +0200 Subject: [PATCH 043/124] Remove obsolete yaml check in SQL (#98950) * Remove unique id check from SQL * Remove unique id check from SQL --- homeassistant/components/sql/sensor.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index 0b32b10f972..dffb45bfd93 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -321,13 +321,12 @@ class SQLSensor(ManualTriggerSensorEntity): self._attr_extra_state_attributes = {} self._use_database_executor = use_database_executor self._lambda_stmt = _generate_lambda_stmt(query) - if not yaml: + if not yaml and (unique_id := trigger_entity_config.get(CONF_UNIQUE_ID)): self._attr_name = None self._attr_has_entity_name = True - if not yaml and trigger_entity_config.get(CONF_UNIQUE_ID): self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, trigger_entity_config[CONF_UNIQUE_ID])}, + identifiers={(DOMAIN, unique_id)}, manufacturer="SQL", name=self.name, ) From 0d013767ee498bafcb8fbe5d8a6a59dc28603e52 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 24 Aug 2023 12:49:38 +0200 Subject: [PATCH 044/124] Add support for event groups (#98463) Co-authored-by: Martin Hjelmare --- homeassistant/components/group/config_flow.py | 6 + homeassistant/components/group/event.py | 180 ++++++++++++++++++ homeassistant/components/group/strings.json | 9 + tests/components/group/test_config_flow.py | 16 ++ tests/components/group/test_event.py | 138 ++++++++++++++ 5 files changed, 349 insertions(+) create mode 100644 homeassistant/components/group/event.py create mode 100644 tests/components/group/test_event.py diff --git a/homeassistant/components/group/config_flow.py b/homeassistant/components/group/config_flow.py index 1d820b516af..28a7330a206 100644 --- a/homeassistant/components/group/config_flow.py +++ b/homeassistant/components/group/config_flow.py @@ -137,6 +137,7 @@ async def light_switch_options_schema( GROUP_TYPES = [ "binary_sensor", "cover", + "event", "fan", "light", "lock", @@ -178,6 +179,10 @@ CONFIG_FLOW = { basic_group_config_schema("cover"), validate_user_input=set_group_type("cover"), ), + "event": SchemaFlowFormStep( + basic_group_config_schema("event"), + validate_user_input=set_group_type("event"), + ), "fan": SchemaFlowFormStep( basic_group_config_schema("fan"), validate_user_input=set_group_type("fan"), @@ -213,6 +218,7 @@ OPTIONS_FLOW = { preview="group", ), "cover": SchemaFlowFormStep(partial(basic_group_options_schema, "cover")), + "event": SchemaFlowFormStep(partial(basic_group_options_schema, "event")), "fan": SchemaFlowFormStep(partial(basic_group_options_schema, "fan")), "light": SchemaFlowFormStep(partial(light_switch_options_schema, "light")), "lock": SchemaFlowFormStep(partial(basic_group_options_schema, "lock")), diff --git a/homeassistant/components/group/event.py b/homeassistant/components/group/event.py new file mode 100644 index 00000000000..81705c7f6f0 --- /dev/null +++ b/homeassistant/components/group/event.py @@ -0,0 +1,180 @@ +"""Platform allowing several event entities to be grouped into one event.""" +from __future__ import annotations + +import itertools + +import voluptuous as vol + +from homeassistant.components.event import ( + ATTR_EVENT_TYPE, + ATTR_EVENT_TYPES, + DOMAIN, + PLATFORM_SCHEMA, + EventEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + CONF_ENTITIES, + CONF_NAME, + CONF_UNIQUE_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_change_event, +) +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType + +from . import GroupEntity + +DEFAULT_NAME = "Event group" + +# No limit on parallel updates to enable a group calling another group +PARALLEL_UPDATES = 0 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_ENTITIES): cv.entities_domain(DOMAIN), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UNIQUE_ID): cv.string, + } +) + + +async def async_setup_platform( + _: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + __: DiscoveryInfoType | None = None, +) -> None: + """Set up the event group platform.""" + async_add_entities( + [ + EventGroup( + config.get(CONF_UNIQUE_ID), + config[CONF_NAME], + config[CONF_ENTITIES], + ) + ] + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize event group config entry.""" + registry = er.async_get(hass) + entities = er.async_validate_entity_ids( + registry, config_entry.options[CONF_ENTITIES] + ) + async_add_entities( + [ + EventGroup( + config_entry.entry_id, + config_entry.title, + entities, + ) + ] + ) + + +class EventGroup(GroupEntity, EventEntity): + """Representation of an event group.""" + + _attr_available = False + _attr_should_poll = False + + def __init__( + self, + unique_id: str | None, + name: str, + entity_ids: list[str], + ) -> None: + """Initialize an event group.""" + self._entity_ids = entity_ids + self._attr_name = name + self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids} + self._attr_unique_id = unique_id + self._attr_event_types = [] + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + + @callback + def async_state_changed_listener( + event: EventType[EventStateChangedData], + ) -> None: + """Handle child updates.""" + if not self.hass.is_running: + return + + self.async_set_context(event.context) + + # Update all properties of the group + self.async_update_group_state() + + # Re-fire if one of the members fires an event, but only + # if the original state was not unavailable or unknown. + if ( + (old_state := event.data["old_state"]) + and old_state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) + and (new_state := event.data["new_state"]) + and new_state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) + and (event_type := new_state.attributes.get(ATTR_EVENT_TYPE)) + ): + event_attributes = new_state.attributes.copy() + + # We should not propagate the event properties as + # fired event attributes. + del event_attributes[ATTR_EVENT_TYPE] + del event_attributes[ATTR_EVENT_TYPES] + event_attributes.pop(ATTR_DEVICE_CLASS, None) + event_attributes.pop(ATTR_FRIENDLY_NAME, None) + + # Fire the group event + self._trigger_event(event_type, event_attributes) + + self.async_write_ha_state() + + self.async_on_remove( + async_track_state_change_event( + self.hass, self._entity_ids, async_state_changed_listener + ) + ) + + await super().async_added_to_hass() + + @callback + def async_update_group_state(self) -> None: + """Query all members and determine the event group properties.""" + states = [ + state + for entity_id in self._entity_ids + if (state := self.hass.states.get(entity_id)) is not None + ] + + # None of the members are available + if not states: + self._attr_available = False + return + + # Gather and combine all possible event types from all entities + self._attr_event_types = list( + set( + itertools.chain.from_iterable( + state.attributes.get(ATTR_EVENT_TYPES, []) for state in states + ) + ) + ) + + # Set group as unavailable if all members are unavailable or missing + self._attr_available = any(state.state != STATE_UNAVAILABLE for state in states) diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json index 1c656b46b9e..5f3042c5bf7 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -8,6 +8,7 @@ "menu_options": { "binary_sensor": "Binary sensor group", "cover": "Cover group", + "event": "Event group", "fan": "Fan group", "light": "Light group", "lock": "Lock group", @@ -34,6 +35,14 @@ "name": "[%key:component::group::config::step::binary_sensor::data::name%]" } }, + "event": { + "title": "[%key:component::group::config::step::user::title%]", + "data": { + "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", + "hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]", + "name": "[%key:component::group::config::step::binary_sensor::data::name%]" + } + }, "fan": { "title": "[%key:component::group::config::step::user::title%]", "data": { diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index a2845f098d3..b244b37e072 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -6,6 +6,7 @@ import pytest from homeassistant import config_entries from homeassistant.components.group import DOMAIN, async_setup_entry +from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import entity_registry as er @@ -28,6 +29,18 @@ from tests.typing import WebSocketGenerator ("binary_sensor", "on", "on", {}, {}, {"all": False}, {}), ("binary_sensor", "on", "on", {}, {"all": True}, {"all": True}, {}), ("cover", "open", "open", {}, {}, {}, {}), + ( + "event", + STATE_UNKNOWN, + "2021-01-01T23:59:59.123+00:00", + { + "event_type": "single_press", + "event_types": ["single_press", "double_press"], + }, + {}, + {}, + {}, + ), ("fan", "on", "on", {}, {}, {}, {}), ("light", "on", "on", {}, {}, {}, {}), ("lock", "locked", "locked", {}, {}, {}, {}), @@ -122,6 +135,7 @@ async def test_config_flow( ( ("binary_sensor", {"all": False}), ("cover", {}), + ("event", {}), ("fan", {}), ("light", {}), ("lock", {}), @@ -194,6 +208,7 @@ def get_suggested(schema, key): ( ("binary_sensor", "on", {"all": False}, {}), ("cover", "open", {}, {}), + ("event", "2021-01-01T23:59:59.123+00:00", {}, {}), ("fan", "on", {}, {}), ("light", "on", {"all": False}, {}), ("lock", "locked", {}, {}), @@ -377,6 +392,7 @@ async def test_all_options( ( ("binary_sensor", {"all": False}), ("cover", {}), + ("event", {}), ("fan", {}), ("light", {}), ("lock", {}), diff --git a/tests/components/group/test_event.py b/tests/components/group/test_event.py new file mode 100644 index 00000000000..16ea11fe311 --- /dev/null +++ b/tests/components/group/test_event.py @@ -0,0 +1,138 @@ +"""The tests for the group event platform.""" + +from pytest_unordered import unordered + +from homeassistant.components.event import DOMAIN as EVENT_DOMAIN +from homeassistant.components.event.const import ATTR_EVENT_TYPE, ATTR_EVENT_TYPES +from homeassistant.components.group import DOMAIN +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ENTITY_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + + +async def test_default_state(hass: HomeAssistant) -> None: + """Test event group default state.""" + await async_setup_component( + hass, + EVENT_DOMAIN, + { + EVENT_DOMAIN: { + "platform": DOMAIN, + "entities": ["event.button_1", "event.button_2"], + "name": "Remote control", + "unique_id": "unique_identifier", + } + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get("event.remote_control") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + hass.states.async_set( + "event.button_1", + "2021-01-01T23:59:59.123+00:00", + {"event_type": "double_press", "event_types": ["single_press", "double_press"]}, + ) + await hass.async_block_till_done() + + state = hass.states.get("event.remote_control") + assert state is not None + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_ENTITY_ID) == ["event.button_1", "event.button_2"] + assert not state.attributes.get(ATTR_EVENT_TYPE) + assert state.attributes.get(ATTR_EVENT_TYPES) == unordered( + ["single_press", "double_press"] + ) + + # State changed + hass.states.async_set( + "event.button_1", + "2021-01-01T23:59:59.123+00:00", + {"event_type": "single_press", "event_types": ["single_press", "double_press"]}, + ) + await hass.async_block_till_done() + + state = hass.states.get("event.remote_control") + assert state is not None + assert state.state + assert state.attributes.get(ATTR_ENTITY_ID) == ["event.button_1", "event.button_2"] + assert state.attributes.get(ATTR_EVENT_TYPE) == "single_press" + assert state.attributes.get(ATTR_EVENT_TYPES) == unordered( + ["single_press", "double_press"] + ) + + # State changed, second remote came online + hass.states.async_set( + "event.button_2", + "2021-01-01T23:59:59.123+00:00", + {"event_type": "double_press", "event_types": ["double_press", "triple_press"]}, + ) + await hass.async_block_till_done() + + # State should be single_press, because button coming online is not an event + state = hass.states.get("event.remote_control") + assert state is not None + assert state.state + assert state.attributes.get(ATTR_ENTITY_ID) == ["event.button_1", "event.button_2"] + assert state.attributes.get(ATTR_EVENT_TYPE) == "single_press" + assert state.attributes.get(ATTR_EVENT_TYPES) == unordered( + ["single_press", "double_press", "triple_press"] + ) + + # State changed, now it fires an event + hass.states.async_set( + "event.button_2", + "2021-01-01T23:59:59.123+00:00", + { + "event_type": "triple_press", + "event_types": ["double_press", "triple_press"], + "device_class": "doorbell", + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("event.remote_control") + assert state is not None + assert state.state + assert state.attributes.get(ATTR_ENTITY_ID) == ["event.button_1", "event.button_2"] + assert state.attributes.get(ATTR_EVENT_TYPE) == "triple_press" + assert state.attributes.get(ATTR_EVENT_TYPES) == unordered( + ["single_press", "double_press", "triple_press"] + ) + assert ATTR_DEVICE_CLASS not in state.attributes + + # Mark button 1 unavailable + hass.states.async_set("event.button_1", STATE_UNAVAILABLE) + await hass.async_block_till_done() + + state = hass.states.get("event.remote_control") + assert state is not None + assert state.state + assert state.attributes.get(ATTR_ENTITY_ID) == ["event.button_1", "event.button_2"] + assert state.attributes.get(ATTR_EVENT_TYPE) == "triple_press" + assert state.attributes.get(ATTR_EVENT_TYPES) == unordered( + ["double_press", "triple_press"] + ) + + # Mark button 2 unavailable + hass.states.async_set("event.button_2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + + state = hass.states.get("event.remote_control") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + entity_registry = er.async_get(hass) + entry = entity_registry.async_get("event.remote_control") + assert entry + assert entry.unique_id == "unique_identifier" From b145352bbb59d2ffaec5c020231191841a5e2dcf Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 24 Aug 2023 13:44:43 +0200 Subject: [PATCH 045/124] Modernize meteo_france weather (#98022) * Modernize meteofrance weather * Remove options flow * Remove unused constant * Format code --------- Co-authored-by: Quentin POLLET --- .../components/meteo_france/config_flow.py | 39 ++------------ .../components/meteo_france/const.py | 1 - .../components/meteo_france/strings.json | 9 ---- .../components/meteo_france/weather.py | 52 ++++++++++++++----- .../meteo_france/test_config_flow.py | 42 +-------------- 5 files changed, 45 insertions(+), 98 deletions(-) diff --git a/homeassistant/components/meteo_france/config_flow.py b/homeassistant/components/meteo_france/config_flow.py index d05c63ef684..ade6bedd362 100644 --- a/homeassistant/components/meteo_france/config_flow.py +++ b/homeassistant/components/meteo_france/config_flow.py @@ -7,11 +7,11 @@ from meteofrance_api.client import MeteoFranceClient import voluptuous as vol from homeassistant import config_entries -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import callback -from .const import CONF_CITY, DOMAIN, FORECAST_MODE, FORECAST_MODE_DAILY +from .const import CONF_CITY, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -25,14 +25,6 @@ class MeteoFranceFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Init MeteoFranceFlowHandler.""" self.places = [] - @staticmethod - @callback - def async_get_options_flow( - config_entry: ConfigEntry, - ) -> MeteoFranceOptionsFlowHandler: - """Get the options flow for this handler.""" - return MeteoFranceOptionsFlowHandler(config_entry) - @callback def _show_setup_form(self, user_input=None, errors=None): """Show the setup form to the user.""" @@ -114,30 +106,5 @@ class MeteoFranceFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) -class MeteoFranceOptionsFlowHandler(config_entries.OptionsFlow): - """Handle a option flow.""" - - def __init__(self, config_entry: config_entries.ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - - async def async_step_init(self, user_input=None): - """Handle options flow.""" - if user_input is not None: - return self.async_create_entry(title="", data=user_input) - - data_schema = vol.Schema( - { - vol.Optional( - CONF_MODE, - default=self.config_entry.options.get( - CONF_MODE, FORECAST_MODE_DAILY - ), - ): vol.In(FORECAST_MODE) - } - ) - return self.async_show_form(step_id="init", data_schema=data_schema) - - def _build_place_key(place) -> str: return f"{place};{place.latitude};{place.longitude}" diff --git a/homeassistant/components/meteo_france/const.py b/homeassistant/components/meteo_france/const.py index f1e6ae8d0eb..e950dfe1fa8 100644 --- a/homeassistant/components/meteo_france/const.py +++ b/homeassistant/components/meteo_france/const.py @@ -33,7 +33,6 @@ MANUFACTURER = "Météo-France" CONF_CITY = "city" FORECAST_MODE_HOURLY = "hourly" FORECAST_MODE_DAILY = "daily" -FORECAST_MODE = [FORECAST_MODE_HOURLY, FORECAST_MODE_DAILY] ATTR_NEXT_RAIN_1_HOUR_FORECAST = "1_hour_forecast" ATTR_NEXT_RAIN_DT_REF = "forecast_time_ref" diff --git a/homeassistant/components/meteo_france/strings.json b/homeassistant/components/meteo_france/strings.json index 944f2b32fab..7cb7d3efe53 100644 --- a/homeassistant/components/meteo_france/strings.json +++ b/homeassistant/components/meteo_france/strings.json @@ -21,14 +21,5 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_location%]", "unknown": "[%key:common::config_flow::error::unknown%]" } - }, - "options": { - "step": { - "init": { - "data": { - "mode": "Forecast mode" - } - } - } } } diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index 6459827b601..d081a6e729b 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -2,7 +2,7 @@ import logging import time -from meteofrance_api.model.forecast import Forecast +from meteofrance_api.model.forecast import Forecast as MeteoFranceForecast from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, @@ -13,7 +13,9 @@ from homeassistant.components.weather import ( ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, + Forecast, WeatherEntity, + WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -23,7 +25,7 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( @@ -55,9 +57,9 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Meteo-France weather platform.""" - coordinator: DataUpdateCoordinator[Forecast] = hass.data[DOMAIN][entry.entry_id][ - COORDINATOR_FORECAST - ] + coordinator: DataUpdateCoordinator[MeteoFranceForecast] = hass.data[DOMAIN][ + entry.entry_id + ][COORDINATOR_FORECAST] async_add_entities( [ @@ -76,7 +78,7 @@ async def async_setup_entry( class MeteoFranceWeather( - CoordinatorEntity[DataUpdateCoordinator[Forecast]], WeatherEntity + CoordinatorEntity[DataUpdateCoordinator[MeteoFranceForecast]], WeatherEntity ): """Representation of a weather condition.""" @@ -85,14 +87,28 @@ class MeteoFranceWeather( _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS _attr_native_pressure_unit = UnitOfPressure.HPA _attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND + _attr_supported_features = ( + WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY + ) - def __init__(self, coordinator: DataUpdateCoordinator[Forecast], mode: str) -> None: + def __init__( + self, coordinator: DataUpdateCoordinator[MeteoFranceForecast], mode: str + ) -> None: """Initialise the platform with a data instance and station name.""" super().__init__(coordinator) self._city_name = self.coordinator.data.position["name"] self._mode = mode self._unique_id = f"{self.coordinator.data.position['lat']},{self.coordinator.data.position['lon']}" + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + super()._handle_coordinator_update() + assert self.platform.config_entry + self.platform.config_entry.async_create_task( + self.hass, self.async_update_listeners(("daily", "hourly")) + ) + @property def unique_id(self): """Return the unique id of the sensor.""" @@ -149,12 +165,11 @@ class MeteoFranceWeather( if wind_bearing != -1: return wind_bearing - @property - def forecast(self): + def _forecast(self, mode: str) -> list[Forecast]: """Return the forecast.""" - forecast_data = [] + forecast_data: list[Forecast] = [] - if self._mode == FORECAST_MODE_HOURLY: + if mode == FORECAST_MODE_HOURLY: today = time.time() for forecast in self.coordinator.data.forecast: # Can have data in the past @@ -186,7 +201,7 @@ class MeteoFranceWeather( { ATTR_FORECAST_TIME: self.coordinator.data.timestamp_to_locale_time( forecast["dt"] - ), + ).isoformat(), ATTR_FORECAST_CONDITION: format_condition( forecast["weather12H"]["desc"] ), @@ -199,3 +214,16 @@ class MeteoFranceWeather( } ) return forecast_data + + @property + def forecast(self) -> list[Forecast]: + """Return the forecast array.""" + return self._forecast(self._mode) + + async def async_forecast_daily(self) -> list[Forecast]: + """Return the daily forecast in native units.""" + return self._forecast(FORECAST_MODE_DAILY) + + async def async_forecast_hourly(self) -> list[Forecast]: + """Return the hourly forecast in native units.""" + return self._forecast(FORECAST_MODE_HOURLY) diff --git a/tests/components/meteo_france/test_config_flow.py b/tests/components/meteo_france/test_config_flow.py index e405d74ad53..80155d3311a 100644 --- a/tests/components/meteo_france/test_config_flow.py +++ b/tests/components/meteo_france/test_config_flow.py @@ -5,14 +5,9 @@ from meteofrance_api.model import Place import pytest from homeassistant import data_entry_flow -from homeassistant.components.meteo_france.const import ( - CONF_CITY, - DOMAIN, - FORECAST_MODE_DAILY, - FORECAST_MODE_HOURLY, -) +from homeassistant.components.meteo_france.const import CONF_CITY, DOMAIN from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -212,36 +207,3 @@ async def test_abort_if_already_setup(hass: HomeAssistant, client_single) -> Non ) assert result["type"] == data_entry_flow.FlowResultType.ABORT assert result["reason"] == "already_configured" - - -async def test_options_flow(hass: HomeAssistant) -> None: - """Test config flow options.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_LATITUDE: CITY_1_LAT, CONF_LONGITUDE: CITY_1_LON}, - unique_id=f"{CITY_1_LAT}, {CITY_1_LON}", - ) - config_entry.add_to_hass(hass) - - assert config_entry.options == {} - - result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "init" - - # Default - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={}, - ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert config_entry.options[CONF_MODE] == FORECAST_MODE_DAILY - - # Manual - result = await hass.config_entries.options.async_init(config_entry.entry_id) - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={CONF_MODE: FORECAST_MODE_HOURLY}, - ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert config_entry.options[CONF_MODE] == FORECAST_MODE_HOURLY From 99e97782b6b742067ef8834e413c4d51124ba2ec Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Aug 2023 08:34:45 -0500 Subject: [PATCH 046/124] Improve performance of abort_entries_match (#98932) * Improve performance of abort_entries_match In #90406 a ChainMap was added which called __iter__ and __contains__ which ends up creating temp dicts for matching https://github.com/python/cpython/blob/174e9da0836844a2138cc8915dd305cb2cd7a583/Lib/collections/__init__.py#L1022 We can avoid this by removing the ChainMap since there are only two mappings to match on. This also means options no longer obscures data * adjust comment --- homeassistant/config_entries.py | 15 ++++++--------- tests/test_config_entries.py | 12 ++++++++---- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 78b54929015..a3b03407a14 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio -from collections import ChainMap from collections.abc import Callable, Coroutine, Generator, Iterable, Mapping from contextvars import ContextVar from copy import deepcopy @@ -1465,14 +1464,12 @@ def _async_abort_entries_match( if match_dict is None: match_dict = {} # Match any entry for entry in other_entries: - if all( - item - in ChainMap( - entry.options, # type: ignore[arg-type] - entry.data, # type: ignore[arg-type] - ).items() - for item in match_dict.items() - ): + options_items = entry.options.items() + data_items = entry.data.items() + for kv in match_dict.items(): + if kv not in options_items and kv not in data_items: + break + else: raise data_entry_flow.AbortFlow("already_configured") diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 680adcf1202..75b6377973b 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -3379,11 +3379,13 @@ async def test_setup_retrying_during_shutdown(hass: HomeAssistant) -> None: ({"vendor": "zoo"}, "already_configured"), ({"ip": "9.9.9.9"}, "already_configured"), ({"ip": "7.7.7.7"}, "no_match"), # ignored - ({"vendor": "data"}, "no_match"), + # The next two data sets ensure options or data match + # as options previously shadowed data when matching. + ({"vendor": "data"}, "already_configured"), ( {"vendor": "options"}, "already_configured", - ), # ensure options takes precedence over data + ), ], ) async def test__async_abort_entries_match( @@ -3460,11 +3462,13 @@ async def test__async_abort_entries_match( ({"vendor": "zoo"}, "already_configured"), ({"ip": "9.9.9.9"}, "already_configured"), ({"ip": "7.7.7.7"}, "no_match"), # ignored - ({"vendor": "data"}, "no_match"), + # The next two data sets ensure options or data match + # as options previously shadowed data when matching. + ({"vendor": "data"}, "already_configured"), ( {"vendor": "options"}, "already_configured", - ), # ensure options takes precedence over data + ), ], ) async def test__async_abort_entries_match_options_flow( From 61c17291fb4674f4d5091b9f6cec5f87987be82e Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 24 Aug 2023 15:37:04 +0200 Subject: [PATCH 047/124] Move TemplateEntity to template (#98957) * Move TemplateEntity to template * Rename template_entity in helpers --- .../components/command_line/binary_sensor.py | 2 +- .../components/command_line/cover.py | 2 +- .../components/command_line/sensor.py | 2 +- .../components/command_line/switch.py | 2 +- .../components/rest/binary_sensor.py | 2 +- homeassistant/components/rest/schema.py | 2 +- homeassistant/components/rest/sensor.py | 2 +- homeassistant/components/rest/switch.py | 2 +- homeassistant/components/scrape/__init__.py | 2 +- homeassistant/components/scrape/sensor.py | 2 +- homeassistant/components/snmp/sensor.py | 2 +- homeassistant/components/sql/__init__.py | 5 +- homeassistant/components/sql/sensor.py | 2 +- homeassistant/components/template/sensor.py | 6 +- .../components/template/template_entity.py | 390 ++++++++++- .../components/template/trigger_entity.py | 2 +- homeassistant/helpers/template_entity.py | 648 ------------------ .../helpers/trigger_template_entity.py | 267 ++++++++ tests/components/rest/test_switch.py | 2 +- tests/components/scrape/test_sensor.py | 5 +- tests/components/sql/__init__.py | 5 +- .../template/test_manual_trigger_entity.py | 2 +- 22 files changed, 683 insertions(+), 673 deletions(-) delete mode 100644 homeassistant/helpers/template_entity.py create mode 100644 homeassistant/helpers/trigger_template_entity.py diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py index f2097178a95..1d6ee9046e8 100644 --- a/homeassistant/components/command_line/binary_sensor.py +++ b/homeassistant/components/command_line/binary_sensor.py @@ -29,7 +29,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template -from homeassistant.helpers.template_entity import ManualTriggerEntity +from homeassistant.helpers.trigger_template_entity import ManualTriggerEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py index 553af2f0c86..2aa67cec641 100644 --- a/homeassistant/components/command_line/cover.py +++ b/homeassistant/components/command_line/cover.py @@ -30,7 +30,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template -from homeassistant.helpers.template_entity import ManualTriggerEntity +from homeassistant.helpers.trigger_template_entity import ManualTriggerEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util, slugify diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index f04320b159e..a617d348c8d 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -35,7 +35,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template -from homeassistant.helpers.template_entity import ( +from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, CONF_PICTURE, ManualTriggerSensorEntity, diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py index 8fbafd7a4d1..004a65643bb 100644 --- a/homeassistant/components/command_line/switch.py +++ b/homeassistant/components/command_line/switch.py @@ -32,7 +32,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template -from homeassistant.helpers.template_entity import ManualTriggerEntity +from homeassistant.helpers.trigger_template_entity import ManualTriggerEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util, slugify diff --git a/homeassistant/components/rest/binary_sensor.py b/homeassistant/components/rest/binary_sensor.py index 7ab632995ea..8c629e2240e 100644 --- a/homeassistant/components/rest/binary_sensor.py +++ b/homeassistant/components/rest/binary_sensor.py @@ -26,7 +26,7 @@ from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template -from homeassistant.helpers.template_entity import ( +from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, CONF_PICTURE, ManualTriggerEntity, diff --git a/homeassistant/components/rest/schema.py b/homeassistant/components/rest/schema.py index 2f447b1c08c..d6011a43efd 100644 --- a/homeassistant/components/rest/schema.py +++ b/homeassistant/components/rest/schema.py @@ -27,7 +27,7 @@ from homeassistant.const import ( HTTP_DIGEST_AUTHENTICATION, ) import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.template_entity import ( +from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, TEMPLATE_ENTITY_BASE_SCHEMA, TEMPLATE_SENSOR_BASE_SCHEMA, diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index 63a9d6f210c..67f70a716b0 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -30,7 +30,7 @@ from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template -from homeassistant.helpers.template_entity import ( +from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, CONF_PICTURE, ManualTriggerSensorEntity, diff --git a/homeassistant/components/rest/switch.py b/homeassistant/components/rest/switch.py index 22570c3a245..102bb024924 100644 --- a/homeassistant/components/rest/switch.py +++ b/homeassistant/components/rest/switch.py @@ -33,7 +33,7 @@ from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.httpx_client import get_async_client -from homeassistant.helpers.template_entity import ( +from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, CONF_PICTURE, TEMPLATE_ENTITY_BASE_SCHEMA, diff --git a/homeassistant/components/scrape/__init__.py b/homeassistant/components/scrape/__init__.py index bf2ccb16b03..bdfa3fd9c5a 100644 --- a/homeassistant/components/scrape/__init__.py +++ b/homeassistant/components/scrape/__init__.py @@ -20,7 +20,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.template_entity import ( +from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, TEMPLATE_SENSOR_BASE_SCHEMA, ) diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index 2763d034804..77131ccb225 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -23,7 +23,7 @@ from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template -from homeassistant.helpers.template_entity import ( +from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, CONF_PICTURE, TEMPLATE_SENSOR_BASE_SCHEMA, diff --git a/homeassistant/components/snmp/sensor.py b/homeassistant/components/snmp/sensor.py index 85c69ddf76b..a5915183ad0 100644 --- a/homeassistant/components/snmp/sensor.py +++ b/homeassistant/components/snmp/sensor.py @@ -36,7 +36,7 @@ from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template -from homeassistant.helpers.template_entity import ( +from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, CONF_PICTURE, TEMPLATE_SENSOR_BASE_SCHEMA, diff --git a/homeassistant/components/sql/__init__.py b/homeassistant/components/sql/__init__.py index 316e816fd6f..4658e19932c 100644 --- a/homeassistant/components/sql/__init__.py +++ b/homeassistant/components/sql/__init__.py @@ -24,7 +24,10 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.template_entity import CONF_AVAILABILITY, CONF_PICTURE +from homeassistant.helpers.trigger_template_entity import ( + CONF_AVAILABILITY, + CONF_PICTURE, +) from homeassistant.helpers.typing import ConfigType from .const import CONF_COLUMN_NAME, CONF_QUERY, DOMAIN, PLATFORMS diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index dffb45bfd93..f4f44d4f9a4 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -36,7 +36,7 @@ from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template -from homeassistant.helpers.template_entity import ( +from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, CONF_PICTURE, ManualTriggerSensorEntity, diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index aa6788109ff..36e54eaabc9 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -38,10 +38,7 @@ from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.template_entity import ( - TEMPLATE_SENSOR_BASE_SCHEMA, - TemplateEntity, -) +from homeassistant.helpers.trigger_template_entity import TEMPLATE_SENSOR_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( @@ -52,6 +49,7 @@ from .const import ( ) from .template_entity import ( TEMPLATE_ENTITY_COMMON_SCHEMA, + TemplateEntity, rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index fe1a53e6510..64112b0d3d4 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -1,7 +1,10 @@ """TemplateEntity utility class.""" from __future__ import annotations +from collections.abc import Callable +import contextlib import itertools +import logging from typing import Any import voluptuous as vol @@ -12,14 +15,30 @@ from homeassistant.const import ( CONF_ICON, CONF_ICON_TEMPLATE, CONF_NAME, + EVENT_HOMEASSISTANT_START, + STATE_UNKNOWN, ) +from homeassistant.core import Context, CoreState, HomeAssistant, State, callback +from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.template import Template -from homeassistant.helpers.template_entity import ( # noqa: F401 +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import ( + EventStateChangedData, + TrackTemplate, + TrackTemplateResult, + async_track_template_result, +) +from homeassistant.helpers.script import Script, _VarsType +from homeassistant.helpers.template import ( + Template, + TemplateStateFromEntityId, + result_as_boolean, +) +from homeassistant.helpers.trigger_template_entity import ( TEMPLATE_ENTITY_BASE_SCHEMA, - TemplateEntity, make_template_entity_base_schema, ) +from homeassistant.helpers.typing import ConfigType, EventType from .const import ( CONF_ATTRIBUTE_TEMPLATES, @@ -29,6 +48,8 @@ from .const import ( CONF_PICTURE, ) +_LOGGER = logging.getLogger(__name__) + TEMPLATE_ENTITY_AVAILABILITY_SCHEMA = vol.Schema( { vol.Optional(CONF_AVAILABILITY): cv.template, @@ -113,3 +134,366 @@ def rewrite_common_legacy_to_modern_conf( entity_cfg[CONF_NAME] = Template(entity_cfg[CONF_NAME]) return entity_cfg + + +class _TemplateAttribute: + """Attribute value linked to template result.""" + + def __init__( + self, + entity: Entity, + attribute: str, + template: Template, + validator: Callable[[Any], Any] | None = None, + on_update: Callable[[Any], None] | None = None, + none_on_template_error: bool | None = False, + ) -> None: + """Template attribute.""" + self._entity = entity + self._attribute = attribute + self.template = template + self.validator = validator + self.on_update = on_update + self.async_update = None + self.none_on_template_error = none_on_template_error + + @callback + def async_setup(self) -> None: + """Config update path for the attribute.""" + if self.on_update: + return + + if not hasattr(self._entity, self._attribute): + raise AttributeError(f"Attribute '{self._attribute}' does not exist.") + + self.on_update = self._default_update + + @callback + def _default_update(self, result: str | TemplateError) -> None: + attr_result = None if isinstance(result, TemplateError) else result + setattr(self._entity, self._attribute, attr_result) + + @callback + def handle_result( + self, + event: EventType[EventStateChangedData] | None, + template: Template, + last_result: str | None | TemplateError, + result: str | TemplateError, + ) -> None: + """Handle a template result event callback.""" + if isinstance(result, TemplateError): + _LOGGER.error( + ( + "TemplateError('%s') " + "while processing template '%s' " + "for attribute '%s' in entity '%s'" + ), + result, + self.template, + self._attribute, + self._entity.entity_id, + ) + if self.none_on_template_error: + self._default_update(result) + else: + assert self.on_update + self.on_update(result) + return + + if not self.validator: + assert self.on_update + self.on_update(result) + return + + try: + validated = self.validator(result) + except vol.Invalid as ex: + _LOGGER.error( + ( + "Error validating template result '%s' " + "from template '%s' " + "for attribute '%s' in entity %s " + "validation message '%s'" + ), + result, + self.template, + self._attribute, + self._entity.entity_id, + ex.msg, + ) + assert self.on_update + self.on_update(None) + return + + assert self.on_update + self.on_update(validated) + return + + +class TemplateEntity(Entity): + """Entity that uses templates to calculate attributes.""" + + _attr_available = True + _attr_entity_picture = None + _attr_icon = None + + def __init__( + self, + hass: HomeAssistant, + *, + availability_template: Template | None = None, + icon_template: Template | None = None, + entity_picture_template: Template | None = None, + attribute_templates: dict[str, Template] | None = None, + config: ConfigType | None = None, + fallback_name: str | None = None, + unique_id: str | None = None, + ) -> None: + """Template Entity.""" + self._template_attrs: dict[Template, list[_TemplateAttribute]] = {} + self._async_update: Callable[[], None] | None = None + self._attr_extra_state_attributes = {} + self._self_ref_update_count = 0 + self._attr_unique_id = unique_id + if config is None: + self._attribute_templates = attribute_templates + self._availability_template = availability_template + self._icon_template = icon_template + self._entity_picture_template = entity_picture_template + self._friendly_name_template = None + else: + self._attribute_templates = config.get(CONF_ATTRIBUTES) + self._availability_template = config.get(CONF_AVAILABILITY) + self._icon_template = config.get(CONF_ICON) + self._entity_picture_template = config.get(CONF_PICTURE) + self._friendly_name_template = config.get(CONF_NAME) + + class DummyState(State): + """None-state for template entities not yet added to the state machine.""" + + def __init__(self) -> None: + """Initialize a new state.""" + super().__init__("unknown.unknown", STATE_UNKNOWN) + self.entity_id = None # type: ignore[assignment] + + @property + def name(self) -> str: + """Name of this state.""" + return "" + + variables = {"this": DummyState()} + + # Try to render the name as it can influence the entity ID + self._attr_name = fallback_name + if self._friendly_name_template: + self._friendly_name_template.hass = hass + with contextlib.suppress(TemplateError): + self._attr_name = self._friendly_name_template.async_render( + variables=variables, parse_result=False + ) + + # Templates will not render while the entity is unavailable, try to render the + # icon and picture templates. + if self._entity_picture_template: + self._entity_picture_template.hass = hass + with contextlib.suppress(TemplateError): + self._attr_entity_picture = self._entity_picture_template.async_render( + variables=variables, parse_result=False + ) + + if self._icon_template: + self._icon_template.hass = hass + with contextlib.suppress(TemplateError): + self._attr_icon = self._icon_template.async_render( + variables=variables, parse_result=False + ) + + @callback + def _update_available(self, result: str | TemplateError) -> None: + if isinstance(result, TemplateError): + self._attr_available = True + return + + self._attr_available = result_as_boolean(result) + + @callback + def _update_state(self, result: str | TemplateError) -> None: + if self._availability_template: + return + + self._attr_available = not isinstance(result, TemplateError) + + @callback + def _add_attribute_template( + self, attribute_key: str, attribute_template: Template + ) -> None: + """Create a template tracker for the attribute.""" + + def _update_attribute(result: str | TemplateError) -> None: + attr_result = None if isinstance(result, TemplateError) else result + self._attr_extra_state_attributes[attribute_key] = attr_result + + self.add_template_attribute( + attribute_key, attribute_template, None, _update_attribute + ) + + def add_template_attribute( + self, + attribute: str, + template: Template, + validator: Callable[[Any], Any] | None = None, + on_update: Callable[[Any], None] | None = None, + none_on_template_error: bool = False, + ) -> None: + """Call in the constructor to add a template linked to a attribute. + + Parameters + ---------- + attribute + The name of the attribute to link to. This attribute must exist + unless a custom on_update method is supplied. + template + The template to calculate. + validator + Validator function to parse the result and ensure it's valid. + on_update + Called to store the template result rather than storing it + the supplied attribute. Passed the result of the validator, or None + if the template or validator resulted in an error. + none_on_template_error + If True, the attribute will be set to None if the template errors. + + """ + assert self.hass is not None, "hass cannot be None" + template.hass = self.hass + template_attribute = _TemplateAttribute( + self, attribute, template, validator, on_update, none_on_template_error + ) + self._template_attrs.setdefault(template, []) + self._template_attrs[template].append(template_attribute) + + @callback + def _handle_results( + self, + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: + """Call back the results to the attributes.""" + if event: + self.async_set_context(event.context) + + entity_id = event and event.data["entity_id"] + + if entity_id and entity_id == self.entity_id: + self._self_ref_update_count += 1 + else: + self._self_ref_update_count = 0 + + if self._self_ref_update_count > len(self._template_attrs): + for update in updates: + _LOGGER.warning( + ( + "Template loop detected while processing event: %s, skipping" + " template render for Template[%s]" + ), + event, + update.template.template, + ) + return + + for update in updates: + for attr in self._template_attrs[update.template]: + attr.handle_result( + event, update.template, update.last_result, update.result + ) + + self.async_write_ha_state() + + async def _async_template_startup(self, *_: Any) -> None: + template_var_tups: list[TrackTemplate] = [] + has_availability_template = False + + variables = {"this": TemplateStateFromEntityId(self.hass, self.entity_id)} + + for template, attributes in self._template_attrs.items(): + template_var_tup = TrackTemplate(template, variables) + is_availability_template = False + for attribute in attributes: + # pylint: disable-next=protected-access + if attribute._attribute == "_attr_available": + has_availability_template = True + is_availability_template = True + attribute.async_setup() + # Insert the availability template first in the list + if is_availability_template: + template_var_tups.insert(0, template_var_tup) + else: + template_var_tups.append(template_var_tup) + + result_info = async_track_template_result( + self.hass, + template_var_tups, + self._handle_results, + has_super_template=has_availability_template, + ) + self.async_on_remove(result_info.async_remove) + self._async_update = result_info.async_refresh + result_info.async_refresh() + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + if self._availability_template is not None: + self.add_template_attribute( + "_attr_available", + self._availability_template, + None, + self._update_available, + ) + if self._attribute_templates is not None: + for key, value in self._attribute_templates.items(): + self._add_attribute_template(key, value) + if self._icon_template is not None: + self.add_template_attribute( + "_attr_icon", self._icon_template, vol.Or(cv.whitespace, cv.icon) + ) + if self._entity_picture_template is not None: + self.add_template_attribute( + "_attr_entity_picture", self._entity_picture_template + ) + if ( + self._friendly_name_template is not None + and not self._friendly_name_template.is_static + ): + self.add_template_attribute("_attr_name", self._friendly_name_template) + + if self.hass.state == CoreState.running: + await self._async_template_startup() + return + + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, self._async_template_startup + ) + + async def async_update(self) -> None: + """Call for forced update.""" + assert self._async_update + self._async_update() + + async def async_run_script( + self, + script: Script, + *, + run_variables: _VarsType | None = None, + context: Context | None = None, + ) -> None: + """Run an action script.""" + if run_variables is None: + run_variables = {} + await script.async_run( + run_variables={ + "this": TemplateStateFromEntityId(self.hass, self.entity_id), + **run_variables, + }, + context=context, + ) diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index 7d1a844fb3d..ca2f7240086 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -2,7 +2,7 @@ from __future__ import annotations from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.template_entity import TriggerBaseEntity +from homeassistant.helpers.trigger_template_entity import TriggerBaseEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import TriggerUpdateCoordinator diff --git a/homeassistant/helpers/template_entity.py b/homeassistant/helpers/template_entity.py deleted file mode 100644 index 16dc212e8cc..00000000000 --- a/homeassistant/helpers/template_entity.py +++ /dev/null @@ -1,648 +0,0 @@ -"""TemplateEntity utility class.""" -from __future__ import annotations - -from collections.abc import Callable -import contextlib -import logging -from typing import Any - -import voluptuous as vol - -from homeassistant.components.sensor import ( - CONF_STATE_CLASS, - DEVICE_CLASSES_SCHEMA, - STATE_CLASSES_SCHEMA, - SensorEntity, -) -from homeassistant.const import ( - ATTR_ENTITY_PICTURE, - ATTR_FRIENDLY_NAME, - ATTR_ICON, - CONF_DEVICE_CLASS, - CONF_ICON, - CONF_NAME, - CONF_UNIQUE_ID, - CONF_UNIT_OF_MEASUREMENT, - EVENT_HOMEASSISTANT_START, - STATE_UNKNOWN, -) -from homeassistant.core import Context, CoreState, HomeAssistant, State, callback -from homeassistant.exceptions import TemplateError -from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads - -from . import config_validation as cv -from .entity import Entity -from .event import ( - EventStateChangedData, - TrackTemplate, - TrackTemplateResult, - async_track_template_result, -) -from .script import Script, _VarsType -from .template import ( - Template, - TemplateStateFromEntityId, - attach as template_attach, - render_complex, - result_as_boolean, -) -from .typing import ConfigType, EventType - -_LOGGER = logging.getLogger(__name__) - -CONF_AVAILABILITY = "availability" -CONF_ATTRIBUTES = "attributes" -CONF_PICTURE = "picture" - -CONF_TO_ATTRIBUTE = { - CONF_ICON: ATTR_ICON, - CONF_NAME: ATTR_FRIENDLY_NAME, - CONF_PICTURE: ATTR_ENTITY_PICTURE, -} - -TEMPLATE_ENTITY_BASE_SCHEMA = vol.Schema( - { - vol.Optional(CONF_ICON): cv.template, - vol.Optional(CONF_NAME): cv.template, - vol.Optional(CONF_PICTURE): cv.template, - vol.Optional(CONF_UNIQUE_ID): cv.string, - } -) - - -def make_template_entity_base_schema(default_name: str) -> vol.Schema: - """Return a schema with default name.""" - return vol.Schema( - { - vol.Optional(CONF_ICON): cv.template, - vol.Optional(CONF_NAME, default=default_name): cv.template, - vol.Optional(CONF_PICTURE): cv.template, - vol.Optional(CONF_UNIQUE_ID): cv.string, - } - ) - - -TEMPLATE_SENSOR_BASE_SCHEMA = vol.Schema( - { - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - } -).extend(TEMPLATE_ENTITY_BASE_SCHEMA.schema) - - -class _TemplateAttribute: - """Attribute value linked to template result.""" - - def __init__( - self, - entity: Entity, - attribute: str, - template: Template, - validator: Callable[[Any], Any] | None = None, - on_update: Callable[[Any], None] | None = None, - none_on_template_error: bool | None = False, - ) -> None: - """Template attribute.""" - self._entity = entity - self._attribute = attribute - self.template = template - self.validator = validator - self.on_update = on_update - self.async_update = None - self.none_on_template_error = none_on_template_error - - @callback - def async_setup(self) -> None: - """Config update path for the attribute.""" - if self.on_update: - return - - if not hasattr(self._entity, self._attribute): - raise AttributeError(f"Attribute '{self._attribute}' does not exist.") - - self.on_update = self._default_update - - @callback - def _default_update(self, result: str | TemplateError) -> None: - attr_result = None if isinstance(result, TemplateError) else result - setattr(self._entity, self._attribute, attr_result) - - @callback - def handle_result( - self, - event: EventType[EventStateChangedData] | None, - template: Template, - last_result: str | None | TemplateError, - result: str | TemplateError, - ) -> None: - """Handle a template result event callback.""" - if isinstance(result, TemplateError): - _LOGGER.error( - ( - "TemplateError('%s') " - "while processing template '%s' " - "for attribute '%s' in entity '%s'" - ), - result, - self.template, - self._attribute, - self._entity.entity_id, - ) - if self.none_on_template_error: - self._default_update(result) - else: - assert self.on_update - self.on_update(result) - return - - if not self.validator: - assert self.on_update - self.on_update(result) - return - - try: - validated = self.validator(result) - except vol.Invalid as ex: - _LOGGER.error( - ( - "Error validating template result '%s' " - "from template '%s' " - "for attribute '%s' in entity %s " - "validation message '%s'" - ), - result, - self.template, - self._attribute, - self._entity.entity_id, - ex.msg, - ) - assert self.on_update - self.on_update(None) - return - - assert self.on_update - self.on_update(validated) - return - - -class TemplateEntity(Entity): - """Entity that uses templates to calculate attributes.""" - - _attr_available = True - _attr_entity_picture = None - _attr_icon = None - - def __init__( - self, - hass: HomeAssistant, - *, - availability_template: Template | None = None, - icon_template: Template | None = None, - entity_picture_template: Template | None = None, - attribute_templates: dict[str, Template] | None = None, - config: ConfigType | None = None, - fallback_name: str | None = None, - unique_id: str | None = None, - ) -> None: - """Template Entity.""" - self._template_attrs: dict[Template, list[_TemplateAttribute]] = {} - self._async_update: Callable[[], None] | None = None - self._attr_extra_state_attributes = {} - self._self_ref_update_count = 0 - self._attr_unique_id = unique_id - if config is None: - self._attribute_templates = attribute_templates - self._availability_template = availability_template - self._icon_template = icon_template - self._entity_picture_template = entity_picture_template - self._friendly_name_template = None - else: - self._attribute_templates = config.get(CONF_ATTRIBUTES) - self._availability_template = config.get(CONF_AVAILABILITY) - self._icon_template = config.get(CONF_ICON) - self._entity_picture_template = config.get(CONF_PICTURE) - self._friendly_name_template = config.get(CONF_NAME) - - class DummyState(State): - """None-state for template entities not yet added to the state machine.""" - - def __init__(self) -> None: - """Initialize a new state.""" - super().__init__("unknown.unknown", STATE_UNKNOWN) - self.entity_id = None # type: ignore[assignment] - - @property - def name(self) -> str: - """Name of this state.""" - return "" - - variables = {"this": DummyState()} - - # Try to render the name as it can influence the entity ID - self._attr_name = fallback_name - if self._friendly_name_template: - self._friendly_name_template.hass = hass - with contextlib.suppress(TemplateError): - self._attr_name = self._friendly_name_template.async_render( - variables=variables, parse_result=False - ) - - # Templates will not render while the entity is unavailable, try to render the - # icon and picture templates. - if self._entity_picture_template: - self._entity_picture_template.hass = hass - with contextlib.suppress(TemplateError): - self._attr_entity_picture = self._entity_picture_template.async_render( - variables=variables, parse_result=False - ) - - if self._icon_template: - self._icon_template.hass = hass - with contextlib.suppress(TemplateError): - self._attr_icon = self._icon_template.async_render( - variables=variables, parse_result=False - ) - - @callback - def _update_available(self, result: str | TemplateError) -> None: - if isinstance(result, TemplateError): - self._attr_available = True - return - - self._attr_available = result_as_boolean(result) - - @callback - def _update_state(self, result: str | TemplateError) -> None: - if self._availability_template: - return - - self._attr_available = not isinstance(result, TemplateError) - - @callback - def _add_attribute_template( - self, attribute_key: str, attribute_template: Template - ) -> None: - """Create a template tracker for the attribute.""" - - def _update_attribute(result: str | TemplateError) -> None: - attr_result = None if isinstance(result, TemplateError) else result - self._attr_extra_state_attributes[attribute_key] = attr_result - - self.add_template_attribute( - attribute_key, attribute_template, None, _update_attribute - ) - - def add_template_attribute( - self, - attribute: str, - template: Template, - validator: Callable[[Any], Any] | None = None, - on_update: Callable[[Any], None] | None = None, - none_on_template_error: bool = False, - ) -> None: - """Call in the constructor to add a template linked to a attribute. - - Parameters - ---------- - attribute - The name of the attribute to link to. This attribute must exist - unless a custom on_update method is supplied. - template - The template to calculate. - validator - Validator function to parse the result and ensure it's valid. - on_update - Called to store the template result rather than storing it - the supplied attribute. Passed the result of the validator, or None - if the template or validator resulted in an error. - none_on_template_error - If True, the attribute will be set to None if the template errors. - - """ - assert self.hass is not None, "hass cannot be None" - template.hass = self.hass - template_attribute = _TemplateAttribute( - self, attribute, template, validator, on_update, none_on_template_error - ) - self._template_attrs.setdefault(template, []) - self._template_attrs[template].append(template_attribute) - - @callback - def _handle_results( - self, - event: EventType[EventStateChangedData] | None, - updates: list[TrackTemplateResult], - ) -> None: - """Call back the results to the attributes.""" - if event: - self.async_set_context(event.context) - - entity_id = event and event.data["entity_id"] - - if entity_id and entity_id == self.entity_id: - self._self_ref_update_count += 1 - else: - self._self_ref_update_count = 0 - - if self._self_ref_update_count > len(self._template_attrs): - for update in updates: - _LOGGER.warning( - ( - "Template loop detected while processing event: %s, skipping" - " template render for Template[%s]" - ), - event, - update.template.template, - ) - return - - for update in updates: - for attr in self._template_attrs[update.template]: - attr.handle_result( - event, update.template, update.last_result, update.result - ) - - self.async_write_ha_state() - - async def _async_template_startup(self, *_: Any) -> None: - template_var_tups: list[TrackTemplate] = [] - has_availability_template = False - - variables = {"this": TemplateStateFromEntityId(self.hass, self.entity_id)} - - for template, attributes in self._template_attrs.items(): - template_var_tup = TrackTemplate(template, variables) - is_availability_template = False - for attribute in attributes: - # pylint: disable-next=protected-access - if attribute._attribute == "_attr_available": - has_availability_template = True - is_availability_template = True - attribute.async_setup() - # Insert the availability template first in the list - if is_availability_template: - template_var_tups.insert(0, template_var_tup) - else: - template_var_tups.append(template_var_tup) - - result_info = async_track_template_result( - self.hass, - template_var_tups, - self._handle_results, - has_super_template=has_availability_template, - ) - self.async_on_remove(result_info.async_remove) - self._async_update = result_info.async_refresh - result_info.async_refresh() - - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - if self._availability_template is not None: - self.add_template_attribute( - "_attr_available", - self._availability_template, - None, - self._update_available, - ) - if self._attribute_templates is not None: - for key, value in self._attribute_templates.items(): - self._add_attribute_template(key, value) - if self._icon_template is not None: - self.add_template_attribute( - "_attr_icon", self._icon_template, vol.Or(cv.whitespace, cv.icon) - ) - if self._entity_picture_template is not None: - self.add_template_attribute( - "_attr_entity_picture", self._entity_picture_template - ) - if ( - self._friendly_name_template is not None - and not self._friendly_name_template.is_static - ): - self.add_template_attribute("_attr_name", self._friendly_name_template) - - if self.hass.state == CoreState.running: - await self._async_template_startup() - return - - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, self._async_template_startup - ) - - async def async_update(self) -> None: - """Call for forced update.""" - assert self._async_update - self._async_update() - - async def async_run_script( - self, - script: Script, - *, - run_variables: _VarsType | None = None, - context: Context | None = None, - ) -> None: - """Run an action script.""" - if run_variables is None: - run_variables = {} - await script.async_run( - run_variables={ - "this": TemplateStateFromEntityId(self.hass, self.entity_id), - **run_variables, - }, - context=context, - ) - - -class TriggerBaseEntity(Entity): - """Template Base entity based on trigger data.""" - - domain: str - extra_template_keys: tuple | None = None - extra_template_keys_complex: tuple | None = None - _unique_id: str | None - - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - ) -> None: - """Initialize the entity.""" - self.hass = hass - - self._set_unique_id(config.get(CONF_UNIQUE_ID)) - - self._config = config - - self._static_rendered = {} - self._to_render_simple = [] - self._to_render_complex: list[str] = [] - - for itm in ( - CONF_AVAILABILITY, - CONF_ICON, - CONF_NAME, - CONF_PICTURE, - ): - if itm not in config or config[itm] is None: - continue - if config[itm].is_static: - self._static_rendered[itm] = config[itm].template - else: - self._to_render_simple.append(itm) - - if self.extra_template_keys is not None: - self._to_render_simple.extend(self.extra_template_keys) - - if self.extra_template_keys_complex is not None: - self._to_render_complex.extend(self.extra_template_keys_complex) - - # We make a copy so our initial render is 'unknown' and not 'unavailable' - self._rendered = dict(self._static_rendered) - self._parse_result = {CONF_AVAILABILITY} - - @property - def name(self) -> str | None: - """Name of the entity.""" - return self._rendered.get(CONF_NAME) - - @property - def unique_id(self) -> str | None: - """Return unique ID of the entity.""" - return self._unique_id - - @property - def device_class(self): # type: ignore[no-untyped-def] - """Return device class of the entity.""" - return self._config.get(CONF_DEVICE_CLASS) - - @property - def icon(self) -> str | None: - """Return icon.""" - return self._rendered.get(CONF_ICON) - - @property - def entity_picture(self) -> str | None: - """Return entity picture.""" - return self._rendered.get(CONF_PICTURE) - - @property - def available(self) -> bool: - """Return availability of the entity.""" - return ( - self._rendered is not self._static_rendered - and - # Check against False so `None` is ok - self._rendered.get(CONF_AVAILABILITY) is not False - ) - - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return extra attributes.""" - return self._rendered.get(CONF_ATTRIBUTES) - - async def async_added_to_hass(self) -> None: - """Handle being added to Home Assistant.""" - await super().async_added_to_hass() - template_attach(self.hass, self._config) - - def _set_unique_id(self, unique_id: str | None) -> None: - """Set unique id.""" - self._unique_id = unique_id - - def restore_attributes(self, last_state: State) -> None: - """Restore attributes.""" - for conf_key, attr in CONF_TO_ATTRIBUTE.items(): - if conf_key not in self._config or attr not in last_state.attributes: - continue - self._rendered[conf_key] = last_state.attributes[attr] - - if CONF_ATTRIBUTES in self._config: - extra_state_attributes = {} - for attr in self._config[CONF_ATTRIBUTES]: - if attr not in last_state.attributes: - continue - extra_state_attributes[attr] = last_state.attributes[attr] - self._rendered[CONF_ATTRIBUTES] = extra_state_attributes - - def _render_templates(self, variables: dict[str, Any]) -> None: - """Render templates.""" - try: - rendered = dict(self._static_rendered) - - for key in self._to_render_simple: - rendered[key] = self._config[key].async_render( - variables, - parse_result=key in self._parse_result, - ) - - for key in self._to_render_complex: - rendered[key] = render_complex( - self._config[key], - variables, - ) - - if CONF_ATTRIBUTES in self._config: - rendered[CONF_ATTRIBUTES] = render_complex( - self._config[CONF_ATTRIBUTES], - variables, - ) - - self._rendered = rendered - except TemplateError as err: - logging.getLogger(f"{__package__}.{self.entity_id.split('.')[0]}").error( - "Error rendering %s template for %s: %s", key, self.entity_id, err - ) - self._rendered = self._static_rendered - - -class ManualTriggerEntity(TriggerBaseEntity): - """Template entity based on manual trigger data.""" - - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - ) -> None: - """Initialize the entity.""" - TriggerBaseEntity.__init__(self, hass, config) - # Need initial rendering on `name` as it influence the `entity_id` - self._rendered[CONF_NAME] = config[CONF_NAME].async_render( - {}, - parse_result=CONF_NAME in self._parse_result, - ) - - @callback - def _process_manual_data(self, value: Any | None = None) -> None: - """Process new data manually. - - Implementing class should call this last in update method to render templates. - Ex: self._process_manual_data(payload) - """ - - self.async_write_ha_state() - this = None - if state := self.hass.states.get(self.entity_id): - this = state.as_dict() - - run_variables: dict[str, Any] = {"value": value} - # Silently try if variable is a json and store result in `value_json` if it is. - with contextlib.suppress(*JSON_DECODE_EXCEPTIONS): - run_variables["value_json"] = json_loads(run_variables["value"]) - variables = {"this": this, **(run_variables or {})} - - self._render_templates(variables) - - -class ManualTriggerSensorEntity(ManualTriggerEntity, SensorEntity): - """Template entity based on manual trigger data for sensor.""" - - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - ) -> None: - """Initialize the sensor entity.""" - ManualTriggerEntity.__init__(self, hass, config) - self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) - self._attr_state_class = config.get(CONF_STATE_CLASS) diff --git a/homeassistant/helpers/trigger_template_entity.py b/homeassistant/helpers/trigger_template_entity.py new file mode 100644 index 00000000000..8fc99f5cb52 --- /dev/null +++ b/homeassistant/helpers/trigger_template_entity.py @@ -0,0 +1,267 @@ +"""TemplateEntity utility class.""" +from __future__ import annotations + +import contextlib +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + DEVICE_CLASSES_SCHEMA, + STATE_CLASSES_SCHEMA, + SensorEntity, +) +from homeassistant.const import ( + ATTR_ENTITY_PICTURE, + ATTR_FRIENDLY_NAME, + ATTR_ICON, + CONF_DEVICE_CLASS, + CONF_ICON, + CONF_NAME, + CONF_UNIQUE_ID, + CONF_UNIT_OF_MEASUREMENT, +) +from homeassistant.core import HomeAssistant, State, callback +from homeassistant.exceptions import TemplateError +from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads + +from . import config_validation as cv +from .entity import Entity +from .template import attach as template_attach, render_complex +from .typing import ConfigType + +CONF_AVAILABILITY = "availability" +CONF_ATTRIBUTES = "attributes" +CONF_PICTURE = "picture" + +CONF_TO_ATTRIBUTE = { + CONF_ICON: ATTR_ICON, + CONF_NAME: ATTR_FRIENDLY_NAME, + CONF_PICTURE: ATTR_ENTITY_PICTURE, +} + +TEMPLATE_ENTITY_BASE_SCHEMA = vol.Schema( + { + vol.Optional(CONF_ICON): cv.template, + vol.Optional(CONF_NAME): cv.template, + vol.Optional(CONF_PICTURE): cv.template, + vol.Optional(CONF_UNIQUE_ID): cv.string, + } +) + + +def make_template_entity_base_schema(default_name: str) -> vol.Schema: + """Return a schema with default name.""" + return vol.Schema( + { + vol.Optional(CONF_ICON): cv.template, + vol.Optional(CONF_NAME, default=default_name): cv.template, + vol.Optional(CONF_PICTURE): cv.template, + vol.Optional(CONF_UNIQUE_ID): cv.string, + } + ) + + +TEMPLATE_SENSOR_BASE_SCHEMA = vol.Schema( + { + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + } +).extend(TEMPLATE_ENTITY_BASE_SCHEMA.schema) + + +class TriggerBaseEntity(Entity): + """Template Base entity based on trigger data.""" + + domain: str + extra_template_keys: tuple | None = None + extra_template_keys_complex: tuple | None = None + _unique_id: str | None + + def __init__( + self, + hass: HomeAssistant, + config: ConfigType, + ) -> None: + """Initialize the entity.""" + self.hass = hass + + self._set_unique_id(config.get(CONF_UNIQUE_ID)) + + self._config = config + + self._static_rendered = {} + self._to_render_simple = [] + self._to_render_complex: list[str] = [] + + for itm in ( + CONF_AVAILABILITY, + CONF_ICON, + CONF_NAME, + CONF_PICTURE, + ): + if itm not in config or config[itm] is None: + continue + if config[itm].is_static: + self._static_rendered[itm] = config[itm].template + else: + self._to_render_simple.append(itm) + + if self.extra_template_keys is not None: + self._to_render_simple.extend(self.extra_template_keys) + + if self.extra_template_keys_complex is not None: + self._to_render_complex.extend(self.extra_template_keys_complex) + + # We make a copy so our initial render is 'unknown' and not 'unavailable' + self._rendered = dict(self._static_rendered) + self._parse_result = {CONF_AVAILABILITY} + + @property + def name(self) -> str | None: + """Name of the entity.""" + return self._rendered.get(CONF_NAME) + + @property + def unique_id(self) -> str | None: + """Return unique ID of the entity.""" + return self._unique_id + + @property + def device_class(self): # type: ignore[no-untyped-def] + """Return device class of the entity.""" + return self._config.get(CONF_DEVICE_CLASS) + + @property + def icon(self) -> str | None: + """Return icon.""" + return self._rendered.get(CONF_ICON) + + @property + def entity_picture(self) -> str | None: + """Return entity picture.""" + return self._rendered.get(CONF_PICTURE) + + @property + def available(self) -> bool: + """Return availability of the entity.""" + return ( + self._rendered is not self._static_rendered + and + # Check against False so `None` is ok + self._rendered.get(CONF_AVAILABILITY) is not False + ) + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return extra attributes.""" + return self._rendered.get(CONF_ATTRIBUTES) + + async def async_added_to_hass(self) -> None: + """Handle being added to Home Assistant.""" + await super().async_added_to_hass() + template_attach(self.hass, self._config) + + def _set_unique_id(self, unique_id: str | None) -> None: + """Set unique id.""" + self._unique_id = unique_id + + def restore_attributes(self, last_state: State) -> None: + """Restore attributes.""" + for conf_key, attr in CONF_TO_ATTRIBUTE.items(): + if conf_key not in self._config or attr not in last_state.attributes: + continue + self._rendered[conf_key] = last_state.attributes[attr] + + if CONF_ATTRIBUTES in self._config: + extra_state_attributes = {} + for attr in self._config[CONF_ATTRIBUTES]: + if attr not in last_state.attributes: + continue + extra_state_attributes[attr] = last_state.attributes[attr] + self._rendered[CONF_ATTRIBUTES] = extra_state_attributes + + def _render_templates(self, variables: dict[str, Any]) -> None: + """Render templates.""" + try: + rendered = dict(self._static_rendered) + + for key in self._to_render_simple: + rendered[key] = self._config[key].async_render( + variables, + parse_result=key in self._parse_result, + ) + + for key in self._to_render_complex: + rendered[key] = render_complex( + self._config[key], + variables, + ) + + if CONF_ATTRIBUTES in self._config: + rendered[CONF_ATTRIBUTES] = render_complex( + self._config[CONF_ATTRIBUTES], + variables, + ) + + self._rendered = rendered + except TemplateError as err: + logging.getLogger(f"{__package__}.{self.entity_id.split('.')[0]}").error( + "Error rendering %s template for %s: %s", key, self.entity_id, err + ) + self._rendered = self._static_rendered + + +class ManualTriggerEntity(TriggerBaseEntity): + """Template entity based on manual trigger data.""" + + def __init__( + self, + hass: HomeAssistant, + config: ConfigType, + ) -> None: + """Initialize the entity.""" + TriggerBaseEntity.__init__(self, hass, config) + # Need initial rendering on `name` as it influence the `entity_id` + self._rendered[CONF_NAME] = config[CONF_NAME].async_render( + {}, + parse_result=CONF_NAME in self._parse_result, + ) + + @callback + def _process_manual_data(self, value: Any | None = None) -> None: + """Process new data manually. + + Implementing class should call this last in update method to render templates. + Ex: self._process_manual_data(payload) + """ + + self.async_write_ha_state() + this = None + if state := self.hass.states.get(self.entity_id): + this = state.as_dict() + + run_variables: dict[str, Any] = {"value": value} + # Silently try if variable is a json and store result in `value_json` if it is. + with contextlib.suppress(*JSON_DECODE_EXCEPTIONS): + run_variables["value_json"] = json_loads(run_variables["value"]) + variables = {"this": this, **(run_variables or {})} + + self._render_templates(variables) + + +class ManualTriggerSensorEntity(ManualTriggerEntity, SensorEntity): + """Template entity based on manual trigger data for sensor.""" + + def __init__( + self, + hass: HomeAssistant, + config: ConfigType, + ) -> None: + """Initialize the sensor entity.""" + ManualTriggerEntity.__init__(self, hass, config) + self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) + self._attr_state_class = config.get(CONF_STATE_CLASS) diff --git a/tests/components/rest/test_switch.py b/tests/components/rest/test_switch.py index 8bd13550960..d57cd41aa10 100644 --- a/tests/components/rest/test_switch.py +++ b/tests/components/rest/test_switch.py @@ -41,7 +41,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.template_entity import CONF_PICTURE +from homeassistant.helpers.trigger_template_entity import CONF_PICTURE from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow diff --git a/tests/components/scrape/test_sensor.py b/tests/components/scrape/test_sensor.py index 60cde48e5bf..3ded3ce5bca 100644 --- a/tests/components/scrape/test_sensor.py +++ b/tests/components/scrape/test_sensor.py @@ -28,7 +28,10 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.template_entity import CONF_AVAILABILITY, CONF_PICTURE +from homeassistant.helpers.trigger_template_entity import ( + CONF_AVAILABILITY, + CONF_PICTURE, +) from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util diff --git a/tests/components/sql/__init__.py b/tests/components/sql/__init__.py index 53356a85c4e..6a629f9603d 100644 --- a/tests/components/sql/__init__.py +++ b/tests/components/sql/__init__.py @@ -20,7 +20,10 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.template_entity import CONF_AVAILABILITY, CONF_PICTURE +from homeassistant.helpers.trigger_template_entity import ( + CONF_AVAILABILITY, + CONF_PICTURE, +) from tests.common import MockConfigEntry diff --git a/tests/components/template/test_manual_trigger_entity.py b/tests/components/template/test_manual_trigger_entity.py index 19210645a0f..a18827ecb4c 100644 --- a/tests/components/template/test_manual_trigger_entity.py +++ b/tests/components/template/test_manual_trigger_entity.py @@ -2,7 +2,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import template -from homeassistant.helpers.template_entity import ManualTriggerEntity +from homeassistant.helpers.trigger_template_entity import ManualTriggerEntity async def test_template_entity_requires_hass_set(hass: HomeAssistant) -> None: From 9da192c752c4627f35858b83a9c76d02d91cade8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Thu, 24 Aug 2023 17:38:22 +0300 Subject: [PATCH 048/124] Avoid use of `datetime.utc*` methods deprecated in Python 3.12 (#93684) Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> --- homeassistant/components/feedreader/__init__.py | 6 +++--- homeassistant/components/filesize/sensor.py | 4 +--- homeassistant/components/http/__init__.py | 7 ++++--- .../components/local_calendar/diagnostics.py | 2 +- homeassistant/components/ovo_energy/__init__.py | 5 +++-- homeassistant/components/repetier/sensor.py | 6 +++--- homeassistant/components/starline/account.py | 5 ++++- homeassistant/components/stream/worker.py | 3 ++- .../components/traccar/device_tracker.py | 7 ++++--- script/version_bump.py | 9 +++------ tests/components/google/conftest.py | 5 ++--- tests/components/lacrosse_view/test_init.py | 4 ++-- tests/components/recorder/db_schema_0.py | 13 ++++++------- tests/components/scrape/test_init.py | 4 ++-- tests/components/scrape/test_sensor.py | 4 ++-- tests/components/stream/common.py | 4 ++-- tests/components/traccar/test_device_tracker.py | 6 +++--- tests/util/test_dt.py | 17 ++++++++++------- 18 files changed, 57 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/feedreader/__init__.py b/homeassistant/components/feedreader/__init__.py index 82312b8897c..eef84996d56 100644 --- a/homeassistant/components/feedreader/__init__.py +++ b/homeassistant/components/feedreader/__init__.py @@ -17,7 +17,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType -from homeassistant.util.dt import utc_from_timestamp +from homeassistant.util import dt as dt_util _LOGGER = getLogger(__name__) @@ -207,7 +207,7 @@ class FeedManager: self._firstrun = False else: # Set last entry timestamp as epoch time if not available - self._last_entry_timestamp = datetime.utcfromtimestamp(0).timetuple() + self._last_entry_timestamp = dt_util.utc_from_timestamp(0).timetuple() for entry in self._feed.entries: if ( self._firstrun @@ -286,6 +286,6 @@ class StoredData: def _async_save_data(self) -> dict[str, str]: """Save feed data to storage.""" return { - feed_id: utc_from_timestamp(timegm(struct_utc)).isoformat() + feed_id: dt_util.utc_from_timestamp(timegm(struct_utc)).isoformat() for feed_id, struct_utc in self._data.items() } diff --git a/homeassistant/components/filesize/sensor.py b/homeassistant/components/filesize/sensor.py index 0526df81a02..0e600363640 100644 --- a/homeassistant/components/filesize/sensor.py +++ b/homeassistant/components/filesize/sensor.py @@ -102,9 +102,7 @@ class FileSizeCoordinator(DataUpdateCoordinator): raise UpdateFailed(f"Can not retrieve file statistics {error}") from error size = statinfo.st_size - last_updated = datetime.utcfromtimestamp(statinfo.st_mtime).replace( - tzinfo=dt_util.UTC - ) + last_updated = dt_util.utc_from_timestamp(statinfo.st_mtime) _LOGGER.debug("size %s, last updated %s", size, last_updated) data: dict[str, int | float | datetime] = { diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index ff287efb083..68f68d7f558 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -40,7 +40,7 @@ from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.setup import async_start_setup, async_when_setup_or_start -from homeassistant.util import ssl as ssl_util +from homeassistant.util import dt as dt_util, ssl as ssl_util from homeassistant.util.json import json_loads from .auth import async_setup_auth @@ -503,14 +503,15 @@ class HomeAssistantHTTP: x509.NameAttribute(NameOID.COMMON_NAME, host), ] ) + now = dt_util.utcnow() cert = ( x509.CertificateBuilder() .subject_name(subject) .issuer_name(issuer) .public_key(key.public_key()) .serial_number(x509.random_serial_number()) - .not_valid_before(datetime.datetime.utcnow()) - .not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=30)) + .not_valid_before(now) + .not_valid_after(now + datetime.timedelta(days=30)) .add_extension( x509.SubjectAlternativeName([x509.DNSName(host)]), critical=False, diff --git a/homeassistant/components/local_calendar/diagnostics.py b/homeassistant/components/local_calendar/diagnostics.py index 51b53ff0073..c3b9e5d151c 100644 --- a/homeassistant/components/local_calendar/diagnostics.py +++ b/homeassistant/components/local_calendar/diagnostics.py @@ -19,7 +19,7 @@ async def async_get_config_entry_diagnostics( payload: dict[str, Any] = { "now": dt_util.now().isoformat(), "timezone": str(dt_util.DEFAULT_TIME_ZONE), - "system_timezone": str(datetime.datetime.utcnow().astimezone().tzinfo), + "system_timezone": str(datetime.datetime.now().astimezone().tzinfo), } store = hass.data[DOMAIN][config_entry.entry_id] ics = await store.async_load() diff --git a/homeassistant/components/ovo_energy/__init__.py b/homeassistant/components/ovo_energy/__init__.py index f9547fc3493..3e2e868728d 100644 --- a/homeassistant/components/ovo_energy/__init__.py +++ b/homeassistant/components/ovo_energy/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from datetime import datetime, timedelta +from datetime import timedelta import logging import aiohttp @@ -19,6 +19,7 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, UpdateFailed, ) +from homeassistant.util import dt as dt_util from .const import CONF_ACCOUNT, DATA_CLIENT, DATA_COORDINATOR, DOMAIN @@ -58,7 +59,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise UpdateFailed(exception) from exception if not authenticated: raise ConfigEntryAuthFailed("Not authenticated with OVO Energy") - return await client.get_daily_usage(datetime.utcnow().strftime("%Y-%m")) + return await client.get_daily_usage(dt_util.utcnow().strftime("%Y-%m")) coordinator = DataUpdateCoordinator[OVODailyUsage]( hass, diff --git a/homeassistant/components/repetier/sensor.py b/homeassistant/components/repetier/sensor.py index 784555e6c73..578ca58b80f 100644 --- a/homeassistant/components/repetier/sensor.py +++ b/homeassistant/components/repetier/sensor.py @@ -1,7 +1,6 @@ """Support for monitoring Repetier Server Sensors.""" from __future__ import annotations -from datetime import datetime import logging import time @@ -10,6 +9,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import UNDEFINED, ConfigType, DiscoveryInfoType +from homeassistant.util import dt as dt_util from . import REPETIER_API, SENSOR_TYPES, UPDATE_SIGNAL, RepetierSensorEntityDescription @@ -170,7 +170,7 @@ class RepetierJobEndSensor(RepetierSensor): print_time = data["print_time"] from_start = data["from_start"] time_end = start + round(print_time, 0) - self._state = datetime.utcfromtimestamp(time_end) + self._state = dt_util.utc_from_timestamp(time_end) remaining = print_time - from_start remaining_secs = int(round(remaining, 0)) _LOGGER.debug( @@ -192,7 +192,7 @@ class RepetierJobStartSensor(RepetierSensor): job_name = data["job_name"] start = data["start"] from_start = data["from_start"] - self._state = datetime.utcfromtimestamp(start) + self._state = dt_util.utc_from_timestamp(start) elapsed_secs = int(round(from_start, 0)) _LOGGER.debug( "Job %s elapsed %s", diff --git a/homeassistant/components/starline/account.py b/homeassistant/components/starline/account.py index b6a6ae4a953..f0dea666085 100644 --- a/homeassistant/components/starline/account.py +++ b/homeassistant/components/starline/account.py @@ -11,6 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.event import async_track_time_interval +from homeassistant.util import dt as dt_util from .const import ( _LOGGER, @@ -141,7 +142,9 @@ class StarlineAccount: def gps_attrs(device: StarlineDevice) -> dict[str, Any]: """Attributes for device tracker.""" return { - "updated": datetime.utcfromtimestamp(device.position["ts"]).isoformat(), + "updated": dt_util.utc_from_timestamp(device.position["ts"]) + .replace(tzinfo=None) + .isoformat(), "online": device.online, } diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index c237a820e58..07d274e655c 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -14,6 +14,7 @@ import attr import av from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from . import redact_credentials from .const import ( @@ -140,7 +141,7 @@ class StreamMuxer: self._part_has_keyframe = False self._stream_settings = stream_settings self._stream_state = stream_state - self._start_time = datetime.datetime.utcnow() + self._start_time = dt_util.utcnow() def make_new_av( self, diff --git a/homeassistant/components/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py index d15669745ef..f1236a66700 100644 --- a/homeassistant/components/traccar/device_tracker.py +++ b/homeassistant/components/traccar/device_tracker.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from datetime import datetime, timedelta +from datetime import timedelta import logging from pytraccar import ( @@ -44,7 +44,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import slugify +from homeassistant.util import dt as dt_util, slugify from . import DOMAIN, TRACKER_UPDATE from .const import ( @@ -334,7 +334,8 @@ class TraccarScanner: async def import_events(self): """Import events from Traccar.""" - start_intervel = datetime.utcnow() + # get_reports_events requires naive UTC datetimes as of 1.0.0 + start_intervel = dt_util.utcnow().replace(tzinfo=None) events = await self._api.get_reports_events( devices=[device.id for device in self._devices], start_time=start_intervel, diff --git a/script/version_bump.py b/script/version_bump.py index ae01b1e6bed..4c4f8a97f09 100755 --- a/script/version_bump.py +++ b/script/version_bump.py @@ -1,13 +1,13 @@ #!/usr/bin/env python3 """Helper script to bump the current version.""" import argparse -from datetime import datetime import re import subprocess from packaging.version import Version from homeassistant import const +from homeassistant.util import dt as dt_util def _bump_release(release, bump_type): @@ -86,10 +86,7 @@ def bump_version(version, bump_type): if not version.is_devrelease: raise ValueError("Can only be run on dev release") - to_change["dev"] = ( - "dev", - datetime.utcnow().date().isoformat().replace("-", ""), - ) + to_change["dev"] = ("dev", dt_util.utcnow().strftime("%Y%m%d")) else: assert False, f"Unsupported type: {bump_type}" @@ -206,7 +203,7 @@ def test_bump_version(): assert bump_version(Version("0.56.0.dev0"), "minor") == Version("0.56.0") assert bump_version(Version("0.56.2.dev0"), "minor") == Version("0.57.0") - today = datetime.utcnow().date().isoformat().replace("-", "") + today = dt_util.utcnow().strftime("%Y%m%d") assert bump_version(Version("0.56.0.dev0"), "nightly") == Version( f"0.56.0.dev{today}" ) diff --git a/tests/components/google/conftest.py b/tests/components/google/conftest.py index 9516da8e841..57e542e8a21 100644 --- a/tests/components/google/conftest.py +++ b/tests/components/google/conftest.py @@ -20,6 +20,7 @@ from homeassistant.components.application_credentials import ( from homeassistant.components.google import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -138,9 +139,7 @@ def token_scopes() -> list[str]: def token_expiry() -> datetime.datetime: """Expiration time for credentials used in the test.""" # OAuth library returns an offset-naive timestamp - return datetime.datetime.fromtimestamp( - datetime.datetime.utcnow().timestamp() - ) + datetime.timedelta(hours=1) + return dt_util.utcnow().replace(tzinfo=None) + datetime.timedelta(hours=1) @pytest.fixture diff --git a/tests/components/lacrosse_view/test_init.py b/tests/components/lacrosse_view/test_init.py index 600fe1c9d24..557f8c4234a 100644 --- a/tests/components/lacrosse_view/test_init.py +++ b/tests/components/lacrosse_view/test_init.py @@ -92,7 +92,7 @@ async def test_new_token(hass: HomeAssistant) -> None: assert len(entries) == 1 assert entries[0].state == ConfigEntryState.LOADED - one_hour_after = datetime.utcnow() + timedelta(hours=1) + one_hour_after = datetime.now() + timedelta(hours=1) with patch("lacrosse_view.LaCrosse.login", return_value=True) as login, patch( "lacrosse_view.LaCrosse.get_sensors", @@ -122,7 +122,7 @@ async def test_failed_token(hass: HomeAssistant) -> None: assert len(entries) == 1 assert entries[0].state == ConfigEntryState.LOADED - one_hour_after = datetime.utcnow() + timedelta(hours=1) + one_hour_after = datetime.now() + timedelta(hours=1) with patch( "lacrosse_view.LaCrosse.login", side_effect=LoginError("Test") diff --git a/tests/components/recorder/db_schema_0.py b/tests/components/recorder/db_schema_0.py index 3fbf9cce5fc..6365ff6a7e7 100644 --- a/tests/components/recorder/db_schema_0.py +++ b/tests/components/recorder/db_schema_0.py @@ -4,7 +4,6 @@ This file contains the original models definitions before schema tracking was implemented. It is used to test the schema migration logic. """ -from datetime import datetime import json import logging @@ -40,7 +39,7 @@ class Events(Base): # type: ignore event_data = Column(Text) origin = Column(String(32)) time_fired = Column(DateTime(timezone=True)) - created = Column(DateTime(timezone=True), default=datetime.utcnow) + created = Column(DateTime(timezone=True), default=dt_util.utcnow) @staticmethod def from_event(event): @@ -77,9 +76,9 @@ class States(Base): # type: ignore state = Column(String(255)) attributes = Column(Text) event_id = Column(Integer, ForeignKey("events.event_id")) - last_changed = Column(DateTime(timezone=True), default=datetime.utcnow) - last_updated = Column(DateTime(timezone=True), default=datetime.utcnow) - created = Column(DateTime(timezone=True), default=datetime.utcnow) + last_changed = Column(DateTime(timezone=True), default=dt_util.utcnow) + last_updated = Column(DateTime(timezone=True), default=dt_util.utcnow) + created = Column(DateTime(timezone=True), default=dt_util.utcnow) __table_args__ = ( Index("states__state_changes", "last_changed", "last_updated", "entity_id"), @@ -131,10 +130,10 @@ class RecorderRuns(Base): # type: ignore __tablename__ = "recorder_runs" run_id = Column(Integer, primary_key=True) - start = Column(DateTime(timezone=True), default=datetime.utcnow) + start = Column(DateTime(timezone=True), default=dt_util.utcnow) end = Column(DateTime(timezone=True)) closed_incorrect = Column(Boolean, default=False) - created = Column(DateTime(timezone=True), default=datetime.utcnow) + created = Column(DateTime(timezone=True), default=dt_util.utcnow) def entity_ids(self, point_in_time=None): """Return the entity ids that existed in this run. diff --git a/tests/components/scrape/test_init.py b/tests/components/scrape/test_init.py index 9b6122d6010..aa4be4cdef3 100644 --- a/tests/components/scrape/test_init.py +++ b/tests/components/scrape/test_init.py @@ -1,7 +1,6 @@ """Test Scrape component setup process.""" from __future__ import annotations -from datetime import datetime from unittest.mock import patch import pytest @@ -11,6 +10,7 @@ from homeassistant.components.scrape.const import DEFAULT_SCAN_INTERVAL, DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from . import MockRestData, return_integration_config @@ -67,7 +67,7 @@ async def test_setup_no_data_fails_with_recovery( assert "Platform scrape not ready yet" in caplog.text mocker.payload = "test_scrape_sensor" - async_fire_time_changed(hass, datetime.utcnow() + DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + DEFAULT_SCAN_INTERVAL) await hass.async_block_till_done() state = hass.states.get("sensor.ha_version") diff --git a/tests/components/scrape/test_sensor.py b/tests/components/scrape/test_sensor.py index 3ded3ce5bca..559c94633cd 100644 --- a/tests/components/scrape/test_sensor.py +++ b/tests/components/scrape/test_sensor.py @@ -1,7 +1,7 @@ """The tests for the Scrape sensor platform.""" from __future__ import annotations -from datetime import datetime, timedelta +from datetime import timedelta from unittest.mock import patch import pytest @@ -247,7 +247,7 @@ async def test_scrape_sensor_no_data_refresh(hass: HomeAssistant) -> None: assert state.state == "Current Version: 2021.12.10" mocker.payload = "test_scrape_sensor_no_data" - async_fire_time_changed(hass, datetime.utcnow() + DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + DEFAULT_SCAN_INTERVAL) await hass.async_block_till_done() state = hass.states.get("sensor.ha_version") diff --git a/tests/components/stream/common.py b/tests/components/stream/common.py index c75525c6061..7ea583c0ec3 100644 --- a/tests/components/stream/common.py +++ b/tests/components/stream/common.py @@ -1,5 +1,4 @@ """Collection of test helpers.""" -from datetime import datetime from fractions import Fraction import functools from functools import partial @@ -15,8 +14,9 @@ from homeassistant.components.stream.fmp4utils import ( XYW_ROW, find_box, ) +from homeassistant.util import dt as dt_util -FAKE_TIME = datetime.utcnow() +FAKE_TIME = dt_util.utcnow() # Segment with defaults filled in for use in tests DefaultSegment = partial( diff --git a/tests/components/traccar/test_device_tracker.py b/tests/components/traccar/test_device_tracker.py index 065b459354a..ed6cc3f629b 100644 --- a/tests/components/traccar/test_device_tracker.py +++ b/tests/components/traccar/test_device_tracker.py @@ -1,5 +1,4 @@ """The tests for the Traccar device tracker platform.""" -from datetime import datetime from unittest.mock import AsyncMock, patch from pytraccar import ReportsEventeModel @@ -17,6 +16,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from tests.common import async_capture_events @@ -47,7 +47,7 @@ async def test_import_events_catch_all(hass: HomeAssistant) -> None: "maintenanceId": 1, "deviceId": device["id"], "type": "ignitionOn", - "eventTime": datetime.utcnow().isoformat(), + "eventTime": dt_util.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"), "attributes": {}, } ), @@ -59,7 +59,7 @@ async def test_import_events_catch_all(hass: HomeAssistant) -> None: "maintenanceId": 1, "deviceId": device["id"], "type": "ignitionOff", - "eventTime": datetime.utcnow().isoformat(), + "eventTime": dt_util.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"), "attributes": {}, } ), diff --git a/tests/util/test_dt.py b/tests/util/test_dt.py index e9cde21a265..28695a94400 100644 --- a/tests/util/test_dt.py +++ b/tests/util/test_dt.py @@ -1,7 +1,7 @@ """Test Home Assistant date util methods.""" from __future__ import annotations -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta import time import pytest @@ -41,9 +41,9 @@ def test_set_default_time_zone() -> None: def test_utcnow() -> None: """Test the UTC now method.""" - assert abs(dt_util.utcnow().replace(tzinfo=None) - datetime.utcnow()) < timedelta( - seconds=1 - ) + assert abs( + dt_util.utcnow().replace(tzinfo=None) - datetime.now(UTC).replace(tzinfo=None) + ) < timedelta(seconds=1) def test_now() -> None: @@ -51,13 +51,14 @@ def test_now() -> None: dt_util.set_default_time_zone(dt_util.get_time_zone(TEST_TIME_ZONE)) assert abs( - dt_util.as_utc(dt_util.now()).replace(tzinfo=None) - datetime.utcnow() + dt_util.as_utc(dt_util.now()).replace(tzinfo=None) + - datetime.now(UTC).replace(tzinfo=None) ) < timedelta(seconds=1) def test_as_utc_with_naive_object() -> None: """Test the now method.""" - utcnow = datetime.utcnow() + utcnow = datetime.now(UTC).replace(tzinfo=None) assert utcnow == dt_util.as_utc(utcnow).replace(tzinfo=None) @@ -82,7 +83,9 @@ def test_as_utc_with_local_object() -> None: def test_as_local_with_naive_object() -> None: """Test local time with native object.""" now = dt_util.now() - assert abs(now - dt_util.as_local(datetime.utcnow())) < timedelta(seconds=1) + assert abs( + now - dt_util.as_local(datetime.now(UTC).replace(tzinfo=None)) + ) < timedelta(seconds=1) def test_as_local_with_local_object() -> None: From d300f2d0cc92f0bb5a0eea17cc4617d450c34a41 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 24 Aug 2023 16:39:14 +0200 Subject: [PATCH 049/124] Remove default model from upcloud (#98972) --- homeassistant/components/upcloud/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/upcloud/__init__.py b/homeassistant/components/upcloud/__init__.py index 7f832eb733f..174d35f07e0 100644 --- a/homeassistant/components/upcloud/__init__.py +++ b/homeassistant/components/upcloud/__init__.py @@ -243,7 +243,7 @@ class UpCloudServerEntity(CoordinatorEntity[UpCloudDataUpdateCoordinator]): assert self.coordinator.config_entry is not None return DeviceInfo( configuration_url="https://hub.upcloud.com", - default_model="Control Panel", + model="Control Panel", entry_type=DeviceEntryType.SERVICE, identifiers={ (DOMAIN, f"{self.coordinator.config_entry.data[CONF_USERNAME]}@hub") From 4e049f9bedefc5c253944d140c96d2b12a1983f3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 24 Aug 2023 17:11:24 +0200 Subject: [PATCH 050/124] Use snapshot assertion in Tile diagnostic test (#98965) --- .../tile/snapshots/test_diagnostics.ambr | 26 ++++++++++++++++ tests/components/tile/test_diagnostics.py | 31 +++++-------------- 2 files changed, 33 insertions(+), 24 deletions(-) create mode 100644 tests/components/tile/snapshots/test_diagnostics.ambr diff --git a/tests/components/tile/snapshots/test_diagnostics.ambr b/tests/components/tile/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..c04bd93315f --- /dev/null +++ b/tests/components/tile/snapshots/test_diagnostics.ambr @@ -0,0 +1,26 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'tiles': list([ + dict({ + 'accuracy': 13.496111, + 'altitude': '**REDACTED**', + 'archetype': 'WALLET', + 'dead': False, + 'firmware_version': '01.12.14.0', + 'hardware_version': '02.09', + 'kind': 'TILE', + 'last_timestamp': '2020-08-12T17:55:26', + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'lost': False, + 'lost_timestamp': '1969-12-31T23:59:59.999000', + 'name': 'Wallet', + 'ring_state': 'STOPPED', + 'uuid': '**REDACTED**', + 'visible': True, + 'voip_state': 'OFFLINE', + }), + ]), + }) +# --- diff --git a/tests/components/tile/test_diagnostics.py b/tests/components/tile/test_diagnostics.py index a4aa42cc1fb..8af2c513202 100644 --- a/tests/components/tile/test_diagnostics.py +++ b/tests/components/tile/test_diagnostics.py @@ -1,5 +1,6 @@ """Test Tile diagnostics.""" -from homeassistant.components.diagnostics import REDACTED +from syrupy import SnapshotAssertion + from homeassistant.core import HomeAssistant from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -11,28 +12,10 @@ async def test_entry_diagnostics( config_entry, hass_client: ClientSessionGenerator, setup_config_entry, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "tiles": [ - { - "accuracy": 13.496111, - "altitude": REDACTED, - "archetype": "WALLET", - "dead": False, - "firmware_version": "01.12.14.0", - "hardware_version": "02.09", - "kind": "TILE", - "last_timestamp": "2020-08-12T17:55:26", - "latitude": REDACTED, - "longitude": REDACTED, - "lost": False, - "lost_timestamp": "1969-12-31T23:59:59.999000", - "name": "Wallet", - "ring_state": "STOPPED", - "uuid": REDACTED, - "visible": True, - "voip_state": "OFFLINE", - } - ] - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) From 53eb4d0ead920fb25c41d9f4600e0f4f23d9bdf7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Aug 2023 11:10:38 -0500 Subject: [PATCH 051/124] Bump dbus-fast to 1.94.0 (#98973) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index db28e550d23..e6916d00881 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,6 +19,6 @@ "bluetooth-adapters==0.16.0", "bluetooth-auto-recovery==1.2.1", "bluetooth-data-tools==1.9.0", - "dbus-fast==1.93.0" + "dbus-fast==1.94.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9d9e98e0c26..71f8a37ff40 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,7 +16,7 @@ bluetooth-data-tools==1.9.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.3 -dbus-fast==1.93.0 +dbus-fast==1.94.0 fnv-hash-fast==0.4.0 ha-av==10.1.1 hass-nabucasa==0.70.0 diff --git a/requirements_all.txt b/requirements_all.txt index 89088840dd6..8ef933870a0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -635,7 +635,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.93.0 +dbus-fast==1.94.0 # homeassistant.components.debugpy debugpy==1.6.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 212132857ba..57ddafb4eba 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -515,7 +515,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.93.0 +dbus-fast==1.94.0 # homeassistant.components.debugpy debugpy==1.6.7 From 7d35dcfa65f06f199a1c05f8f90810de9e995495 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 24 Aug 2023 19:49:53 +0200 Subject: [PATCH 052/124] Make Sabnzbd entity translation clearer (#98938) --- homeassistant/components/sabnzbd/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sabnzbd/strings.json b/homeassistant/components/sabnzbd/strings.json index 8d3fb84fb9f..f8c831cd95a 100644 --- a/homeassistant/components/sabnzbd/strings.json +++ b/homeassistant/components/sabnzbd/strings.json @@ -26,7 +26,7 @@ "name": "Queue" }, "left": { - "name": "Left" + "name": "Left to download" }, "total_disk_space": { "name": "Total disk space" From 089f76099df11f36058d6c792861486b2ecce615 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 24 Aug 2023 19:50:25 +0200 Subject: [PATCH 053/124] Fix stream test aiohttp DeprecationWarning (#98962) --- tests/components/stream/test_ll_hls.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/components/stream/test_ll_hls.py b/tests/components/stream/test_ll_hls.py index 17918ff93df..cd13ab340c2 100644 --- a/tests/components/stream/test_ll_hls.py +++ b/tests/components/stream/test_ll_hls.py @@ -7,6 +7,7 @@ import math import re from urllib.parse import urlparse +from aiohttp import web from dateutil import parser import pytest @@ -394,6 +395,9 @@ async def test_ll_hls_playlist_bad_msn_part( ) -> None: """Test some playlist requests with invalid _HLS_msn/_HLS_part.""" + async def _handler_bad_request(request): + raise web.HTTPBadRequest() + await async_setup_component( hass, "stream", @@ -413,6 +417,12 @@ async def test_ll_hls_playlist_bad_msn_part( hls_client = await hls_stream(stream) + # All GET calls to '/.../playlist.m3u8' should raise a HTTPBadRequest exception + hls_client.http_client.app.router._frozen = False + parsed_url = urlparse(stream.endpoint_url(HLS_PROVIDER)) + url = "/".join(parsed_url.path.split("/")[:-1]) + "/playlist.m3u8" + hls_client.http_client.app.router.add_route("GET", url, _handler_bad_request) + # If the Playlist URI contains an _HLS_part directive but no _HLS_msn # directive, the Server MUST return Bad Request, such as HTTP 400. From 998a390da5dfb22054242579f0d231325082012f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 24 Aug 2023 19:53:09 +0200 Subject: [PATCH 054/124] Use device class in TPLink Omada Update entity (#98971) --- homeassistant/components/tplink_omada/update.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tplink_omada/update.py b/homeassistant/components/tplink_omada/update.py index 685ad9c5761..1e653a53aae 100644 --- a/homeassistant/components/tplink_omada/update.py +++ b/homeassistant/components/tplink_omada/update.py @@ -8,7 +8,11 @@ from tplink_omada_client.devices import OmadaFirmwareUpdate, OmadaListDevice from tplink_omada_client.exceptions import OmadaClientException, RequestFailed from tplink_omada_client.omadasiteclient import OmadaSiteClient -from homeassistant.components.update import UpdateEntity, UpdateEntityFeature +from homeassistant.components.update import ( + UpdateDeviceClass, + UpdateEntity, + UpdateEntityFeature, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -94,7 +98,7 @@ class OmadaDeviceUpdate( | UpdateEntityFeature.RELEASE_NOTES ) _attr_has_entity_name = True - _attr_name = "Firmware update" + _attr_device_class = UpdateDeviceClass.FIRMWARE def __init__( self, From 2066cf6b31b0b61bae95373ad3b6a59013c25459 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 24 Aug 2023 19:54:04 +0200 Subject: [PATCH 055/124] Remove `group_type` from group preview events (#98952) --- homeassistant/components/group/config_flow.py | 3 +-- tests/components/group/test_config_flow.py | 3 --- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/group/config_flow.py b/homeassistant/components/group/config_flow.py index 28a7330a206..a5bf9e0b972 100644 --- a/homeassistant/components/group/config_flow.py +++ b/homeassistant/components/group/config_flow.py @@ -343,8 +343,7 @@ def ws_start_preview( """Forward config entry state events to websocket.""" connection.send_message( websocket_api.event_message( - msg["id"], - {"attributes": attributes, "group_type": group_type, "state": state}, + msg["id"], {"attributes": attributes, "state": state} ) ) diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index b244b37e072..a58e47cae71 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -524,7 +524,6 @@ async def test_config_flow_preview( msg = await client.receive_json() assert msg["event"] == { "attributes": {"friendly_name": "My group"} | extra_attributes[0], - "group_type": domain, "state": "unavailable", } @@ -539,7 +538,6 @@ async def test_config_flow_preview( } | extra_attributes[0] | extra_attributes[1], - "group_type": domain, "state": group_state, } assert len(hass.states.async_all()) == 2 @@ -622,7 +620,6 @@ async def test_option_flow_preview( assert msg["event"] == { "attributes": {"entity_id": input_entities, "friendly_name": "My group"} | extra_attributes, - "group_type": domain, "state": group_state, } assert len(hass.states.async_all()) == 3 From 969063ccf8fb4f314a07f090fc7a28f43dfe4a87 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 24 Aug 2023 19:54:49 +0200 Subject: [PATCH 056/124] Use shorthand attributes for SRP Energy (#98953) --- homeassistant/components/srp_energy/sensor.py | 22 +++---------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/srp_energy/sensor.py b/homeassistant/components/srp_energy/sensor.py index 946b2aedb13..d477b65b21d 100644 --- a/homeassistant/components/srp_energy/sensor.py +++ b/homeassistant/components/srp_energy/sensor.py @@ -101,6 +101,9 @@ class SrpEntity(SensorEntity): _attr_attribution = "Powered by SRP Energy" _attr_icon = "mdi:flash" + _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR + _attr_device_class = SensorDeviceClass.ENERGY + _attr_state_class = SensorStateClass.TOTAL_INCREASING _attr_should_poll = False def __init__(self, coordinator) -> None: @@ -108,8 +111,6 @@ class SrpEntity(SensorEntity): self._name = SENSOR_NAME self.type = SENSOR_TYPE self.coordinator = coordinator - self._unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR - self._state = None @property def name(self) -> str: @@ -121,33 +122,16 @@ class SrpEntity(SensorEntity): """Return the state of the device.""" return self.coordinator.data - @property - def native_unit_of_measurement(self) -> str: - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - @property def available(self) -> bool: """Return if entity is available.""" return self.coordinator.last_update_success - @property - def device_class(self) -> SensorDeviceClass: - """Return the device class.""" - return SensorDeviceClass.ENERGY - - @property - def state_class(self) -> SensorStateClass: - """Return the state class.""" - return SensorStateClass.TOTAL_INCREASING - async def async_added_to_hass(self) -> None: """When entity is added to hass.""" self.async_on_remove( self.coordinator.async_add_listener(self.async_write_ha_state) ) - if self.coordinator.data: - self._state = self.coordinator.data async def async_update(self) -> None: """Update the entity. From be78169065837ac2534213d12e8048c323730a9b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 24 Aug 2023 19:56:02 +0200 Subject: [PATCH 057/124] Add entity translations to Risco (#98921) --- .../components/risco/alarm_control_panel.py | 4 ++-- homeassistant/components/risco/binary_sensor.py | 16 ++++++++-------- homeassistant/components/risco/entity.py | 4 ---- homeassistant/components/risco/strings.json | 15 +++++++++++++++ homeassistant/components/risco/switch.py | 4 ++-- 5 files changed, 27 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/risco/alarm_control_panel.py b/homeassistant/components/risco/alarm_control_panel.py index 5b2d85b2bca..a72efe1629c 100644 --- a/homeassistant/components/risco/alarm_control_panel.py +++ b/homeassistant/components/risco/alarm_control_panel.py @@ -88,6 +88,8 @@ class RiscoAlarm(AlarmControlPanelEntity): """Representation of a Risco cloud partition.""" _attr_code_format = CodeFormat.NUMBER + _attr_has_entity_name = True + _attr_name = None def __init__( self, @@ -107,8 +109,6 @@ class RiscoAlarm(AlarmControlPanelEntity): self._code_disarm_required = options[CONF_CODE_DISARM_REQUIRED] self._risco_to_ha = options[CONF_RISCO_STATES_TO_HA] self._ha_to_risco = options[CONF_HA_STATES_TO_RISCO] - self._attr_has_entity_name = True - self._attr_name = None for state in self._ha_to_risco: self._attr_supported_features |= STATES_TO_SUPPORTED_FEATURES[state] diff --git a/homeassistant/components/risco/binary_sensor.py b/homeassistant/components/risco/binary_sensor.py index 423137d88b6..f60b0bf3c35 100644 --- a/homeassistant/components/risco/binary_sensor.py +++ b/homeassistant/components/risco/binary_sensor.py @@ -50,14 +50,13 @@ class RiscoCloudBinarySensor(RiscoCloudZoneEntity, BinarySensorEntity): """Representation of a Risco cloud zone as a binary sensor.""" _attr_device_class = BinarySensorDeviceClass.MOTION + _attr_name = None def __init__( self, coordinator: RiscoDataUpdateCoordinator, zone_id: int, zone: Zone ) -> None: """Init the zone.""" - super().__init__( - coordinator=coordinator, name=None, suffix="", zone_id=zone_id, zone=zone - ) + super().__init__(coordinator=coordinator, suffix="", zone_id=zone_id, zone=zone) @property def is_on(self) -> bool | None: @@ -69,12 +68,11 @@ class RiscoLocalBinarySensor(RiscoLocalZoneEntity, BinarySensorEntity): """Representation of a Risco local zone as a binary sensor.""" _attr_device_class = BinarySensorDeviceClass.MOTION + _attr_name = None def __init__(self, system_id: str, zone_id: int, zone: Zone) -> None: """Init the zone.""" - super().__init__( - system_id=system_id, name=None, suffix="", zone_id=zone_id, zone=zone - ) + super().__init__(system_id=system_id, suffix="", zone_id=zone_id, zone=zone) @property def extra_state_attributes(self) -> Mapping[str, Any] | None: @@ -93,11 +91,12 @@ class RiscoLocalBinarySensor(RiscoLocalZoneEntity, BinarySensorEntity): class RiscoLocalAlarmedBinarySensor(RiscoLocalZoneEntity, BinarySensorEntity): """Representation whether a zone in Risco local is currently triggering an alarm.""" + _attr_translation_key = "alarmed" + def __init__(self, system_id: str, zone_id: int, zone: Zone) -> None: """Init the zone.""" super().__init__( system_id=system_id, - name="Alarmed", suffix="_alarmed", zone_id=zone_id, zone=zone, @@ -112,11 +111,12 @@ class RiscoLocalAlarmedBinarySensor(RiscoLocalZoneEntity, BinarySensorEntity): class RiscoLocalArmedBinarySensor(RiscoLocalZoneEntity, BinarySensorEntity): """Representation whether a zone in Risco local is currently armed.""" + _attr_translation_key = "armed" + def __init__(self, system_id: str, zone_id: int, zone: Zone) -> None: """Init the zone.""" super().__init__( system_id=system_id, - name="Armed", suffix="_armed", zone_id=zone_id, zone=zone, diff --git a/homeassistant/components/risco/entity.py b/homeassistant/components/risco/entity.py index 3a2c50e20af..7f8e3be698b 100644 --- a/homeassistant/components/risco/entity.py +++ b/homeassistant/components/risco/entity.py @@ -56,7 +56,6 @@ class RiscoCloudZoneEntity(RiscoCloudEntity): self, *, coordinator: RiscoDataUpdateCoordinator, - name: str | None, suffix: str, zone_id: int, zone: Zone, @@ -66,7 +65,6 @@ class RiscoCloudZoneEntity(RiscoCloudEntity): super().__init__(coordinator=coordinator, **kwargs) self._zone_id = zone_id self._zone = zone - self._attr_name = name device_unique_id = zone_unique_id(self._risco, zone_id) self._attr_unique_id = f"{device_unique_id}{suffix}" self._attr_device_info = DeviceInfo( @@ -90,7 +88,6 @@ class RiscoLocalZoneEntity(Entity): self, *, system_id: str, - name: str | None, suffix: str, zone_id: int, zone: Zone, @@ -100,7 +97,6 @@ class RiscoLocalZoneEntity(Entity): super().__init__(**kwargs) self._zone_id = zone_id self._zone = zone - self._attr_name = name device_unique_id = f"{system_id}_zone_{zone_id}_local" self._attr_unique_id = f"{device_unique_id}{suffix}" self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/risco/strings.json b/homeassistant/components/risco/strings.json index ed3d832cf0b..13dfd60b5b6 100644 --- a/homeassistant/components/risco/strings.json +++ b/homeassistant/components/risco/strings.json @@ -64,5 +64,20 @@ } } } + }, + "entity": { + "binary_sensor": { + "alarmed": { + "name": "Alarmed" + }, + "armed": { + "name": "Armed" + } + }, + "switch": { + "bypassed": { + "name": "Bypassed" + } + } } } diff --git a/homeassistant/components/risco/switch.py b/homeassistant/components/risco/switch.py index f0804abb68a..9b34479f8a2 100644 --- a/homeassistant/components/risco/switch.py +++ b/homeassistant/components/risco/switch.py @@ -40,6 +40,7 @@ class RiscoCloudSwitch(RiscoCloudZoneEntity, SwitchEntity): """Representation of a bypass switch for a Risco cloud zone.""" _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "bypassed" def __init__( self, coordinator: RiscoDataUpdateCoordinator, zone_id: int, zone: Zone @@ -47,7 +48,6 @@ class RiscoCloudSwitch(RiscoCloudZoneEntity, SwitchEntity): """Init the zone.""" super().__init__( coordinator=coordinator, - name="Bypassed", suffix="_bypassed", zone_id=zone_id, zone=zone, @@ -76,12 +76,12 @@ class RiscoLocalSwitch(RiscoLocalZoneEntity, SwitchEntity): """Representation of a bypass switch for a Risco local zone.""" _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "bypassed" def __init__(self, system_id: str, zone_id: int, zone: Zone) -> None: """Init the zone.""" super().__init__( system_id=system_id, - name="Bypassed", suffix="_bypassed", zone_id=zone_id, zone=zone, From 480db1f1e66ea633f41be548abf54e5926873357 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 24 Aug 2023 19:58:54 +0200 Subject: [PATCH 058/124] Migrate Squeezebox to has entity name (#98948) --- .../components/squeezebox/media_player.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index d57ba8ba49d..c77126e4377 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -35,7 +35,7 @@ from homeassistant.helpers import ( entity_platform, ) from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.device_registry import DeviceInfo, format_mac from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -236,6 +236,8 @@ class SqueezeBoxEntity(MediaPlayerEntity): | MediaPlayerEntityFeature.GROUPING | MediaPlayerEntityFeature.MEDIA_ENQUEUE ) + _attr_has_entity_name = True + _attr_name = None def __init__(self, player): """Initialize the SqueezeBox device.""" @@ -244,6 +246,10 @@ class SqueezeBoxEntity(MediaPlayerEntity): self._query_result = {} self._available = True self._remove_dispatcher = None + self._attr_unique_id = format_mac(self._player.player_id) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._attr_unique_id)}, name=self._player.name + ) @property def extra_state_attributes(self): @@ -256,16 +262,6 @@ class SqueezeBoxEntity(MediaPlayerEntity): return squeezebox_attr - @property - def name(self): - """Return the name of the device.""" - return self._player.name - - @property - def unique_id(self): - """Return a unique ID.""" - return format_mac(self._player.player_id) - @property def available(self): """Return True if device connected to LMS server.""" From 7575ffa24e3c1c84062735946188ea42b547bfb2 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 24 Aug 2023 19:59:34 +0200 Subject: [PATCH 059/124] Add entity translations to Tankerkoenig (#98961) --- .../components/tankerkoenig/__init__.py | 2 ++ .../components/tankerkoenig/binary_sensor.py | 4 +--- .../components/tankerkoenig/sensor.py | 5 ++--- .../components/tankerkoenig/strings.json | 18 ++++++++++++++++++ 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/tankerkoenig/__init__.py b/homeassistant/components/tankerkoenig/__init__.py index f1dbc26fc3a..39ae0c2fc16 100644 --- a/homeassistant/components/tankerkoenig/__init__.py +++ b/homeassistant/components/tankerkoenig/__init__.py @@ -170,6 +170,8 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator): class TankerkoenigCoordinatorEntity(CoordinatorEntity): """Tankerkoenig base entity.""" + _attr_has_entity_name = True + def __init__( self, coordinator: TankerkoenigDataUpdateCoordinator, station: dict ) -> None: diff --git a/homeassistant/components/tankerkoenig/binary_sensor.py b/homeassistant/components/tankerkoenig/binary_sensor.py index 5f10b54f704..a6a79fd2d92 100644 --- a/homeassistant/components/tankerkoenig/binary_sensor.py +++ b/homeassistant/components/tankerkoenig/binary_sensor.py @@ -43,6 +43,7 @@ class StationOpenBinarySensorEntity(TankerkoenigCoordinatorEntity, BinarySensorE """Shows if a station is open or closed.""" _attr_device_class = BinarySensorDeviceClass.DOOR + _attr_translation_key = "status" def __init__( self, @@ -53,9 +54,6 @@ class StationOpenBinarySensorEntity(TankerkoenigCoordinatorEntity, BinarySensorE """Initialize the sensor.""" super().__init__(coordinator, station) self._station_id = station["id"] - self._attr_name = ( - f"{station['brand']} {station['street']} {station['houseNumber']} status" - ) self._attr_unique_id = f"{station['id']}_status" if show_on_map: self._attr_extra_state_attributes = { diff --git a/homeassistant/components/tankerkoenig/sensor.py b/homeassistant/components/tankerkoenig/sensor.py index 1638a8c3abb..af21ac4b6d6 100644 --- a/homeassistant/components/tankerkoenig/sensor.py +++ b/homeassistant/components/tankerkoenig/sensor.py @@ -20,7 +20,6 @@ from .const import ( ATTR_STREET, ATTRIBUTION, DOMAIN, - FUEL_TYPES, ) _LOGGER = logging.getLogger(__name__) @@ -59,6 +58,7 @@ class FuelPriceSensor(TankerkoenigCoordinatorEntity, SensorEntity): _attr_attribution = ATTRIBUTION _attr_state_class = SensorStateClass.MEASUREMENT + _attr_native_unit_of_measurement = CURRENCY_EURO _attr_icon = "mdi:gas-station" def __init__(self, fuel_type, station, coordinator, show_on_map): @@ -66,8 +66,7 @@ class FuelPriceSensor(TankerkoenigCoordinatorEntity, SensorEntity): super().__init__(coordinator, station) self._station_id = station["id"] self._fuel_type = fuel_type - self._attr_name = f"{station['brand']} {station['street']} {station['houseNumber']} {FUEL_TYPES[fuel_type]}" - self._attr_native_unit_of_measurement = CURRENCY_EURO + self._attr_translation_key = fuel_type self._attr_unique_id = f"{station['id']}_{fuel_type}" attrs = { ATTR_BRAND: station["brand"], diff --git a/homeassistant/components/tankerkoenig/strings.json b/homeassistant/components/tankerkoenig/strings.json index dea370f45b3..43d444b2c46 100644 --- a/homeassistant/components/tankerkoenig/strings.json +++ b/homeassistant/components/tankerkoenig/strings.json @@ -43,5 +43,23 @@ } } } + }, + "entity": { + "binary_sensor": { + "status": { + "name": "Status" + } + }, + "sensor": { + "e5": { + "name": "Super" + }, + "e10": { + "name": "Super E10" + }, + "diesel": { + "name": "Diesel" + } + } } } From a5cced1da95729924e11894ddaaddcf057e0b0a7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 24 Aug 2023 20:07:02 +0200 Subject: [PATCH 060/124] Add device to Tile (#98964) --- homeassistant/components/tile/device_tracker.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tile/device_tracker.py b/homeassistant/components/tile/device_tracker.py index 8dba892de83..81d3fb00c6e 100644 --- a/homeassistant/components/tile/device_tracker.py +++ b/homeassistant/components/tile/device_tracker.py @@ -13,6 +13,7 @@ from homeassistant.components.device_tracker import ( from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import ( @@ -82,6 +83,8 @@ class TileDeviceTracker(CoordinatorEntity[DataUpdateCoordinator[None]], TrackerE """Representation of a network infrastructure device.""" _attr_icon = DEFAULT_ICON + _attr_has_entity_name = True + _attr_name = None def __init__( self, entry: ConfigEntry, coordinator: DataUpdateCoordinator[None], tile: Tile @@ -90,7 +93,6 @@ class TileDeviceTracker(CoordinatorEntity[DataUpdateCoordinator[None]], TrackerE super().__init__(coordinator) self._attr_extra_state_attributes = {} - self._attr_name = tile.name self._attr_unique_id = f"{entry.data[CONF_USERNAME]}_{tile.uuid}" self._entry = entry self._tile = tile @@ -110,6 +112,11 @@ class TileDeviceTracker(CoordinatorEntity[DataUpdateCoordinator[None]], TrackerE return super().location_accuracy return int(self._tile.accuracy) + @property + def device_info(self) -> DeviceInfo: + """Return device info.""" + return DeviceInfo(identifiers={(DOMAIN, self._tile.uuid)}, name=self._tile.name) + @property def latitude(self) -> float | None: """Return latitude value of the device.""" From 948b34b045a2364f14abba4dd3669829c8f7672f Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 24 Aug 2023 20:09:14 +0200 Subject: [PATCH 061/124] Do not force update mqtt device_tracker (#98838) --- .../components/mqtt/device_tracker.py | 5 +++++ tests/components/mqtt/test_device_tracker.py | 22 ++++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index dd4eca9878a..67355d9bca5 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -165,6 +165,11 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): }, ) + @property + def force_update(self) -> bool: + """Do not force updates if the state is the same.""" + return False + async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" await subscription.async_subscribe_topics(self.hass, self._sub_state) diff --git a/tests/components/mqtt/test_device_tracker.py b/tests/components/mqtt/test_device_tracker.py index ddce53bfca0..8485e5578fe 100644 --- a/tests/components/mqtt/test_device_tracker.py +++ b/tests/components/mqtt/test_device_tracker.py @@ -1,6 +1,8 @@ """The tests for the MQTT device_tracker platform.""" +from datetime import UTC, datetime from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components import device_tracker, mqtt @@ -199,9 +201,10 @@ async def test_duplicate_device_tracker_removal( async def test_device_tracker_discovery_update( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, ) -> None: """Test for a discovery update event.""" + freezer.move_to("2023-08-22 19:15:00+00:00") await mqtt_mock_entry() async_fire_mqtt_message( hass, @@ -213,7 +216,9 @@ async def test_device_tracker_discovery_update( state = hass.states.get("device_tracker.beer") assert state is not None assert state.name == "Beer" + assert state.last_updated == datetime(2023, 8, 22, 19, 15, tzinfo=UTC) + freezer.move_to("2023-08-22 19:16:00+00:00") async_fire_mqtt_message( hass, "homeassistant/device_tracker/bla/config", @@ -224,6 +229,21 @@ async def test_device_tracker_discovery_update( state = hass.states.get("device_tracker.beer") assert state is not None assert state.name == "Cider" + assert state.last_updated == datetime(2023, 8, 22, 19, 16, tzinfo=UTC) + + freezer.move_to("2023-08-22 19:20:00+00:00") + async_fire_mqtt_message( + hass, + "homeassistant/device_tracker/bla/config", + '{ "name": "Cider", "state_topic": "test-topic" }', + ) + await hass.async_block_till_done() + + state = hass.states.get("device_tracker.beer") + assert state is not None + assert state.name == "Cider" + # Entity was not updated as the state was not changed + assert state.last_updated == datetime(2023, 8, 22, 19, 16, tzinfo=UTC) async def test_cleanup_device_tracker( From 417fd0838a74a26f695ee413d8e59460983dcc96 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 24 Aug 2023 21:05:00 +0200 Subject: [PATCH 062/124] Migrate Snooz to has entity name (#98940) --- homeassistant/components/snooz/fan.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/snooz/fan.py b/homeassistant/components/snooz/fan.py index a34989d1a03..c5b3e5b5b69 100644 --- a/homeassistant/components/snooz/fan.py +++ b/homeassistant/components/snooz/fan.py @@ -21,6 +21,7 @@ from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -71,15 +72,18 @@ async def async_setup_entry( class SnoozFan(FanEntity, RestoreEntity): """Fan representation of a Snooz device.""" + _attr_has_entity_name = True + _attr_name = None + def __init__(self, data: SnoozConfigurationData) -> None: """Initialize a Snooz fan entity.""" self._device = data.device - self._attr_name = data.title self._attr_unique_id = data.device.address self._attr_supported_features = FanEntityFeature.SET_SPEED self._attr_should_poll = False self._is_on: bool | None = None self._percentage: int | None = None + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, data.device.address)}) @callback def _async_write_state_changed(self) -> None: From f2c475cf1ba34cd1e0c619b4de6212fe96b2643c Mon Sep 17 00:00:00 2001 From: mkmer Date: Thu, 24 Aug 2023 15:13:42 -0400 Subject: [PATCH 063/124] Bump aiosomecomfort to 0.0.17 (#98978) * Clean up imports Add refresh after login in update * Bump somecomfort 0.0.17 Separate Somecomfort error to unauthorized * Add tests * Run Black format --- homeassistant/components/honeywell/climate.py | 24 +++++++++------ .../components/honeywell/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/honeywell/test_climate.py | 30 +++++++++++++++---- 5 files changed, 43 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 6bfefcf3a8c..b23df9f1f4b 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -6,7 +6,8 @@ import datetime from typing import Any from aiohttp import ClientConnectionError -import aiosomecomfort +from aiosomecomfort import SomeComfortError, UnauthorizedError, UnexpectedResponse +from aiosomecomfort.device import Device as SomeComfortDevice from homeassistant.components.climate import ( ATTR_TARGET_TEMP_HIGH, @@ -106,7 +107,7 @@ class HoneywellUSThermostat(ClimateEntity): def __init__( self, data: HoneywellData, - device: aiosomecomfort.device.Device, + device: SomeComfortDevice, cool_away_temp: int | None, heat_away_temp: int | None, ) -> None: @@ -312,7 +313,7 @@ class HoneywellUSThermostat(ClimateEntity): if mode == "heat": await self._device.set_setpoint_heat(temperature) - except aiosomecomfort.SomeComfortError as err: + except SomeComfortError as err: _LOGGER.error("Invalid temperature %.1f: %s", temperature, err) async def async_set_temperature(self, **kwargs: Any) -> None: @@ -325,7 +326,7 @@ class HoneywellUSThermostat(ClimateEntity): if temperature := kwargs.get(ATTR_TARGET_TEMP_LOW): await self._device.set_setpoint_heat(temperature) - except aiosomecomfort.SomeComfortError as err: + except SomeComfortError as err: _LOGGER.error("Invalid temperature %.1f: %s", temperature, err) async def async_set_fan_mode(self, fan_mode: str) -> None: @@ -354,7 +355,7 @@ class HoneywellUSThermostat(ClimateEntity): if mode in HEATING_MODES: await self._device.set_hold_heat(True, self._heat_away_temp) - except aiosomecomfort.SomeComfortError: + except SomeComfortError: _LOGGER.error( "Temperature out of range. Mode: %s, Heat Temperature: %.1f, Cool Temperature: %.1f", mode, @@ -375,7 +376,7 @@ class HoneywellUSThermostat(ClimateEntity): if mode in HEATING_MODES: await self._device.set_hold_heat(True) - except aiosomecomfort.SomeComfortError: + except SomeComfortError: _LOGGER.error("Couldn't set permanent hold") else: _LOGGER.error("Invalid system mode returned: %s", mode) @@ -387,7 +388,7 @@ class HoneywellUSThermostat(ClimateEntity): # Disabling all hold modes await self._device.set_hold_cool(False) await self._device.set_hold_heat(False) - except aiosomecomfort.SomeComfortError: + except SomeComfortError: _LOGGER.error("Can not stop hold mode") async def async_set_preset_mode(self, preset_mode: str) -> None: @@ -416,12 +417,14 @@ class HoneywellUSThermostat(ClimateEntity): try: await self._device.refresh() self._attr_available = True - except aiosomecomfort.SomeComfortError: + except UnauthorizedError: try: await self._data.client.login() + await self._device.refresh() + self._attr_available = True except ( - aiosomecomfort.SomeComfortError, + SomeComfortError, ClientConnectionError, asyncio.TimeoutError, ): @@ -429,3 +432,6 @@ class HoneywellUSThermostat(ClimateEntity): except (ClientConnectionError, asyncio.TimeoutError): self._attr_available = False + + except UnexpectedResponse: + pass diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index bb72c15cd46..a53eaaab8ce 100644 --- a/homeassistant/components/honeywell/manifest.json +++ b/homeassistant/components/honeywell/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/honeywell", "iot_class": "cloud_polling", "loggers": ["somecomfort"], - "requirements": ["AIOSomecomfort==0.0.16"] + "requirements": ["AIOSomecomfort==0.0.17"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8ef933870a0..be073ac19ab 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -8,7 +8,7 @@ AEMET-OpenData==0.3.0 AIOAladdinConnect==0.1.57 # homeassistant.components.honeywell -AIOSomecomfort==0.0.16 +AIOSomecomfort==0.0.17 # homeassistant.components.adax Adax-local==0.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 57ddafb4eba..7b40e29d86e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -10,7 +10,7 @@ AEMET-OpenData==0.3.0 AIOAladdinConnect==0.1.57 # homeassistant.components.honeywell -AIOSomecomfort==0.0.16 +AIOSomecomfort==0.0.17 # homeassistant.components.adax Adax-local==0.1.5 diff --git a/tests/components/honeywell/test_climate.py b/tests/components/honeywell/test_climate.py index 4d6989d79e8..b8facc54d43 100644 --- a/tests/components/honeywell/test_climate.py +++ b/tests/components/honeywell/test_climate.py @@ -1010,8 +1010,8 @@ async def test_async_update_errors( await init_integration(hass, config_entry) - device.refresh.side_effect = aiosomecomfort.SomeComfortError - client.login.side_effect = aiosomecomfort.SomeComfortError + device.refresh.side_effect = aiosomecomfort.UnauthorizedError + client.login.side_effect = aiosomecomfort.AuthError entity_id = f"climate.{device.name}" state = hass.states.get(entity_id) assert state.state == "off" @@ -1037,6 +1037,28 @@ async def test_async_update_errors( state = hass.states.get(entity_id) assert state.state == "off" + device.refresh.side_effect = aiosomecomfort.UnexpectedResponse + client.login.side_effect = None + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == "off" + + device.refresh.side_effect = [aiosomecomfort.UnauthorizedError, None] + client.login.side_effect = None + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == "off" + # "reload integration" test device.refresh.side_effect = aiosomecomfort.SomeComfortError client.login.side_effect = aiosomecomfort.AuthError @@ -1046,9 +1068,8 @@ async def test_async_update_errors( ) await hass.async_block_till_done() - entity_id = f"climate.{device.name}" state = hass.states.get(entity_id) - assert state.state == "unavailable" + assert state.state == "off" device.refresh.side_effect = ClientConnectionError async_fire_time_changed( @@ -1057,7 +1078,6 @@ async def test_async_update_errors( ) await hass.async_block_till_done() - entity_id = f"climate.{device.name}" state = hass.states.get(entity_id) assert state.state == "unavailable" From b03ffe6a6ace990486e1657c8a05432acd4c76fe Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Fri, 25 Aug 2023 07:57:52 +1200 Subject: [PATCH 064/124] Electric Kiwi: Fix time for installations in UTC (#97881) --- homeassistant/components/electric_kiwi/sensor.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/electric_kiwi/sensor.py b/homeassistant/components/electric_kiwi/sensor.py index a3943437d4f..8c983b92dd5 100644 --- a/homeassistant/components/electric_kiwi/sensor.py +++ b/homeassistant/components/electric_kiwi/sensor.py @@ -46,16 +46,18 @@ class ElectricKiwiHOPSensorEntityDescription( def _check_and_move_time(hop: Hop, time: str) -> datetime: """Return the time a day forward if HOP end_time is in the past.""" date_time = datetime.combine( - datetime.today(), + dt_util.start_of_local_day(), datetime.strptime(time, "%I:%M %p").time(), - ).astimezone(dt_util.DEFAULT_TIME_ZONE) + dt_util.DEFAULT_TIME_ZONE, + ) end_time = datetime.combine( - datetime.today(), + dt_util.start_of_local_day(), datetime.strptime(hop.end.end_time, "%I:%M %p").time(), - ).astimezone(dt_util.DEFAULT_TIME_ZONE) + dt_util.DEFAULT_TIME_ZONE, + ) - if end_time < datetime.now().astimezone(dt_util.DEFAULT_TIME_ZONE): + if end_time < dt_util.now(): return date_time + timedelta(days=1) return date_time From d7adc2621dddc9b1f1b5f72223834fcc704ac5ab Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 24 Aug 2023 22:03:26 +0200 Subject: [PATCH 065/124] Migrate Life360 to has entity name (#98796) --- homeassistant/components/life360/device_tracker.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/life360/device_tracker.py b/homeassistant/components/life360/device_tracker.py index 27b4ce291fd..ee097b9e989 100644 --- a/homeassistant/components/life360/device_tracker.py +++ b/homeassistant/components/life360/device_tracker.py @@ -100,6 +100,8 @@ class Life360DeviceTracker( _attr_attribution = ATTRIBUTION _attr_unique_id: str + _attr_has_entity_name = True + _attr_name = None def __init__( self, coordinator: Life360DataUpdateCoordinator, member_id: str @@ -111,7 +113,6 @@ class Life360DeviceTracker( self._data: Life360Member | None = coordinator.data.members[member_id] self._prev_data = self._data - self._attr_name = self._data.name self._name = self._data.name self._attr_entity_picture = self._data.entity_picture From 54ed8fc914dce70149dca071b133df85292bf82c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 24 Aug 2023 22:19:29 +0200 Subject: [PATCH 066/124] Use device class translations for 1-wire (#98813) --- homeassistant/components/onewire/sensor.py | 16 ------- homeassistant/components/onewire/strings.json | 15 ------ .../onewire/snapshots/test_sensor.ambr | 46 +++++++++---------- 3 files changed, 23 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index 65bd542fc30..34ed66bd511 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -73,7 +73,6 @@ SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION = OneWireSensorEntityDescription( native_unit_of_measurement=UnitOfTemperature.CELSIUS, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, - translation_key="temperature", ) _LOGGER = logging.getLogger(__name__) @@ -89,7 +88,6 @@ DEVICE_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { native_unit_of_measurement=UnitOfTemperature.CELSIUS, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, - translation_key="temperature", ), OneWireSensorEntityDescription( key="TAI8570/pressure", @@ -98,7 +96,6 @@ DEVICE_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { native_unit_of_measurement=UnitOfPressure.MBAR, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, - translation_key="pressure", ), ), "22": (SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION,), @@ -111,7 +108,6 @@ DEVICE_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { native_unit_of_measurement=PERCENTAGE, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, - translation_key="humidity", ), OneWireSensorEntityDescription( key="HIH3600/humidity", @@ -156,7 +152,6 @@ DEVICE_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { native_unit_of_measurement=UnitOfPressure.MBAR, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, - translation_key="pressure", ), OneWireSensorEntityDescription( key="S3-R1-A/illuminance", @@ -165,7 +160,6 @@ DEVICE_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { native_unit_of_measurement=LIGHT_LUX, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, - translation_key="illuminance", ), OneWireSensorEntityDescription( key="VAD", @@ -203,7 +197,6 @@ DEVICE_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { override_key=_get_sensor_precision_family_28, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, - translation_key="temperature", ), ), "30": ( @@ -225,7 +218,6 @@ DEVICE_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { native_unit_of_measurement=UnitOfElectricPotential.VOLT, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, - translation_key="voltage", ), OneWireSensorEntityDescription( key="vis", @@ -261,7 +253,6 @@ HOBBYBOARD_EF: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { native_unit_of_measurement=PERCENTAGE, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, - translation_key="humidity", ), OneWireSensorEntityDescription( key="humidity/humidity_raw", @@ -277,7 +268,6 @@ HOBBYBOARD_EF: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { native_unit_of_measurement=UnitOfTemperature.CELSIUS, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, - translation_key="temperature", ), ), "HB_MOISTURE_METER": tuple( @@ -303,7 +293,6 @@ EDS_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { native_unit_of_measurement=UnitOfTemperature.CELSIUS, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, - translation_key="temperature", ), OneWireSensorEntityDescription( key="EDS0066/pressure", @@ -311,7 +300,6 @@ EDS_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { native_unit_of_measurement=UnitOfPressure.MBAR, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, - translation_key="pressure", ), ), "EDS0068": ( @@ -321,7 +309,6 @@ EDS_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { native_unit_of_measurement=UnitOfTemperature.CELSIUS, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, - translation_key="temperature", ), OneWireSensorEntityDescription( key="EDS0068/pressure", @@ -329,7 +316,6 @@ EDS_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { native_unit_of_measurement=UnitOfPressure.MBAR, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, - translation_key="pressure", ), OneWireSensorEntityDescription( key="EDS0068/light", @@ -337,7 +323,6 @@ EDS_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { native_unit_of_measurement=LIGHT_LUX, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, - translation_key="illuminance", ), OneWireSensorEntityDescription( key="EDS0068/humidity", @@ -345,7 +330,6 @@ EDS_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { native_unit_of_measurement=PERCENTAGE, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, - translation_key="humidity", ), ), } diff --git a/homeassistant/components/onewire/strings.json b/homeassistant/components/onewire/strings.json index f58731a2377..9e4120b68b2 100644 --- a/homeassistant/components/onewire/strings.json +++ b/homeassistant/components/onewire/strings.json @@ -68,9 +68,6 @@ "counter_b": { "name": "Counter B" }, - "humidity": { - "name": "[%key:component::sensor::entity_component::humidity::name%]" - }, "humidity_hih3600": { "name": "HIH3600 humidity" }, @@ -86,9 +83,6 @@ "humidity_raw": { "name": "Raw humidity" }, - "illuminance": { - "name": "[%key:component::sensor::entity_component::illuminance::name%]" - }, "moisture_1": { "name": "Moisture 1" }, @@ -101,18 +95,9 @@ "moisture_4": { "name": "Moisture 4" }, - "pressure": { - "name": "[%key:component::sensor::entity_component::pressure::name%]" - }, - "temperature": { - "name": "[%key:component::sensor::entity_component::temperature::name%]" - }, "thermocouple_temperature_k": { "name": "Thermocouple K temperature" }, - "voltage": { - "name": "[%key:component::sensor::entity_component::voltage::name%]" - }, "voltage_vad": { "name": "VAD voltage" }, diff --git a/tests/components/onewire/snapshots/test_sensor.ambr b/tests/components/onewire/snapshots/test_sensor.ambr index 6c18c1ec652..0664d7e5402 100644 --- a/tests/components/onewire/snapshots/test_sensor.ambr +++ b/tests/components/onewire/snapshots/test_sensor.ambr @@ -105,7 +105,7 @@ 'original_name': 'Temperature', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'temperature', + 'translation_key': None, 'unique_id': '/10.111111111111/temperature', 'unit_of_measurement': , }), @@ -187,7 +187,7 @@ 'original_name': 'Temperature', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'temperature', + 'translation_key': None, 'unique_id': '/12.111111111111/TAI8570/temperature', 'unit_of_measurement': , }), @@ -217,7 +217,7 @@ 'original_name': 'Pressure', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'pressure', + 'translation_key': None, 'unique_id': '/12.111111111111/TAI8570/pressure', 'unit_of_measurement': , }), @@ -589,7 +589,7 @@ 'original_name': 'Temperature', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'temperature', + 'translation_key': None, 'unique_id': '/22.111111111111/temperature', 'unit_of_measurement': , }), @@ -671,7 +671,7 @@ 'original_name': 'Temperature', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'temperature', + 'translation_key': None, 'unique_id': '/26.111111111111/temperature', 'unit_of_measurement': , }), @@ -701,7 +701,7 @@ 'original_name': 'Humidity', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'humidity', + 'translation_key': None, 'unique_id': '/26.111111111111/humidity', 'unit_of_measurement': '%', }), @@ -851,7 +851,7 @@ 'original_name': 'Pressure', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'pressure', + 'translation_key': None, 'unique_id': '/26.111111111111/B1-R1-A/pressure', 'unit_of_measurement': , }), @@ -881,7 +881,7 @@ 'original_name': 'Illuminance', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'illuminance', + 'translation_key': None, 'unique_id': '/26.111111111111/S3-R1-A/illuminance', 'unit_of_measurement': 'lx', }), @@ -1203,7 +1203,7 @@ 'original_name': 'Temperature', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'temperature', + 'translation_key': None, 'unique_id': '/28.111111111111/temperature', 'unit_of_measurement': , }), @@ -1285,7 +1285,7 @@ 'original_name': 'Temperature', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'temperature', + 'translation_key': None, 'unique_id': '/28.222222222222/temperature', 'unit_of_measurement': , }), @@ -1367,7 +1367,7 @@ 'original_name': 'Temperature', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'temperature', + 'translation_key': None, 'unique_id': '/28.222222222223/temperature', 'unit_of_measurement': , }), @@ -1486,7 +1486,7 @@ 'original_name': 'Temperature', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'temperature', + 'translation_key': None, 'unique_id': '/30.111111111111/temperature', 'unit_of_measurement': , }), @@ -1546,7 +1546,7 @@ 'original_name': 'Voltage', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'voltage', + 'translation_key': None, 'unique_id': '/30.111111111111/volt', 'unit_of_measurement': , }), @@ -1740,7 +1740,7 @@ 'original_name': 'Temperature', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'temperature', + 'translation_key': None, 'unique_id': '/3B.111111111111/temperature', 'unit_of_measurement': , }), @@ -1822,7 +1822,7 @@ 'original_name': 'Temperature', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'temperature', + 'translation_key': None, 'unique_id': '/42.111111111111/temperature', 'unit_of_measurement': , }), @@ -1904,7 +1904,7 @@ 'original_name': 'Temperature', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'temperature', + 'translation_key': None, 'unique_id': '/7E.111111111111/EDS0068/temperature', 'unit_of_measurement': , }), @@ -1934,7 +1934,7 @@ 'original_name': 'Pressure', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'pressure', + 'translation_key': None, 'unique_id': '/7E.111111111111/EDS0068/pressure', 'unit_of_measurement': , }), @@ -1964,7 +1964,7 @@ 'original_name': 'Illuminance', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'illuminance', + 'translation_key': None, 'unique_id': '/7E.111111111111/EDS0068/light', 'unit_of_measurement': 'lx', }), @@ -1994,7 +1994,7 @@ 'original_name': 'Humidity', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'humidity', + 'translation_key': None, 'unique_id': '/7E.111111111111/EDS0068/humidity', 'unit_of_measurement': '%', }), @@ -2121,7 +2121,7 @@ 'original_name': 'Temperature', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'temperature', + 'translation_key': None, 'unique_id': '/7E.222222222222/EDS0066/temperature', 'unit_of_measurement': , }), @@ -2151,7 +2151,7 @@ 'original_name': 'Pressure', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'pressure', + 'translation_key': None, 'unique_id': '/7E.222222222222/EDS0066/pressure', 'unit_of_measurement': , }), @@ -2248,7 +2248,7 @@ 'original_name': 'Humidity', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'humidity', + 'translation_key': None, 'unique_id': '/EF.111111111111/humidity/humidity_corrected', 'unit_of_measurement': '%', }), @@ -2308,7 +2308,7 @@ 'original_name': 'Temperature', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'temperature', + 'translation_key': None, 'unique_id': '/EF.111111111111/humidity/temperature', 'unit_of_measurement': , }), From 4d8941d4b7b798892d7f3183d57906ec4e2f6674 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 24 Aug 2023 22:40:45 +0200 Subject: [PATCH 067/124] Use snapshot assertion for onvif diagnostics test (#98982) --- .../onvif/snapshots/test_diagnostics.ambr | 74 ++++++++++++++++ tests/components/onvif/test_diagnostics.py | 84 ++----------------- 2 files changed, 80 insertions(+), 78 deletions(-) create mode 100644 tests/components/onvif/snapshots/test_diagnostics.ambr diff --git a/tests/components/onvif/snapshots/test_diagnostics.ambr b/tests/components/onvif/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..e10c8791ba9 --- /dev/null +++ b/tests/components/onvif/snapshots/test_diagnostics.ambr @@ -0,0 +1,74 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config': dict({ + 'data': dict({ + 'host': '**REDACTED**', + 'name': 'TestCamera', + 'password': '**REDACTED**', + 'port': 80, + 'snapshot_auth': 'digest', + 'username': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'onvif', + 'entry_id': '1', + 'options': dict({ + 'enable_webhooks': True, + 'extra_arguments': '-pred 1', + 'rtsp_transport': 'tcp', + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': 'aa:bb:cc:dd:ee', + 'version': 1, + }), + 'device': dict({ + 'capabilities': dict({ + 'events': False, + 'imaging': True, + 'ptz': True, + 'snapshot': False, + }), + 'info': dict({ + 'fw_version': 'TestFirmwareVersion', + 'mac': 'aa:bb:cc:dd:ee', + 'manufacturer': 'TestManufacturer', + 'model': 'TestModel', + 'serial_number': 'ABCDEFGHIJK', + }), + 'profiles': list([ + dict({ + 'index': 0, + 'name': 'profile1', + 'ptz': None, + 'token': 'dummy', + 'video': dict({ + 'encoding': 'any', + 'resolution': dict({ + 'height': 480, + 'width': 640, + }), + }), + 'video_source_token': None, + }), + ]), + 'services': dict({ + }), + 'xaddrs': dict({ + }), + }), + 'events': dict({ + 'pullpoint_manager_state': dict({ + '__type': "", + 'repr': '', + }), + 'webhook_manager_state': dict({ + '__type': "", + 'repr': '', + }), + }), + }) +# --- diff --git a/tests/components/onvif/test_diagnostics.py b/tests/components/onvif/test_diagnostics.py index f87a5f0eff6..af7a68a6e0d 100644 --- a/tests/components/onvif/test_diagnostics.py +++ b/tests/components/onvif/test_diagnostics.py @@ -1,93 +1,21 @@ """Test ONVIF diagnostics.""" -from unittest.mock import ANY +from syrupy import SnapshotAssertion from homeassistant.core import HomeAssistant -from . import ( - FIRMWARE_VERSION, - MAC, - MANUFACTURER, - MODEL, - SERIAL_NUMBER, - setup_onvif_integration, -) +from . import setup_onvif_integration from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator async def test_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test generating diagnostics for a config entry.""" entry, _, _ = await setup_onvif_integration(hass) - diag = await get_diagnostics_for_config_entry(hass, hass_client, entry) - - assert diag == { - "config": { - "entry_id": "1", - "version": 1, - "domain": "onvif", - "title": "Mock Title", - "data": { - "name": "TestCamera", - "host": "**REDACTED**", - "port": 80, - "username": "**REDACTED**", - "password": "**REDACTED**", - "snapshot_auth": "digest", - }, - "options": { - "extra_arguments": "-pred 1", - "rtsp_transport": "tcp", - "enable_webhooks": True, - }, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "unique_id": "aa:bb:cc:dd:ee", - "disabled_by": None, - }, - "device": { - "info": { - "manufacturer": MANUFACTURER, - "model": MODEL, - "fw_version": FIRMWARE_VERSION, - "serial_number": SERIAL_NUMBER, - "mac": MAC, - }, - "capabilities": { - "snapshot": False, - "events": False, - "ptz": True, - "imaging": True, - }, - "profiles": [ - { - "index": 0, - "token": "dummy", - "name": "profile1", - "video": { - "encoding": "any", - "resolution": {"width": 640, "height": 480}, - }, - "ptz": None, - "video_source_token": None, - } - ], - "services": ANY, - "xaddrs": ANY, - }, - "events": { - "pullpoint_manager_state": { - "__type": "", - "repr": "", - }, - "webhook_manager_state": { - "__type": "", - "repr": "", - }, - }, - } + assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot From 3bcd1d5a1a444d603aa553a468c3e48afd52fed2 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 24 Aug 2023 23:26:21 +0200 Subject: [PATCH 068/124] Use snapshot assertion for iqvia diagnostics test (#98983) --- tests/components/iqvia/conftest.py | 7 +- .../iqvia/snapshots/test_diagnostics.ambr | 363 ++++++++++++++++++ tests/components/iqvia/test_diagnostics.py | 347 +---------------- 3 files changed, 381 insertions(+), 336 deletions(-) create mode 100644 tests/components/iqvia/snapshots/test_diagnostics.ambr diff --git a/tests/components/iqvia/conftest.py b/tests/components/iqvia/conftest.py index b6ac1724885..075d7249d36 100644 --- a/tests/components/iqvia/conftest.py +++ b/tests/components/iqvia/conftest.py @@ -13,7 +13,12 @@ from tests.common import MockConfigEntry, load_fixture @pytest.fixture(name="config_entry") def config_entry_fixture(hass, config): """Define a config entry fixture.""" - entry = MockConfigEntry(domain=DOMAIN, unique_id=config[CONF_ZIP_CODE], data=config) + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=config[CONF_ZIP_CODE], + data=config, + entry_id="690ac4b7e99855fc5ee7b987a758d5cb", + ) entry.add_to_hass(hass) return entry diff --git a/tests/components/iqvia/snapshots/test_diagnostics.ambr b/tests/components/iqvia/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..49006716fb3 --- /dev/null +++ b/tests/components/iqvia/snapshots/test_diagnostics.ambr @@ -0,0 +1,363 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'data': dict({ + 'allergy_average_forecasted': dict({ + 'ForecastDate': '2018-06-12T00:00:00-04:00', + 'Location': dict({ + 'City': '**REDACTED**', + 'DisplayLocation': '**REDACTED**', + 'State': '**REDACTED**', + 'ZIP': '**REDACTED**', + 'periods': list([ + dict({ + 'Index': 6.6, + 'Period': '2018-06-12T13:47:12.897', + }), + dict({ + 'Index': 6.3, + 'Period': '2018-06-13T13:47:12.897', + }), + dict({ + 'Index': 7.6, + 'Period': '2018-06-14T13:47:12.897', + }), + dict({ + 'Index': 7.6, + 'Period': '2018-06-15T13:47:12.897', + }), + dict({ + 'Index': 7.3, + 'Period': '2018-06-16T13:47:12.897', + }), + ]), + }), + 'Type': 'pollen', + }), + 'allergy_index': dict({ + 'ForecastDate': '2018-06-12T00:00:00-04:00', + 'Location': dict({ + 'City': '**REDACTED**', + 'DisplayLocation': '**REDACTED**', + 'State': '**REDACTED**', + 'ZIP': '**REDACTED**', + 'periods': list([ + dict({ + 'Index': 7.2, + 'Period': '0001-01-01T00:00:00', + 'Triggers': list([ + dict({ + 'Genus': 'Juniperus', + 'LGID': 272, + 'Name': 'Juniper', + 'PlantType': 'Tree', + }), + dict({ + 'Genus': 'Grasses', + 'LGID': 346, + 'Name': 'Grasses', + 'PlantType': 'Grass', + }), + dict({ + 'Genus': 'Chenopods', + 'LGID': 63, + 'Name': 'Chenopods', + 'PlantType': 'Ragweed', + }), + ]), + 'Type': 'Yesterday', + }), + dict({ + 'Index': 6.6, + 'Period': '0001-01-01T00:00:00', + 'Triggers': list([ + dict({ + 'Genus': 'Juniperus', + 'LGID': 272, + 'Name': 'Juniper', + 'PlantType': 'Tree', + }), + dict({ + 'Genus': 'Grasses', + 'LGID': 346, + 'Name': 'Grasses', + 'PlantType': 'Grass', + }), + dict({ + 'Genus': 'Chenopods', + 'LGID': 63, + 'Name': 'Chenopods', + 'PlantType': 'Ragweed', + }), + ]), + 'Type': 'Today', + }), + dict({ + 'Index': 6.3, + 'Period': '0001-01-01T00:00:00', + 'Triggers': list([ + dict({ + 'Genus': 'Juniperus', + 'LGID': 272, + 'Name': 'Juniper', + 'PlantType': 'Tree', + }), + dict({ + 'Genus': 'Grasses', + 'LGID': 346, + 'Name': 'Grasses', + 'PlantType': 'Grass', + }), + dict({ + 'Genus': 'Chenopods', + 'LGID': 63, + 'Name': 'Chenopods', + 'PlantType': 'Ragweed', + }), + ]), + 'Type': 'Tomorrow', + }), + ]), + }), + 'Type': 'pollen', + }), + 'allergy_outlook': dict({ + 'Market': '**REDACTED**', + 'Outlook': 'The amount of pollen in the air for Wednesday...', + 'Season': 'Tree', + 'Trend': 'subsiding', + 'TrendID': 4, + 'ZIP': '**REDACTED**', + }), + 'asthma_average_forecasted': dict({ + 'ForecastDate': '2018-10-28T00:00:00-04:00', + 'Location': dict({ + 'City': '**REDACTED**', + 'DisplayLocation': '**REDACTED**', + 'State': '**REDACTED**', + 'ZIP': '**REDACTED**', + 'periods': list([ + dict({ + 'Idx': '4.5', + 'Index': 4.5, + 'Period': '2018-10-28T05:45:01.45', + }), + dict({ + 'Idx': '4.7', + 'Index': 4.7, + 'Period': '2018-10-29T05:45:01.45', + }), + dict({ + 'Idx': '5.0', + 'Index': 5, + 'Period': '2018-10-30T05:45:01.45', + }), + dict({ + 'Idx': '5.2', + 'Index': 5.2, + 'Period': '2018-10-31T05:45:01.45', + }), + dict({ + 'Idx': '5.5', + 'Index': 5.5, + 'Period': '2018-11-01T05:45:01.45', + }), + ]), + }), + 'Type': 'asthma', + }), + 'asthma_index': dict({ + 'ForecastDate': '2018-10-29T00:00:00-04:00', + 'Location': dict({ + 'City': '**REDACTED**', + 'DisplayLocation': '**REDACTED**', + 'State': '**REDACTED**', + 'ZIP': '**REDACTED**', + 'periods': list([ + dict({ + 'Idx': '4.1', + 'Index': 4.1, + 'Period': '0001-01-01T00:00:00', + 'Triggers': list([ + dict({ + 'Description': 'Ozone (O3) is a odorless, colorless ....', + 'LGID': 1, + 'Name': 'OZONE', + 'PPM': 42, + }), + dict({ + 'Description': 'Fine particles (PM2.5) are 2.5 ...', + 'LGID': 1, + 'Name': 'PM2.5', + 'PPM': 30, + }), + dict({ + 'Description': 'Coarse dust particles (PM10) are 2.5 ...', + 'LGID': 1, + 'Name': 'PM10', + 'PPM': 19, + }), + ]), + 'Type': 'Yesterday', + }), + dict({ + 'Idx': '4.5', + 'Index': 4.5, + 'Period': '0001-01-01T00:00:00', + 'Triggers': list([ + dict({ + 'Description': 'Fine particles (PM2.5) are 2.5 ...', + 'LGID': 3, + 'Name': 'PM2.5', + 'PPM': 105, + }), + dict({ + 'Description': 'Coarse dust particles (PM10) are 2.5 ...', + 'LGID': 2, + 'Name': 'PM10', + 'PPM': 65, + }), + dict({ + 'Description': 'Ozone (O3) is a odorless, colorless ...', + 'LGID': 1, + 'Name': 'OZONE', + 'PPM': 42, + }), + ]), + 'Type': 'Today', + }), + dict({ + 'Idx': '4.6', + 'Index': 4.6, + 'Period': '0001-01-01T00:00:00', + 'Triggers': list([ + ]), + 'Type': 'Tomorrow', + }), + ]), + }), + 'Type': 'asthma', + }), + 'disease_average_forecasted': dict({ + 'ForecastDate': '2018-06-12T00:00:00-04:00', + 'Location': dict({ + 'City': '**REDACTED**', + 'DisplayLocation': '**REDACTED**', + 'State': '**REDACTED**', + 'ZIP': '**REDACTED**', + 'periods': list([ + dict({ + 'Index': 2.4, + 'Period': '2018-06-12T05:13:51.817', + }), + dict({ + 'Index': 2.5, + 'Period': '2018-06-13T05:13:51.817', + }), + dict({ + 'Index': 2.5, + 'Period': '2018-06-14T05:13:51.817', + }), + dict({ + 'Index': 2.5, + 'Period': '2018-06-15T05:13:51.817', + }), + ]), + }), + 'Type': 'cold', + }), + 'disease_index': dict({ + 'ForecastDate': '2019-04-07T00:00:00-04:00', + 'Location': dict({ + 'City': '**REDACTED**', + 'DisplayLocation': '**REDACTED**', + 'State': '**REDACTED**', + 'ZIP': '**REDACTED**', + 'periods': list([ + dict({ + 'Idx': '6.8', + 'Index': 6.8, + 'Period': '2019-04-06T00:00:00', + 'Triggers': list([ + dict({ + 'Description': 'Influenza', + 'Idx': '3.1', + 'Index': 3.1, + 'Name': 'Flu', + }), + dict({ + 'Description': 'High Fever', + 'Idx': '6.2', + 'Index': 6.2, + 'Name': 'Fever', + }), + dict({ + 'Description': 'Strep & Sore throat', + 'Idx': '5.2', + 'Index': 5.2, + 'Name': 'Strep', + }), + dict({ + 'Description': 'Cough', + 'Idx': '7.8', + 'Index': 7.8, + 'Name': 'Cough', + }), + ]), + 'Type': 'Yesterday', + }), + dict({ + 'Idx': '6.7', + 'Index': 6.7, + 'Period': '2019-04-07T03:52:58', + 'Triggers': list([ + dict({ + 'Description': 'Influenza', + 'Idx': '3.1', + 'Index': 3.1, + 'Name': 'Flu', + }), + dict({ + 'Description': 'High Fever', + 'Idx': '5.9', + 'Index': 5.9, + 'Name': 'Fever', + }), + dict({ + 'Description': 'Strep & Sore throat', + 'Idx': '5.1', + 'Index': 5.1, + 'Name': 'Strep', + }), + dict({ + 'Description': 'Cough', + 'Idx': '7.7', + 'Index': 7.7, + 'Name': 'Cough', + }), + ]), + 'Type': 'Today', + }), + ]), + }), + 'Type': 'cold', + }), + }), + 'entry': dict({ + 'data': dict({ + 'zip_code': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'iqvia', + 'entry_id': '690ac4b7e99855fc5ee7b987a758d5cb', + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': '**REDACTED**', + 'unique_id': '**REDACTED**', + 'version': 1, + }), + }) +# --- diff --git a/tests/components/iqvia/test_diagnostics.py b/tests/components/iqvia/test_diagnostics.py index 2acf37d6642..bde2af57447 100644 --- a/tests/components/iqvia/test_diagnostics.py +++ b/tests/components/iqvia/test_diagnostics.py @@ -1,5 +1,6 @@ """Test IQVIA diagnostics.""" -from homeassistant.components.diagnostics import REDACTED +from syrupy import SnapshotAssertion + from homeassistant.core import HomeAssistant from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -7,339 +8,15 @@ from tests.typing import ClientSessionGenerator async def test_entry_diagnostics( - hass: HomeAssistant, config_entry, hass_client: ClientSessionGenerator, setup_iqvia + hass: HomeAssistant, + config_entry, + hass_client: ClientSessionGenerator, + setup_iqvia, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "entry": { - "entry_id": config_entry.entry_id, - "version": 1, - "domain": "iqvia", - "title": REDACTED, - "data": {"zip_code": REDACTED}, - "options": {}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "unique_id": REDACTED, - "disabled_by": None, - }, - "data": { - "allergy_average_forecasted": { - "Type": "pollen", - "ForecastDate": "2018-06-12T00:00:00-04:00", - "Location": { - "ZIP": REDACTED, - "City": REDACTED, - "State": REDACTED, - "periods": [ - {"Period": "2018-06-12T13:47:12.897", "Index": 6.6}, - {"Period": "2018-06-13T13:47:12.897", "Index": 6.3}, - {"Period": "2018-06-14T13:47:12.897", "Index": 7.6}, - {"Period": "2018-06-15T13:47:12.897", "Index": 7.6}, - {"Period": "2018-06-16T13:47:12.897", "Index": 7.3}, - ], - "DisplayLocation": REDACTED, - }, - }, - "allergy_index": { - "Type": "pollen", - "ForecastDate": "2018-06-12T00:00:00-04:00", - "Location": { - "ZIP": REDACTED, - "City": REDACTED, - "State": REDACTED, - "periods": [ - { - "Triggers": [ - { - "LGID": 272, - "Name": "Juniper", - "Genus": "Juniperus", - "PlantType": "Tree", - }, - { - "LGID": 346, - "Name": "Grasses", - "Genus": "Grasses", - "PlantType": "Grass", - }, - { - "LGID": 63, - "Name": "Chenopods", - "Genus": "Chenopods", - "PlantType": "Ragweed", - }, - ], - "Period": "0001-01-01T00:00:00", - "Type": "Yesterday", - "Index": 7.2, - }, - { - "Triggers": [ - { - "LGID": 272, - "Name": "Juniper", - "Genus": "Juniperus", - "PlantType": "Tree", - }, - { - "LGID": 346, - "Name": "Grasses", - "Genus": "Grasses", - "PlantType": "Grass", - }, - { - "LGID": 63, - "Name": "Chenopods", - "Genus": "Chenopods", - "PlantType": "Ragweed", - }, - ], - "Period": "0001-01-01T00:00:00", - "Type": "Today", - "Index": 6.6, - }, - { - "Triggers": [ - { - "LGID": 272, - "Name": "Juniper", - "Genus": "Juniperus", - "PlantType": "Tree", - }, - { - "LGID": 346, - "Name": "Grasses", - "Genus": "Grasses", - "PlantType": "Grass", - }, - { - "LGID": 63, - "Name": "Chenopods", - "Genus": "Chenopods", - "PlantType": "Ragweed", - }, - ], - "Period": "0001-01-01T00:00:00", - "Type": "Tomorrow", - "Index": 6.3, - }, - ], - "DisplayLocation": REDACTED, - }, - }, - "allergy_outlook": { - "Market": REDACTED, - "ZIP": REDACTED, - "TrendID": 4, - "Trend": "subsiding", - "Outlook": "The amount of pollen in the air for Wednesday...", - "Season": "Tree", - }, - "asthma_average_forecasted": { - "Type": "asthma", - "ForecastDate": "2018-10-28T00:00:00-04:00", - "Location": { - "ZIP": REDACTED, - "City": REDACTED, - "State": REDACTED, - "periods": [ - { - "Period": "2018-10-28T05:45:01.45", - "Index": 4.5, - "Idx": "4.5", - }, - { - "Period": "2018-10-29T05:45:01.45", - "Index": 4.7, - "Idx": "4.7", - }, - {"Period": "2018-10-30T05:45:01.45", "Index": 5, "Idx": "5.0"}, - { - "Period": "2018-10-31T05:45:01.45", - "Index": 5.2, - "Idx": "5.2", - }, - { - "Period": "2018-11-01T05:45:01.45", - "Index": 5.5, - "Idx": "5.5", - }, - ], - "DisplayLocation": REDACTED, - }, - }, - "asthma_index": { - "Type": "asthma", - "ForecastDate": "2018-10-29T00:00:00-04:00", - "Location": { - "ZIP": REDACTED, - "City": REDACTED, - "State": REDACTED, - "periods": [ - { - "Triggers": [ - { - "LGID": 1, - "Name": "OZONE", - "PPM": 42, - "Description": ( - "Ozone (O3) is a odorless, colorless ...." - ), - }, - { - "LGID": 1, - "Name": "PM2.5", - "PPM": 30, - "Description": "Fine particles (PM2.5) are 2.5 ...", - }, - { - "LGID": 1, - "Name": "PM10", - "PPM": 19, - "Description": ( - "Coarse dust particles (PM10) are 2.5 ..." - ), - }, - ], - "Period": "0001-01-01T00:00:00", - "Type": "Yesterday", - "Index": 4.1, - "Idx": "4.1", - }, - { - "Triggers": [ - { - "LGID": 3, - "Name": "PM2.5", - "PPM": 105, - "Description": "Fine particles (PM2.5) are 2.5 ...", - }, - { - "LGID": 2, - "Name": "PM10", - "PPM": 65, - "Description": ( - "Coarse dust particles (PM10) are 2.5 ..." - ), - }, - { - "LGID": 1, - "Name": "OZONE", - "PPM": 42, - "Description": ( - "Ozone (O3) is a odorless, colorless ..." - ), - }, - ], - "Period": "0001-01-01T00:00:00", - "Type": "Today", - "Index": 4.5, - "Idx": "4.5", - }, - { - "Triggers": [], - "Period": "0001-01-01T00:00:00", - "Type": "Tomorrow", - "Index": 4.6, - "Idx": "4.6", - }, - ], - "DisplayLocation": REDACTED, - }, - }, - "disease_average_forecasted": { - "Type": "cold", - "ForecastDate": "2018-06-12T00:00:00-04:00", - "Location": { - "ZIP": REDACTED, - "City": REDACTED, - "State": REDACTED, - "periods": [ - {"Period": "2018-06-12T05:13:51.817", "Index": 2.4}, - {"Period": "2018-06-13T05:13:51.817", "Index": 2.5}, - {"Period": "2018-06-14T05:13:51.817", "Index": 2.5}, - {"Period": "2018-06-15T05:13:51.817", "Index": 2.5}, - ], - "DisplayLocation": REDACTED, - }, - }, - "disease_index": { - "ForecastDate": "2019-04-07T00:00:00-04:00", - "Location": { - "City": REDACTED, - "DisplayLocation": REDACTED, - "State": REDACTED, - "ZIP": REDACTED, - "periods": [ - { - "Idx": "6.8", - "Index": 6.8, - "Period": "2019-04-06T00:00:00", - "Triggers": [ - { - "Description": "Influenza", - "Idx": "3.1", - "Index": 3.1, - "Name": "Flu", - }, - { - "Description": "High Fever", - "Idx": "6.2", - "Index": 6.2, - "Name": "Fever", - }, - { - "Description": "Strep & Sore throat", - "Idx": "5.2", - "Index": 5.2, - "Name": "Strep", - }, - { - "Description": "Cough", - "Idx": "7.8", - "Index": 7.8, - "Name": "Cough", - }, - ], - "Type": "Yesterday", - }, - { - "Idx": "6.7", - "Index": 6.7, - "Period": "2019-04-07T03:52:58", - "Triggers": [ - { - "Description": "Influenza", - "Idx": "3.1", - "Index": 3.1, - "Name": "Flu", - }, - { - "Description": "High Fever", - "Idx": "5.9", - "Index": 5.9, - "Name": "Fever", - }, - { - "Description": "Strep & Sore throat", - "Idx": "5.1", - "Index": 5.1, - "Name": "Strep", - }, - { - "Description": "Cough", - "Idx": "7.7", - "Index": 7.7, - "Name": "Cough", - }, - ], - "Type": "Today", - }, - ], - }, - "Type": "cold", - }, - }, - } + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) From 588db501fbf11aedecd549540661397557916e35 Mon Sep 17 00:00:00 2001 From: Marty Sun Date: Fri, 25 Aug 2023 06:48:49 +0800 Subject: [PATCH 069/124] Add new integration Yardian (#97326) Co-authored-by: J. Nick Koston --- .coveragerc | 3 + CODEOWNERS | 1 + homeassistant/components/yardian/__init__.py | 40 ++++ .../components/yardian/config_flow.py | 73 +++++++ homeassistant/components/yardian/const.py | 7 + .../components/yardian/coordinator.py | 73 +++++++ .../components/yardian/manifest.json | 9 + homeassistant/components/yardian/strings.json | 20 ++ homeassistant/components/yardian/switch.py | 71 +++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + tests/components/yardian/conftest.py | 14 ++ tests/components/yardian/test_config_flow.py | 188 ++++++++++++++++++ 14 files changed, 509 insertions(+) create mode 100644 homeassistant/components/yardian/__init__.py create mode 100644 homeassistant/components/yardian/config_flow.py create mode 100644 homeassistant/components/yardian/const.py create mode 100644 homeassistant/components/yardian/coordinator.py create mode 100644 homeassistant/components/yardian/manifest.json create mode 100644 homeassistant/components/yardian/strings.json create mode 100644 homeassistant/components/yardian/switch.py create mode 100644 tests/components/yardian/conftest.py create mode 100644 tests/components/yardian/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 5155cac79f1..5753bc13195 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1521,6 +1521,9 @@ omit = homeassistant/components/yamaha_musiccast/select.py homeassistant/components/yamaha_musiccast/switch.py homeassistant/components/yandex_transport/sensor.py + homeassistant/components/yardian/__init__.py + homeassistant/components/yardian/coordinator.py + homeassistant/components/yardian/switch.py homeassistant/components/yeelightsunflower/light.py homeassistant/components/yi/camera.py homeassistant/components/yolink/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index e3e42b75280..dd52cb196a6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1444,6 +1444,7 @@ build.json @home-assistant/supervisor /tests/components/yamaha_musiccast/ @vigonotion @micha91 /homeassistant/components/yandex_transport/ @rishatik92 @devbis /tests/components/yandex_transport/ @rishatik92 @devbis +/homeassistant/components/yardian/ @h3l1o5 /homeassistant/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015 /tests/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015 /homeassistant/components/yeelightsunflower/ @lindsaymarkward diff --git a/homeassistant/components/yardian/__init__.py b/homeassistant/components/yardian/__init__.py new file mode 100644 index 00000000000..d6cee9015b8 --- /dev/null +++ b/homeassistant/components/yardian/__init__.py @@ -0,0 +1,40 @@ +"""The Yardian integration.""" +from __future__ import annotations + +from pyyardian import AsyncYardianClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN +from .coordinator import YardianUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.SWITCH] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Yardian from a config entry.""" + + host = entry.data[CONF_HOST] + access_token = entry.data[CONF_ACCESS_TOKEN] + + controller = AsyncYardianClient(async_get_clientsession(hass), host, access_token) + coordinator = YardianUpdateCoordinator(hass, entry, controller) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data.get(DOMAIN, {}).pop(entry.entry_id, None) + + return unload_ok diff --git a/homeassistant/components/yardian/config_flow.py b/homeassistant/components/yardian/config_flow.py new file mode 100644 index 00000000000..99258965f21 --- /dev/null +++ b/homeassistant/components/yardian/config_flow.py @@ -0,0 +1,73 @@ +"""Config flow for Yardian integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from pyyardian import ( + AsyncYardianClient, + DeviceInfo, + NetworkException, + NotAuthorizedException, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, PRODUCT_NAME + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_ACCESS_TOKEN): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Yardian.""" + + VERSION = 1 + + async def async_fetch_device_info(self, host: str, access_token: str) -> DeviceInfo: + """Fetch device info from Yardian.""" + yarcli = AsyncYardianClient( + async_get_clientsession(self.hass), + host, + access_token, + ) + return await yarcli.fetch_device_info() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + try: + device_info = await self.async_fetch_device_info( + user_input["host"], user_input["access_token"] + ) + except NotAuthorizedException: + errors["base"] = "invalid_auth" + except NetworkException: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(device_info["yid"]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + data=user_input | device_info, + title=PRODUCT_NAME, + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/yardian/const.py b/homeassistant/components/yardian/const.py new file mode 100644 index 00000000000..b4e75f2367b --- /dev/null +++ b/homeassistant/components/yardian/const.py @@ -0,0 +1,7 @@ +"""Constants for the Yardian integration.""" + +DOMAIN = "yardian" +MANUFACTURER = "Aeon Matrix" +PRODUCT_NAME = "Yardian Smart Sprinkler" + +DEFAULT_WATERING_DURATION = 6 diff --git a/homeassistant/components/yardian/coordinator.py b/homeassistant/components/yardian/coordinator.py new file mode 100644 index 00000000000..526ee3c42ab --- /dev/null +++ b/homeassistant/components/yardian/coordinator.py @@ -0,0 +1,73 @@ +"""Update coordinators for Yardian.""" + +from __future__ import annotations + +import asyncio +import datetime +import logging + +from pyyardian import ( + AsyncYardianClient, + NetworkException, + NotAuthorizedException, + YardianDeviceState, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, MANUFACTURER + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = datetime.timedelta(seconds=30) + + +class YardianUpdateCoordinator(DataUpdateCoordinator[YardianDeviceState]): + """Coordinator for Yardian API calls.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + controller: AsyncYardianClient, + ) -> None: + """Initialize Yardian API communication.""" + super().__init__( + hass, + _LOGGER, + name=entry.title, + update_method=self._async_update_data, + update_interval=SCAN_INTERVAL, + always_update=False, + ) + + self.controller = controller + self.yid = entry.data["yid"] + self._name = entry.title + self._model = entry.data["model"] + + @property + def device_info(self) -> DeviceInfo: + """Return information about the device.""" + return DeviceInfo( + name=self._name, + identifiers={(DOMAIN, self.yid)}, + manufacturer=MANUFACTURER, + model=self._model, + ) + + async def _async_update_data(self) -> YardianDeviceState: + """Fetch data from Yardian device.""" + try: + async with asyncio.timeout(10): + return await self.controller.fetch_device_state() + + except asyncio.TimeoutError as e: + raise UpdateFailed("Communication with Device was time out") from e + except NotAuthorizedException as e: + raise UpdateFailed("Invalid access token") from e + except NetworkException as e: + raise UpdateFailed("Failed to communicate with Device") from e diff --git a/homeassistant/components/yardian/manifest.json b/homeassistant/components/yardian/manifest.json new file mode 100644 index 00000000000..a20315278b4 --- /dev/null +++ b/homeassistant/components/yardian/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "yardian", + "name": "Yardian", + "codeowners": ["@h3l1o5"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/yardian", + "iot_class": "local_polling", + "requirements": ["pyyardian==1.1.0"] +} diff --git a/homeassistant/components/yardian/strings.json b/homeassistant/components/yardian/strings.json new file mode 100644 index 00000000000..6577c99456c --- /dev/null +++ b/homeassistant/components/yardian/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "access_token": "[%key:common::config_flow::data::access_token%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/yardian/switch.py b/homeassistant/components/yardian/switch.py new file mode 100644 index 00000000000..af5703e0fd4 --- /dev/null +++ b/homeassistant/components/yardian/switch.py @@ -0,0 +1,71 @@ +"""Support for Yardian integration.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DEFAULT_WATERING_DURATION, DOMAIN +from .coordinator import YardianUpdateCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up entry for a Yardian irrigation switches.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities( + YardianSwitch( + coordinator, + i, + ) + for i in range(len(coordinator.data.zones)) + ) + + +class YardianSwitch(CoordinatorEntity[YardianUpdateCoordinator], SwitchEntity): + """Representation of a Yardian switch.""" + + _attr_icon = "mdi:water" + _attr_has_entity_name = True + + def __init__(self, coordinator: YardianUpdateCoordinator, zone_id) -> None: + """Initialize a Yardian Switch Device.""" + super().__init__(coordinator) + self._zone_id = zone_id + self._attr_unique_id = f"{coordinator.yid}-{zone_id}" + self._attr_device_info = coordinator.device_info + + @property + def name(self) -> str: + """Return the zone name.""" + return self.coordinator.data.zones[self._zone_id][0] + + @property + def is_on(self) -> bool: + """Return true if switch is on.""" + return self._zone_id in self.coordinator.data.active_zones + + @property + def available(self) -> bool: + """Return the switch is available or not.""" + return self.coordinator.data.zones[self._zone_id][1] == 1 + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self.coordinator.controller.start_irrigation( + self._zone_id, + kwargs.get("duration", DEFAULT_WATERING_DURATION), + ) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self.coordinator.controller.stop_irrigation() + await self.coordinator.async_request_refresh() diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 82c2d82f423..93d7ec1fbdc 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -535,6 +535,7 @@ FLOWS = { "yale_smart_alarm", "yalexs_ble", "yamaha_musiccast", + "yardian", "yeelight", "yolink", "youless", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 75540a3af83..07960a97fe5 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6518,6 +6518,12 @@ } } }, + "yardian": { + "name": "Yardian", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "yeelight": { "name": "Yeelight", "integrations": { diff --git a/requirements_all.txt b/requirements_all.txt index be073ac19ab..0c96dc89643 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2248,6 +2248,9 @@ pyws66i==1.1 # homeassistant.components.xeoma pyxeoma==1.4.1 +# homeassistant.components.yardian +pyyardian==1.1.0 + # homeassistant.components.qrcode pyzbar==0.1.7 diff --git a/tests/components/yardian/conftest.py b/tests/components/yardian/conftest.py new file mode 100644 index 00000000000..d4f289c4242 --- /dev/null +++ b/tests/components/yardian/conftest.py @@ -0,0 +1,14 @@ +"""Common fixtures for the Yardian tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.yardian.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/yardian/test_config_flow.py b/tests/components/yardian/test_config_flow.py new file mode 100644 index 00000000000..5f1fcc940cc --- /dev/null +++ b/tests/components/yardian/test_config_flow.py @@ -0,0 +1,188 @@ +"""Test the Yardian config flow.""" +from unittest.mock import AsyncMock, patch + +import pytest +from pyyardian import NetworkException, NotAuthorizedException + +from homeassistant import config_entries +from homeassistant.components.yardian.const import DOMAIN, PRODUCT_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.yardian.config_flow.AsyncYardianClient.fetch_device_info", + return_value={"name": "fake_name", "yid": "fake_yid"}, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "fake_host", + "access_token": "fake_token", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == PRODUCT_NAME + assert result2["data"] == { + "host": "fake_host", + "access_token": "fake_token", + "name": "fake_name", + "yid": "fake_yid", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.yardian.config_flow.AsyncYardianClient.fetch_device_info", + side_effect=NotAuthorizedException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "fake_host", + "access_token": "fake_token", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} + + # Should be recoverable after hits error + with patch( + "homeassistant.components.yardian.config_flow.AsyncYardianClient.fetch_device_info", + return_value={"name": "fake_name", "yid": "fake_yid"}, + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "fake_host", + "access_token": "fake_token", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == PRODUCT_NAME + assert result3["data"] == { + "host": "fake_host", + "access_token": "fake_token", + "name": "fake_name", + "yid": "fake_yid", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.yardian.config_flow.AsyncYardianClient.fetch_device_info", + side_effect=NetworkException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "fake_host", + "access_token": "fake_token", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + # Should be recoverable after hits error + with patch( + "homeassistant.components.yardian.config_flow.AsyncYardianClient.fetch_device_info", + return_value={"name": "fake_name", "yid": "fake_yid"}, + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "fake_host", + "access_token": "fake_token", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == PRODUCT_NAME + assert result3["data"] == { + "host": "fake_host", + "access_token": "fake_token", + "name": "fake_name", + "yid": "fake_yid", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_uncategorized_error( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we handle uncategorized error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.yardian.config_flow.AsyncYardianClient.fetch_device_info", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "fake_host", + "access_token": "fake_token", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "unknown"} + + # Should be recoverable after hits error + with patch( + "homeassistant.components.yardian.config_flow.AsyncYardianClient.fetch_device_info", + return_value={"name": "fake_name", "yid": "fake_yid"}, + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "fake_host", + "access_token": "fake_token", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == PRODUCT_NAME + assert result3["data"] == { + "host": "fake_host", + "access_token": "fake_token", + "name": "fake_name", + "yid": "fake_yid", + } + assert len(mock_setup_entry.mock_calls) == 1 From 72e6f790866b9823cece39c1bd4c7647620ed3c6 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 25 Aug 2023 03:23:43 +0200 Subject: [PATCH 070/124] Replace remaining utcnow calls + add ruff check (#97964) --- homeassistant/components/whois/sensor.py | 6 +++++- pyproject.toml | 2 ++ tests/components/unifi/test_sensor.py | 13 ++++++++----- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/whois/sensor.py b/homeassistant/components/whois/sensor.py index 72c366bb0bc..beca3540e8e 100644 --- a/homeassistant/components/whois/sensor.py +++ b/homeassistant/components/whois/sensor.py @@ -22,6 +22,7 @@ from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) +from homeassistant.util import dt as dt_util from .const import ATTR_EXPIRES, ATTR_NAME_SERVERS, ATTR_REGISTRAR, ATTR_UPDATED, DOMAIN @@ -45,7 +46,10 @@ def _days_until_expiration(domain: Domain) -> int | None: if domain.expiration_date is None: return None # We need to cast here, as (unlike Pyright) mypy isn't able to determine the type. - return cast(int, (domain.expiration_date - domain.expiration_date.utcnow()).days) + return cast( + int, + (domain.expiration_date - dt_util.utcnow().replace(tzinfo=None)).days, + ) def _ensure_timezone(timestamp: datetime | None) -> datetime | None: diff --git a/pyproject.toml b/pyproject.toml index 2ae9c96734c..38501a024f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -515,6 +515,8 @@ select = [ "C", # complexity "COM818", # Trailing comma on bare tuple prohibited "D", # docstrings + "DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow() + "DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts) "E", # pycodestyle "F", # pyflakes/autoflake "G", # flake8-logging-format diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index cf6b74b9765..da2c0b46f76 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -8,7 +8,11 @@ from aiounifi.websocket import WebsocketState import pytest from homeassistant.components.device_tracker import DOMAIN as TRACKER_DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass +from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, + SCAN_INTERVAL, + SensorDeviceClass, +) from homeassistant.components.unifi.const import ( CONF_ALLOW_BANDWIDTH_SENSORS, CONF_ALLOW_UPTIME_SENSORS, @@ -19,7 +23,6 @@ from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNAVAILABLE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_component import DEFAULT_SCAN_INTERVAL from homeassistant.helpers.entity_registry import RegistryEntryDisabler import homeassistant.util.dt as dt_util @@ -686,7 +689,7 @@ async def test_wlan_client_sensors( ssid_1 = hass.states.get("sensor.ssid_1") assert ssid_1.state == "1" - async_fire_time_changed(hass, datetime.utcnow() + DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() ssid_1 = hass.states.get("sensor.ssid_1") @@ -697,7 +700,7 @@ async def test_wlan_client_sensors( wireless_client_1["essid"] = "SSID" mock_unifi_websocket(message=MessageKey.CLIENT, data=wireless_client_1) - async_fire_time_changed(hass, datetime.utcnow() + DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() ssid_1 = hass.states.get("sensor.ssid_1") @@ -708,7 +711,7 @@ async def test_wlan_client_sensors( wireless_client_2["last_seen"] = 0 mock_unifi_websocket(message=MessageKey.CLIENT, data=wireless_client_2) - async_fire_time_changed(hass, datetime.utcnow() + DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() ssid_1 = hass.states.get("sensor.ssid_1") From a741298461292440c439423863d69fdaa2c03cd0 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Thu, 24 Aug 2023 20:11:58 -0600 Subject: [PATCH 071/124] Bump `simplisafe-python` to 2023.08.0 (#98991) --- homeassistant/components/simplisafe/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index d137824b3db..d0d2a4c5689 100644 --- a/homeassistant/components/simplisafe/manifest.json +++ b/homeassistant/components/simplisafe/manifest.json @@ -13,5 +13,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["simplipy"], - "requirements": ["simplisafe-python==2023.05.0"] + "requirements": ["simplisafe-python==2023.08.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0c96dc89643..1ec9810d4ad 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2402,7 +2402,7 @@ simplehound==0.3 simplepush==2.1.1 # homeassistant.components.simplisafe -simplisafe-python==2023.05.0 +simplisafe-python==2023.08.0 # homeassistant.components.sisyphus sisyphus-control==3.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7b40e29d86e..4eb7c10b607 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1756,7 +1756,7 @@ simplehound==0.3 simplepush==2.1.1 # homeassistant.components.simplisafe -simplisafe-python==2023.05.0 +simplisafe-python==2023.08.0 # homeassistant.components.slack slackclient==2.5.0 From 3e02fb1f077d66d59988ed66a795aa9446ad5c75 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 25 Aug 2023 08:59:33 +0200 Subject: [PATCH 072/124] Add preview support to all groups (#98951) --- homeassistant/components/group/config_flow.py | 55 ++++++++++++++++--- homeassistant/components/group/cover.py | 12 ++++ homeassistant/components/group/event.py | 13 +++++ homeassistant/components/group/fan.py | 10 ++++ homeassistant/components/group/light.py | 13 +++++ homeassistant/components/group/lock.py | 10 ++++ .../components/group/media_player.py | 45 +++++++++++++-- homeassistant/components/group/switch.py | 13 +++++ tests/components/group/test_config_flow.py | 49 +++++++++++++---- 9 files changed, 194 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/group/config_flow.py b/homeassistant/components/group/config_flow.py index a5bf9e0b972..9eb973b9609 100644 --- a/homeassistant/components/group/config_flow.py +++ b/homeassistant/components/group/config_flow.py @@ -24,7 +24,14 @@ from homeassistant.helpers.schema_config_entry_flow import ( from . import DOMAIN, GroupEntity from .binary_sensor import CONF_ALL, async_create_preview_binary_sensor from .const import CONF_HIDE_MEMBERS, CONF_IGNORE_NON_NUMERIC +from .cover import async_create_preview_cover +from .event import async_create_preview_event +from .fan import async_create_preview_fan +from .light import async_create_preview_light +from .lock import async_create_preview_lock +from .media_player import MediaPlayerGroup, async_create_preview_media_player from .sensor import async_create_preview_sensor +from .switch import async_create_preview_switch _STATISTIC_MEASURES = [ "min", @@ -122,7 +129,7 @@ SENSOR_CONFIG_SCHEMA = basic_group_config_schema( async def light_switch_options_schema( - domain: str, handler: SchemaCommonFlowHandler + domain: str, handler: SchemaCommonFlowHandler | None ) -> vol.Schema: """Generate options schema.""" return (await basic_group_options_schema(domain, handler)).extend( @@ -177,26 +184,32 @@ CONFIG_FLOW = { ), "cover": SchemaFlowFormStep( basic_group_config_schema("cover"), + preview="group", validate_user_input=set_group_type("cover"), ), "event": SchemaFlowFormStep( basic_group_config_schema("event"), + preview="group", validate_user_input=set_group_type("event"), ), "fan": SchemaFlowFormStep( basic_group_config_schema("fan"), + preview="group", validate_user_input=set_group_type("fan"), ), "light": SchemaFlowFormStep( basic_group_config_schema("light"), + preview="group", validate_user_input=set_group_type("light"), ), "lock": SchemaFlowFormStep( basic_group_config_schema("lock"), + preview="group", validate_user_input=set_group_type("lock"), ), "media_player": SchemaFlowFormStep( basic_group_config_schema("media_player"), + preview="group", validate_user_input=set_group_type("media_player"), ), "sensor": SchemaFlowFormStep( @@ -206,6 +219,7 @@ CONFIG_FLOW = { ), "switch": SchemaFlowFormStep( basic_group_config_schema("switch"), + preview="group", validate_user_input=set_group_type("switch"), ), } @@ -217,11 +231,26 @@ OPTIONS_FLOW = { binary_sensor_options_schema, preview="group", ), - "cover": SchemaFlowFormStep(partial(basic_group_options_schema, "cover")), - "event": SchemaFlowFormStep(partial(basic_group_options_schema, "event")), - "fan": SchemaFlowFormStep(partial(basic_group_options_schema, "fan")), - "light": SchemaFlowFormStep(partial(light_switch_options_schema, "light")), - "lock": SchemaFlowFormStep(partial(basic_group_options_schema, "lock")), + "cover": SchemaFlowFormStep( + partial(basic_group_options_schema, "cover"), + preview="group", + ), + "event": SchemaFlowFormStep( + partial(basic_group_options_schema, "event"), + preview="group", + ), + "fan": SchemaFlowFormStep( + partial(basic_group_options_schema, "fan"), + preview="group", + ), + "light": SchemaFlowFormStep( + partial(light_switch_options_schema, "light"), + preview="group", + ), + "lock": SchemaFlowFormStep( + partial(basic_group_options_schema, "lock"), + preview="group", + ), "media_player": SchemaFlowFormStep( partial(basic_group_options_schema, "media_player"), preview="group", @@ -230,17 +259,27 @@ OPTIONS_FLOW = { partial(sensor_options_schema, "sensor"), preview="group", ), - "switch": SchemaFlowFormStep(partial(light_switch_options_schema, "switch")), + "switch": SchemaFlowFormStep( + partial(light_switch_options_schema, "switch"), + preview="group", + ), } PREVIEW_OPTIONS_SCHEMA: dict[str, vol.Schema] = {} CREATE_PREVIEW_ENTITY: dict[ str, - Callable[[str, dict[str, Any]], GroupEntity], + Callable[[str, dict[str, Any]], GroupEntity | MediaPlayerGroup], ] = { "binary_sensor": async_create_preview_binary_sensor, + "cover": async_create_preview_cover, + "event": async_create_preview_event, + "fan": async_create_preview_fan, + "light": async_create_preview_light, + "lock": async_create_preview_lock, + "media_player": async_create_preview_media_player, "sensor": async_create_preview_sensor, + "switch": async_create_preview_switch, } diff --git a/homeassistant/components/group/cover.py b/homeassistant/components/group/cover.py index 0fe67a9bccd..dbb49222bb0 100644 --- a/homeassistant/components/group/cover.py +++ b/homeassistant/components/group/cover.py @@ -96,6 +96,18 @@ async def async_setup_entry( ) +@callback +def async_create_preview_cover( + name: str, validated_config: dict[str, Any] +) -> CoverGroup: + """Create a preview sensor.""" + return CoverGroup( + None, + name, + validated_config[CONF_ENTITIES], + ) + + class CoverGroup(GroupEntity, CoverEntity): """Representation of a CoverGroup.""" diff --git a/homeassistant/components/group/event.py b/homeassistant/components/group/event.py index 81705c7f6f0..ca0c88867fe 100644 --- a/homeassistant/components/group/event.py +++ b/homeassistant/components/group/event.py @@ -2,6 +2,7 @@ from __future__ import annotations import itertools +from typing import Any import voluptuous as vol @@ -87,6 +88,18 @@ async def async_setup_entry( ) +@callback +def async_create_preview_event( + name: str, validated_config: dict[str, Any] +) -> EventGroup: + """Create a preview sensor.""" + return EventGroup( + None, + name, + validated_config[CONF_ENTITIES], + ) + + class EventGroup(GroupEntity, EventEntity): """Representation of an event group.""" diff --git a/homeassistant/components/group/fan.py b/homeassistant/components/group/fan.py index 79ce6fe0d87..4ee788c8402 100644 --- a/homeassistant/components/group/fan.py +++ b/homeassistant/components/group/fan.py @@ -96,6 +96,16 @@ async def async_setup_entry( async_add_entities([FanGroup(config_entry.entry_id, config_entry.title, entities)]) +@callback +def async_create_preview_fan(name: str, validated_config: dict[str, Any]) -> FanGroup: + """Create a preview sensor.""" + return FanGroup( + None, + name, + validated_config[CONF_ENTITIES], + ) + + class FanGroup(GroupEntity, FanEntity): """Representation of a FanGroup.""" diff --git a/homeassistant/components/group/light.py b/homeassistant/components/group/light.py index c6369d876a4..38da7088c2e 100644 --- a/homeassistant/components/group/light.py +++ b/homeassistant/components/group/light.py @@ -110,6 +110,19 @@ async def async_setup_entry( ) +@callback +def async_create_preview_light( + name: str, validated_config: dict[str, Any] +) -> LightGroup: + """Create a preview sensor.""" + return LightGroup( + None, + name, + validated_config[CONF_ENTITIES], + validated_config.get(CONF_ALL, False), + ) + + FORWARDED_ATTRIBUTES = frozenset( { ATTR_BRIGHTNESS, diff --git a/homeassistant/components/group/lock.py b/homeassistant/components/group/lock.py index ec0ff13ee15..5558eab5475 100644 --- a/homeassistant/components/group/lock.py +++ b/homeassistant/components/group/lock.py @@ -90,6 +90,16 @@ async def async_setup_entry( ) +@callback +def async_create_preview_lock(name: str, validated_config: dict[str, Any]) -> LockGroup: + """Create a preview sensor.""" + return LockGroup( + None, + name, + validated_config[CONF_ENTITIES], + ) + + class LockGroup(GroupEntity, LockEntity): """Representation of a lock group.""" diff --git a/homeassistant/components/group/media_player.py b/homeassistant/components/group/media_player.py index f0d076ec130..3960f400614 100644 --- a/homeassistant/components/group/media_player.py +++ b/homeassistant/components/group/media_player.py @@ -1,7 +1,7 @@ """Platform allowing several media players to be grouped into one media player.""" from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Callable, Mapping from contextlib import suppress from typing import Any @@ -44,7 +44,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import HomeAssistant, State, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( @@ -107,6 +107,18 @@ async def async_setup_entry( ) +@callback +def async_create_preview_media_player( + name: str, validated_config: dict[str, Any] +) -> MediaPlayerGroup: + """Create a preview sensor.""" + return MediaPlayerGroup( + None, + name, + validated_config[CONF_ENTITIES], + ) + + class MediaPlayerGroup(MediaPlayerEntity): """Representation of a Media Group.""" @@ -139,7 +151,8 @@ class MediaPlayerGroup(MediaPlayerEntity): self.async_update_supported_features( event.data["entity_id"], event.data["new_state"] ) - self.async_update_state() + self.async_update_group_state() + self.async_write_ha_state() @callback def async_update_supported_features( @@ -208,6 +221,26 @@ class MediaPlayerGroup(MediaPlayerEntity): else: self._features[KEY_ENQUEUE].discard(entity_id) + @callback + def async_start_preview( + self, + preview_callback: Callable[[str, Mapping[str, Any]], None], + ) -> CALLBACK_TYPE: + """Render a preview.""" + + @callback + def async_state_changed_listener( + event: EventType[EventStateChangedData] | None, + ) -> None: + """Handle child updates.""" + self.async_update_group_state() + preview_callback(*self._async_generate_attributes()) + + async_state_changed_listener(None) + return async_track_state_change_event( + self.hass, self._entities, async_state_changed_listener + ) + async def async_added_to_hass(self) -> None: """Register listeners.""" for entity_id in self._entities: @@ -216,7 +249,8 @@ class MediaPlayerGroup(MediaPlayerEntity): async_track_state_change_event( self.hass, self._entities, self.async_on_state_change ) - self.async_update_state() + self.async_update_group_state() + self.async_write_ha_state() @property def name(self) -> str: @@ -391,7 +425,7 @@ class MediaPlayerGroup(MediaPlayerEntity): await self.async_set_volume_level(max(0, volume_level - 0.1)) @callback - def async_update_state(self) -> None: + def async_update_group_state(self) -> None: """Query all members and determine the media group state.""" states = [ state.state @@ -455,4 +489,3 @@ class MediaPlayerGroup(MediaPlayerEntity): supported_features |= MediaPlayerEntityFeature.MEDIA_ENQUEUE self._attr_supported_features = supported_features - self.async_write_ha_state() diff --git a/homeassistant/components/group/switch.py b/homeassistant/components/group/switch.py index bef42824d86..64bc9a99636 100644 --- a/homeassistant/components/group/switch.py +++ b/homeassistant/components/group/switch.py @@ -85,6 +85,19 @@ async def async_setup_entry( ) +@callback +def async_create_preview_switch( + name: str, validated_config: dict[str, Any] +) -> SwitchGroup: + """Create a preview sensor.""" + return SwitchGroup( + None, + name, + validated_config[CONF_ENTITIES], + validated_config.get(CONF_ALL, False), + ) + + class SwitchGroup(GroupEntity, SwitchEntity): """Representation of a switch group.""" diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index a58e47cae71..d0e90fe61bd 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -466,17 +466,34 @@ async def test_options_flow_hides_members( assert registry.async_get(f"{group_type}.three").hidden_by == hidden_by +COVER_ATTRS = [{"supported_features": 0}, {}] +EVENT_ATTRS = [{"event_types": []}, {"event_type": None}] +FAN_ATTRS = [{"supported_features": 0}, {"assumed_state": True}] +LIGHT_ATTRS = [ + { + "icon": "mdi:lightbulb-group", + "supported_color_modes": ["onoff"], + "supported_features": 0, + }, + {"color_mode": "onoff"}, +] +LOCK_ATTRS = [{"supported_features": 1}, {}] +MEDIA_PLAYER_ATTRS = [{"supported_features": 0}, {}] +SENSOR_ATTRS = [{"icon": "mdi:calculator"}, {"max_entity_id": "sensor.input_two"}] + + @pytest.mark.parametrize( ("domain", "extra_user_input", "input_states", "group_state", "extra_attributes"), [ ("binary_sensor", {"all": True}, ["on", "off"], "off", [{}, {}]), - ( - "sensor", - {"type": "max"}, - ["10", "20"], - "20.0", - [{"icon": "mdi:calculator"}, {"max_entity_id": "sensor.input_two"}], - ), + ("cover", {}, ["open", "closed"], "open", COVER_ATTRS), + ("event", {}, ["", ""], "unknown", EVENT_ATTRS), + ("fan", {}, ["on", "off"], "on", FAN_ATTRS), + ("light", {}, ["on", "off"], "on", LIGHT_ATTRS), + ("lock", {}, ["unlocked", "locked"], "unlocked", LOCK_ATTRS), + ("media_player", {}, ["on", "off"], "on", MEDIA_PLAYER_ATTRS), + ("sensor", {"type": "max"}, ["10", "20"], "20.0", SENSOR_ATTRS), + ("switch", {}, ["on", "off"], "on", [{}, {}]), ], ) async def test_config_flow_preview( @@ -553,15 +570,22 @@ async def test_config_flow_preview( "extra_attributes", ), [ - ("binary_sensor", {"all": True}, {"all": False}, ["on", "off"], "on", {}), + ("binary_sensor", {"all": True}, {"all": False}, ["on", "off"], "on", [{}, {}]), + ("cover", {}, {}, ["open", "closed"], "open", COVER_ATTRS), + ("event", {}, {}, ["", ""], "unknown", EVENT_ATTRS), + ("fan", {}, {}, ["on", "off"], "on", FAN_ATTRS), + ("light", {}, {}, ["on", "off"], "on", LIGHT_ATTRS), + ("lock", {}, {}, ["unlocked", "locked"], "unlocked", LOCK_ATTRS), + ("media_player", {}, {}, ["on", "off"], "on", MEDIA_PLAYER_ATTRS), ( "sensor", {"type": "min"}, {"type": "max"}, ["10", "20"], "20.0", - {"icon": "mdi:calculator", "max_entity_id": "sensor.input_two"}, + SENSOR_ATTRS, ), + ("switch", {}, {}, ["on", "off"], "on", [{}, {}]), ], ) async def test_option_flow_preview( @@ -575,8 +599,6 @@ async def test_option_flow_preview( extra_attributes: dict[str, Any], ) -> None: """Test the option flow preview.""" - client = await hass_ws_client(hass) - input_entities = [f"{domain}.input_one", f"{domain}.input_two"] # Setup the config entry @@ -596,6 +618,8 @@ async def test_option_flow_preview( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + client = await hass_ws_client(hass) + result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] == FlowResultType.FORM assert result["errors"] is None @@ -619,7 +643,8 @@ async def test_option_flow_preview( msg = await client.receive_json() assert msg["event"] == { "attributes": {"entity_id": input_entities, "friendly_name": "My group"} - | extra_attributes, + | extra_attributes[0] + | extra_attributes[1], "state": group_state, } assert len(hass.states.async_all()) == 3 From 960d66e168fa55738adf8dab09d1215803a4365e Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 25 Aug 2023 00:43:11 -0700 Subject: [PATCH 073/124] Bump ical to 5.0.1 (#98998) --- homeassistant/components/local_calendar/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index b56acffe4e2..acc2ac80caa 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==4.5.4"] + "requirements": ["ical==5.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1ec9810d4ad..dd7805741a2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1027,7 +1027,7 @@ ibeacon-ble==1.0.1 ibmiotf==0.3.4 # homeassistant.components.local_calendar -ical==4.5.4 +ical==5.0.1 # homeassistant.components.ping icmplib==3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4eb7c10b607..6433987c4d4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -801,7 +801,7 @@ iaqualink==0.5.0 ibeacon-ble==1.0.1 # homeassistant.components.local_calendar -ical==4.5.4 +ical==5.0.1 # homeassistant.components.ping icmplib==3.0 From e7b60374192571fa1591fe026c04a2d3854668b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Fri, 25 Aug 2023 10:46:34 +0300 Subject: [PATCH 074/124] Remove unnnecessary pylint configs from components [e-l]* (#99004) --- .../components/eddystone_temperature/sensor.py | 1 - homeassistant/components/emulated_hue/config.py | 2 +- homeassistant/components/eq3btsmart/climate.py | 2 +- homeassistant/components/esphome/bluetooth/client.py | 4 +--- homeassistant/components/esphome/entity.py | 2 +- homeassistant/components/esphome/light.py | 2 -- homeassistant/components/eufy/light.py | 1 - .../components/google_assistant/report_state.py | 2 +- homeassistant/components/google_mail/config_flow.py | 4 +--- homeassistant/components/gtfs/sensor.py | 1 - homeassistant/components/hassio/http.py | 1 - homeassistant/components/hassio/websocket_api.py | 2 -- homeassistant/components/html5/notify.py | 1 - homeassistant/components/hue/v1/light.py | 2 +- homeassistant/components/ios/__init__.py | 1 - homeassistant/components/ios/notify.py | 1 - homeassistant/components/keyboard/__init__.py | 2 +- homeassistant/components/keyboard_remote/__init__.py | 2 -- homeassistant/components/konnected/__init__.py | 1 - homeassistant/components/limitlessled/light.py | 2 -- homeassistant/components/linode/binary_sensor.py | 2 +- homeassistant/components/linode/switch.py | 2 +- homeassistant/components/lirc/__init__.py | 1 - homeassistant/components/logger/__init__.py | 1 - tests/components/emulated_hue/test_hue_api.py | 2 -- tests/components/flux/test_switch.py | 10 ---------- tests/components/fritz/conftest.py | 2 +- tests/components/group/test_init.py | 1 - tests/components/history/test_init.py | 1 - tests/components/history/test_init_db_schema_30.py | 1 - tests/components/history/test_websocket_api.py | 1 - .../components/history/test_websocket_api_schema_32.py | 1 - tests/components/homematicip_cloud/test_device.py | 4 ++-- tests/components/hyperion/test_config_flow.py | 1 - tests/components/insteon/test_init.py | 1 - tests/components/lock/test_init.py | 1 - tests/components/logbook/test_init.py | 1 - 37 files changed, 13 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/eddystone_temperature/sensor.py b/homeassistant/components/eddystone_temperature/sensor.py index 5a86d45e9f0..347ee1b242f 100644 --- a/homeassistant/components/eddystone_temperature/sensor.py +++ b/homeassistant/components/eddystone_temperature/sensor.py @@ -7,7 +7,6 @@ from __future__ import annotations import logging -# pylint: disable=import-error from beacontools import BeaconScanner, EddystoneFilter, EddystoneTLMFrame import voluptuous as vol diff --git a/homeassistant/components/emulated_hue/config.py b/homeassistant/components/emulated_hue/config.py index 104e05605cb..379f0bec9d7 100644 --- a/homeassistant/components/emulated_hue/config.py +++ b/homeassistant/components/emulated_hue/config.py @@ -225,7 +225,7 @@ class Config: @callback def _clear_exposed_cache(self, event: EventType[EventStateChangedData]) -> None: """Clear the cache of exposed states.""" - self.get_exposed_states.cache_clear() # pylint: disable=no-member + self.get_exposed_states.cache_clear() def is_state_exposed(self, state: State) -> bool: """Cache determine if an entity should be exposed on the emulated bridge.""" diff --git a/homeassistant/components/eq3btsmart/climate.py b/homeassistant/components/eq3btsmart/climate.py index 1ac4531a376..700bc61293f 100644 --- a/homeassistant/components/eq3btsmart/climate.py +++ b/homeassistant/components/eq3btsmart/climate.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging from typing import Any -import eq3bt as eq3 # pylint: disable=import-error +import eq3bt as eq3 import voluptuous as vol from homeassistant.components.climate import ( diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index 4ce8909587e..ad43ca5df7d 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -51,9 +51,7 @@ CCCD_INDICATE_BYTES = b"\x02\x00" DEFAULT_MAX_WRITE_WITHOUT_RESPONSE = DEFAULT_MTU - GATT_HEADER_SIZE _LOGGER = logging.getLogger(__name__) -_WrapFuncType = TypeVar( # pylint: disable=invalid-name - "_WrapFuncType", bound=Callable[..., Any] -) +_WrapFuncType = TypeVar("_WrapFuncType", bound=Callable[..., Any]) def mac_to_int(address: str) -> int: diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index 8b69d011804..db300ab1b28 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable import functools import math -from typing import Any, Generic, TypeVar, cast # pylint: disable=unused-import +from typing import Any, Generic, TypeVar, cast from aioesphomeapi import ( EntityCategory as EsphomeEntityCategory, diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index 95fe864eea8..e170d8b3948 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -181,7 +181,6 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): try_keep_current_mode = False if (rgbw_ha := kwargs.get(ATTR_RGBW_COLOR)) is not None: - # pylint: disable-next=invalid-name *rgb, w = tuple(x / 255 for x in rgbw_ha) # type: ignore[assignment] color_bri = max(rgb) # normalize rgb @@ -194,7 +193,6 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): try_keep_current_mode = False if (rgbww_ha := kwargs.get(ATTR_RGBWW_COLOR)) is not None: - # pylint: disable-next=invalid-name *rgb, cw, ww = tuple(x / 255 for x in rgbww_ha) # type: ignore[assignment] color_bri = max(rgb) # normalize rgb diff --git a/homeassistant/components/eufy/light.py b/homeassistant/components/eufy/light.py index 625b5cda0ba..5185dcd8818 100644 --- a/homeassistant/components/eufy/light.py +++ b/homeassistant/components/eufy/light.py @@ -134,7 +134,6 @@ class EufyHomeLight(LightEntity): """Turn the specified light on.""" brightness = kwargs.get(ATTR_BRIGHTNESS) colortemp = kwargs.get(ATTR_COLOR_TEMP) - # pylint: disable-next=invalid-name hs = kwargs.get(ATTR_HS_COLOR) if brightness is not None: diff --git a/homeassistant/components/google_assistant/report_state.py b/homeassistant/components/google_assistant/report_state.py index 109ea61dbab..5248ce7c4da 100644 --- a/homeassistant/components/google_assistant/report_state.py +++ b/homeassistant/components/google_assistant/report_state.py @@ -147,6 +147,6 @@ def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig def unsub_all(): unsub() if unsub_pending: - unsub_pending() # pylint: disable=not-callable + unsub_pending() return unsub_all diff --git a/homeassistant/components/google_mail/config_flow.py b/homeassistant/components/google_mail/config_flow.py index 0552f57bf5c..b57947302cc 100644 --- a/homeassistant/components/google_mail/config_flow.py +++ b/homeassistant/components/google_mail/config_flow.py @@ -60,9 +60,7 @@ class OAuth2FlowHandler( def _get_profile() -> str: """Get profile from inside the executor.""" - users = build( # pylint: disable=no-member - "gmail", "v1", credentials=credentials - ).users() + users = build("gmail", "v1", credentials=credentials).users() return users.getProfile(userId="me").execute()["emailAddress"] credentials = Credentials(data[CONF_TOKEN][CONF_ACCESS_TOKEN]) diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py index 6f8daf2918d..87d2b55aa24 100644 --- a/homeassistant/components/gtfs/sensor.py +++ b/homeassistant/components/gtfs/sensor.py @@ -505,7 +505,6 @@ def setup_platform( joined_path = os.path.join(gtfs_dir, sqlite_file) gtfs = pygtfs.Schedule(joined_path) - # pylint: disable=no-member if not gtfs.feeds: pygtfs.append_feed(gtfs, os.path.join(gtfs_dir, data)) diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 0e18a009323..5bcdb6896cd 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -82,7 +82,6 @@ NO_STORE = re.compile( r"|app/entrypoint.js" r")$" ) -# pylint: enable=implicit-str-concat # fmt: on RESPONSE_HEADERS_FILTER = { diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index ac0395ebd9f..8f44f7f2843 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -41,7 +41,6 @@ SCHEMA_WEBSOCKET_EVENT = vol.Schema( ) # Endpoints needed for ingress can't require admin because addons can set `panel_admin: false` -# pylint: disable=implicit-str-concat # fmt: off WS_NO_ADMIN_ENDPOINTS = re.compile( r"^(?:" @@ -50,7 +49,6 @@ WS_NO_ADMIN_ENDPOINTS = re.compile( r")$" # noqa: ISC001 ) # fmt: on -# pylint: enable=implicit-str-concat _LOGGER: logging.Logger = logging.getLogger(__package__) diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py index 931d446b2a0..d65a4c42488 100644 --- a/homeassistant/components/html5/notify.py +++ b/homeassistant/components/html5/notify.py @@ -113,7 +113,6 @@ SUBSCRIPTION_SCHEMA = vol.All( dict, vol.Schema( { - # pylint: disable=no-value-for-parameter vol.Required(ATTR_ENDPOINT): vol.Url(), vol.Required(ATTR_KEYS): KEYS_SCHEMA, vol.Optional(ATTR_EXPIRATIONTIME): vol.Any(None, cv.positive_int), diff --git a/homeassistant/components/hue/v1/light.py b/homeassistant/components/hue/v1/light.py index 8ae09ef9d47..18440f68239 100644 --- a/homeassistant/components/hue/v1/light.py +++ b/homeassistant/components/hue/v1/light.py @@ -224,7 +224,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): # Once we do a rooms update, we cancel the listener # until the next time lights are added bridge.reset_jobs.remove(cancel_update_rooms_listener) - cancel_update_rooms_listener() # pylint: disable=not-callable + cancel_update_rooms_listener() cancel_update_rooms_listener = None @callback diff --git a/homeassistant/components/ios/__init__.py b/homeassistant/components/ios/__init__.py index 052ed9f94a0..dd5ea743d57 100644 --- a/homeassistant/components/ios/__init__.py +++ b/homeassistant/components/ios/__init__.py @@ -293,7 +293,6 @@ async def async_setup_entry( return True -# pylint: disable=invalid-name class iOSPushConfigView(HomeAssistantView): """A view that provides the push categories configuration.""" diff --git a/homeassistant/components/ios/notify.py b/homeassistant/components/ios/notify.py index 519bb87d98a..2f42edb4bc1 100644 --- a/homeassistant/components/ios/notify.py +++ b/homeassistant/components/ios/notify.py @@ -25,7 +25,6 @@ _LOGGER = logging.getLogger(__name__) PUSH_URL = "https://ios-push.home-assistant.io/push" -# pylint: disable=invalid-name def log_rate_limits(hass, target, resp, level=20): """Output rate limit log line at given level.""" rate_limits = resp["rateLimits"] diff --git a/homeassistant/components/keyboard/__init__.py b/homeassistant/components/keyboard/__init__.py index f4e7f9e0424..d129505515d 100644 --- a/homeassistant/components/keyboard/__init__.py +++ b/homeassistant/components/keyboard/__init__.py @@ -1,5 +1,5 @@ """Support to emulate keyboard presses on host machine.""" -from pykeyboard import PyKeyboard # pylint: disable=import-error +from pykeyboard import PyKeyboard import voluptuous as vol from homeassistant.const import ( diff --git a/homeassistant/components/keyboard_remote/__init__.py b/homeassistant/components/keyboard_remote/__init__.py index df3b6f0e427..eecde05d1f4 100644 --- a/homeassistant/components/keyboard_remote/__init__.py +++ b/homeassistant/components/keyboard_remote/__init__.py @@ -1,5 +1,4 @@ """Receive signals from a keyboard and use it as a remote control.""" -# pylint: disable=import-error from __future__ import annotations import asyncio @@ -331,7 +330,6 @@ class KeyboardRemote: _LOGGER.debug("Start device monitoring") await self.hass.async_add_executor_job(self.dev.grab) async for event in self.dev.async_read_loop(): - # pylint: disable=no-member if event.type is ecodes.EV_KEY: if event.value in self.key_values: _LOGGER.debug( diff --git a/homeassistant/components/konnected/__init__.py b/homeassistant/components/konnected/__init__.py index 119c7c946a5..fa8a35d7a64 100644 --- a/homeassistant/components/konnected/__init__.py +++ b/homeassistant/components/konnected/__init__.py @@ -197,7 +197,6 @@ DEVICE_SCHEMA_YAML = vol.All( import_device_validator, ) -# pylint: disable=no-value-for-parameter CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.All( diff --git a/homeassistant/components/limitlessled/light.py b/homeassistant/components/limitlessled/light.py index 801f104bd3b..6677768dd00 100644 --- a/homeassistant/components/limitlessled/light.py +++ b/homeassistant/components/limitlessled/light.py @@ -278,7 +278,6 @@ class LimitlessLEDGroup(LightEntity, RestoreEntity): return ColorMode.COLOR_TEMP return ColorMode.HS - # pylint: disable=arguments-differ @state(False) def turn_off(self, transition_time: int, pipeline: Pipeline, **kwargs: Any) -> None: """Turn off a group.""" @@ -286,7 +285,6 @@ class LimitlessLEDGroup(LightEntity, RestoreEntity): pipeline.transition(transition_time, brightness=0.0) pipeline.off() - # pylint: disable=arguments-differ @state(True) def turn_on(self, transition_time: int, pipeline: Pipeline, **kwargs: Any) -> None: """Turn on (or adjust property of) a group.""" diff --git a/homeassistant/components/linode/binary_sensor.py b/homeassistant/components/linode/binary_sensor.py index 2c63bbc0bc8..17a68e9be9c 100644 --- a/homeassistant/components/linode/binary_sensor.py +++ b/homeassistant/components/linode/binary_sensor.py @@ -61,7 +61,7 @@ class LinodeBinarySensor(BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.MOVING - def __init__(self, li, node_id): # pylint: disable=invalid-name + def __init__(self, li, node_id): """Initialize a new Linode sensor.""" self._linode = li self._node_id = node_id diff --git a/homeassistant/components/linode/switch.py b/homeassistant/components/linode/switch.py index 183abbc068c..b59e8f901e5 100644 --- a/homeassistant/components/linode/switch.py +++ b/homeassistant/components/linode/switch.py @@ -57,7 +57,7 @@ def setup_platform( class LinodeSwitch(SwitchEntity): """Representation of a Linode Node switch.""" - def __init__(self, li, node_id): # pylint: disable=invalid-name + def __init__(self, li, node_id): """Initialize a new Linode sensor.""" self._linode = li self._node_id = node_id diff --git a/homeassistant/components/lirc/__init__.py b/homeassistant/components/lirc/__init__.py index 181783b6bbd..1b9688906ff 100644 --- a/homeassistant/components/lirc/__init__.py +++ b/homeassistant/components/lirc/__init__.py @@ -1,5 +1,4 @@ """Support for LIRC devices.""" -# pylint: disable=import-error import logging import threading import time diff --git a/homeassistant/components/logger/__init__.py b/homeassistant/components/logger/__init__.py index cd2761510d3..e7f3d6b78f1 100644 --- a/homeassistant/components/logger/__init__.py +++ b/homeassistant/components/logger/__init__.py @@ -126,7 +126,6 @@ def _get_logger_class(hass_overrides: dict[str, int]) -> type[logging.Logger]: super().setLevel(level) - # pylint: disable=invalid-name def orig_setLevel(self, level: int | str) -> None: """Set the log level.""" super().setLevel(level) diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index b42b40b2739..24acde0709a 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -1130,7 +1130,6 @@ async def test_put_light_state_fan(hass_hue, hue_client) -> None: assert round(fan_json["state"][HUE_API_STATE_BRI] * 100 / 254) == 100 -# pylint: disable=invalid-name async def test_put_with_form_urlencoded_content_type(hass_hue, hue_client) -> None: """Test the form with urlencoded content.""" entity_number = ENTITY_NUMBERS_BY_ID["light.ceiling_lights"] @@ -1215,7 +1214,6 @@ async def test_get_empty_groups_state(hue_client) -> None: assert result_json == {} -# pylint: disable=invalid-name async def perform_put_test_on_ceiling_lights( hass_hue, hue_client, content_type=CONTENT_TYPE_JSON ): diff --git a/tests/components/flux/test_switch.py b/tests/components/flux/test_switch.py index a7d7439226c..ed8a4756031 100644 --- a/tests/components/flux/test_switch.py +++ b/tests/components/flux/test_switch.py @@ -302,7 +302,6 @@ async def test_flux_before_sunrise_known_location( assert call.data[light.ATTR_XY_COLOR] == [0.606, 0.379] -# pylint: disable=invalid-name async def test_flux_after_sunrise_before_sunset( hass: HomeAssistant, enable_custom_integrations: None ) -> None: @@ -361,7 +360,6 @@ async def test_flux_after_sunrise_before_sunset( assert call.data[light.ATTR_XY_COLOR] == [0.439, 0.37] -# pylint: disable=invalid-name async def test_flux_after_sunset_before_stop( hass: HomeAssistant, enable_custom_integrations: None ) -> None: @@ -421,7 +419,6 @@ async def test_flux_after_sunset_before_stop( assert call.data[light.ATTR_XY_COLOR] == [0.506, 0.385] -# pylint: disable=invalid-name async def test_flux_after_stop_before_sunrise( hass: HomeAssistant, enable_custom_integrations: None ) -> None: @@ -480,7 +477,6 @@ async def test_flux_after_stop_before_sunrise( assert call.data[light.ATTR_XY_COLOR] == [0.606, 0.379] -# pylint: disable=invalid-name async def test_flux_with_custom_start_stop_times( hass: HomeAssistant, enable_custom_integrations: None ) -> None: @@ -603,7 +599,6 @@ async def test_flux_before_sunrise_stop_next_day( assert call.data[light.ATTR_XY_COLOR] == [0.606, 0.379] -# pylint: disable=invalid-name async def test_flux_after_sunrise_before_sunset_stop_next_day( hass: HomeAssistant, enable_custom_integrations: None ) -> None: @@ -666,7 +661,6 @@ async def test_flux_after_sunrise_before_sunset_stop_next_day( assert call.data[light.ATTR_XY_COLOR] == [0.439, 0.37] -# pylint: disable=invalid-name @pytest.mark.parametrize("x", [0, 1]) async def test_flux_after_sunset_before_midnight_stop_next_day( hass: HomeAssistant, x, enable_custom_integrations: None @@ -730,7 +724,6 @@ async def test_flux_after_sunset_before_midnight_stop_next_day( assert call.data[light.ATTR_XY_COLOR] == [0.588, 0.386] -# pylint: disable=invalid-name async def test_flux_after_sunset_after_midnight_stop_next_day( hass: HomeAssistant, enable_custom_integrations: None ) -> None: @@ -793,7 +786,6 @@ async def test_flux_after_sunset_after_midnight_stop_next_day( assert call.data[light.ATTR_XY_COLOR] == [0.601, 0.382] -# pylint: disable=invalid-name async def test_flux_after_stop_before_sunrise_stop_next_day( hass: HomeAssistant, enable_custom_integrations: None ) -> None: @@ -856,7 +848,6 @@ async def test_flux_after_stop_before_sunrise_stop_next_day( assert call.data[light.ATTR_XY_COLOR] == [0.606, 0.379] -# pylint: disable=invalid-name async def test_flux_with_custom_colortemps( hass: HomeAssistant, enable_custom_integrations: None ) -> None: @@ -918,7 +909,6 @@ async def test_flux_with_custom_colortemps( assert call.data[light.ATTR_XY_COLOR] == [0.469, 0.378] -# pylint: disable=invalid-name async def test_flux_with_custom_brightness( hass: HomeAssistant, enable_custom_integrations: None ) -> None: diff --git a/tests/components/fritz/conftest.py b/tests/components/fritz/conftest.py index acb135d01bb..08dce14f18d 100644 --- a/tests/components/fritz/conftest.py +++ b/tests/components/fritz/conftest.py @@ -26,7 +26,7 @@ class FritzServiceMock(Service): self.serviceId = serviceId -class FritzConnectionMock: # pylint: disable=too-few-public-methods +class FritzConnectionMock: """FritzConnection mocking.""" def __init__(self, services): diff --git a/tests/components/group/test_init.py b/tests/components/group/test_init.py index e4d737b04e2..3ea75fbce06 100644 --- a/tests/components/group/test_init.py +++ b/tests/components/group/test_init.py @@ -616,7 +616,6 @@ async def test_service_group_services_add_remove_entities(hass: HomeAssistant) - assert "person.one" not in list(group_state.attributes["entity_id"]) -# pylint: disable=invalid-name async def test_service_group_set_group_remove_group(hass: HomeAssistant) -> None: """Check if service are available.""" with assert_setup_component(0, "group"): diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index 04384834282..356fbb86b01 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -1,5 +1,4 @@ """The tests the History component.""" -# pylint: disable=invalid-name from datetime import timedelta from http import HTTPStatus import json diff --git a/tests/components/history/test_init_db_schema_30.py b/tests/components/history/test_init_db_schema_30.py index 32358e95e41..caf151cafe7 100644 --- a/tests/components/history/test_init_db_schema_30.py +++ b/tests/components/history/test_init_db_schema_30.py @@ -1,7 +1,6 @@ """The tests the History component.""" from __future__ import annotations -# pylint: disable=invalid-name from datetime import timedelta from http import HTTPStatus import json diff --git a/tests/components/history/test_websocket_api.py b/tests/components/history/test_websocket_api.py index 87489486614..9ba47303e53 100644 --- a/tests/components/history/test_websocket_api.py +++ b/tests/components/history/test_websocket_api.py @@ -1,5 +1,4 @@ """The tests the History component websocket_api.""" -# pylint: disable=protected-access,invalid-name import asyncio from datetime import timedelta from unittest.mock import patch diff --git a/tests/components/history/test_websocket_api_schema_32.py b/tests/components/history/test_websocket_api_schema_32.py index aebf5aa7ac2..6ef6f7225c1 100644 --- a/tests/components/history/test_websocket_api_schema_32.py +++ b/tests/components/history/test_websocket_api_schema_32.py @@ -1,5 +1,4 @@ """The tests the History component websocket_api.""" -# pylint: disable=protected-access,invalid-name import pytest diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py index d84fe690df6..24842ab8beb 100644 --- a/tests/components/homematicip_cloud/test_device.py +++ b/tests/components/homematicip_cloud/test_device.py @@ -244,13 +244,13 @@ async def test_hmip_reset_energy_counter_services( blocking=True, ) assert hmip_device.mock_calls[-1][0] == "reset_energy_counter" - assert len(hmip_device._connection.mock_calls) == 2 # pylint: disable=W0212 + assert len(hmip_device._connection.mock_calls) == 2 await hass.services.async_call( "homematicip_cloud", "reset_energy_counter", {"entity_id": "all"}, blocking=True ) assert hmip_device.mock_calls[-1][0] == "reset_energy_counter" - assert len(hmip_device._connection.mock_calls) == 4 # pylint: disable=W0212 + assert len(hmip_device._connection.mock_calls) == 4 async def test_hmip_multi_area_device( diff --git a/tests/components/hyperion/test_config_flow.py b/tests/components/hyperion/test_config_flow.py index f9cef677ead..97b705ef731 100644 --- a/tests/components/hyperion/test_config_flow.py +++ b/tests/components/hyperion/test_config_flow.py @@ -763,7 +763,6 @@ async def test_options_priority(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: TEST_ENTITY_ID_1}, blocking=True, ) - # pylint: disable-next=unsubscriptable-object assert client.async_send_set_color.call_args[1][CONF_PRIORITY] == new_priority diff --git a/tests/components/insteon/test_init.py b/tests/components/insteon/test_init.py index 1c4e2abf123..15f529babd8 100644 --- a/tests/components/insteon/test_init.py +++ b/tests/components/insteon/test_init.py @@ -44,7 +44,6 @@ async def test_setup_entry(hass: HomeAssistant) -> None: await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() - # pylint: disable-next=no-member assert insteon.devices.async_save.call_count == 1 assert mock_close.called diff --git a/tests/components/lock/test_init.py b/tests/components/lock/test_init.py index 0d33881c46c..24b13d48a1e 100644 --- a/tests/components/lock/test_init.py +++ b/tests/components/lock/test_init.py @@ -73,7 +73,6 @@ async def test_lock_default(hass: HomeAssistant) -> None: async def test_lock_states(hass: HomeAssistant) -> None: """Test lock entity states.""" - # pylint: disable=protected-access lock = MockLockEntity() lock.hass = hass diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index 2a93e6e1d4c..eaa2a1e4192 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -1,5 +1,4 @@ """The tests for the logbook component.""" -# pylint: disable=invalid-name import asyncio import collections from collections.abc import Callable From 07494f129cb60bbe7bb6fa33e58f57a617047fab Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 25 Aug 2023 10:18:43 +0200 Subject: [PATCH 075/124] Bump actions/checkout from 3.5.3 to 3.6.0 (#99003) Bumps [actions/checkout](https://github.com/actions/checkout) from 3.5.3 to 3.6.0. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v3.5.3...v3.6.0) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 12 ++++++------ .github/workflows/ci.yaml | 28 ++++++++++++++-------------- .github/workflows/translations.yml | 2 +- .github/workflows/wheels.yml | 6 +++--- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index b5d37be44bc..3296f33f84c 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -24,7 +24,7 @@ jobs: publish: ${{ steps.version.outputs.publish }} steps: - name: Checkout the repository - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 with: fetch-depth: 0 @@ -56,7 +56,7 @@ jobs: if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' steps: - name: Checkout the repository - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.0 @@ -98,7 +98,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' @@ -254,7 +254,7 @@ jobs: - green steps: - name: Checkout the repository - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Set build additional args run: | @@ -293,7 +293,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Initialize git uses: home-assistant/actions/helpers/git-init@master @@ -331,7 +331,7 @@ jobs: id-token: write steps: - name: Checkout the repository - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Install Cosign uses: sigstore/cosign-installer@v3.1.1 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a96a0602473..26811f31962 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -87,7 +87,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Generate partial Python venv restore key id: generate_python_cache_key run: >- @@ -220,7 +220,7 @@ jobs: - info steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.0 @@ -265,7 +265,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.0 id: python @@ -311,7 +311,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.0 id: python @@ -360,7 +360,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.0 id: python @@ -454,7 +454,7 @@ jobs: python-version: ${{ fromJSON(needs.info.outputs.python_versions) }} steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.7.0 @@ -522,7 +522,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.0 @@ -554,7 +554,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.0 @@ -587,7 +587,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.0 @@ -631,7 +631,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.0 @@ -713,7 +713,7 @@ jobs: bluez \ ffmpeg - name: Check out code from GitHub - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.7.0 @@ -865,7 +865,7 @@ jobs: ffmpeg \ libmariadb-dev-compat - name: Check out code from GitHub - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.7.0 @@ -989,7 +989,7 @@ jobs: ffmpeg \ postgresql-server-dev-14 - name: Check out code from GitHub - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.7.0 @@ -1084,7 +1084,7 @@ jobs: timeout-minutes: 10 steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Download all coverage artifacts uses: actions/download-artifact@v3 - name: Upload coverage to Codecov (full coverage) diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index 1d77ac8f130..5affa459f52 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.0 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 16bd347d7cf..01823199c17 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -26,7 +26,7 @@ jobs: architectures: ${{ steps.info.outputs.architectures }} steps: - name: Checkout the repository - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Get information id: info @@ -84,7 +84,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Download env_file uses: actions/download-artifact@v3 @@ -122,7 +122,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Download env_file uses: actions/download-artifact@v3 From 48b6b1c11af463a367330e89ebb6865bd9bf439b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 25 Aug 2023 10:25:03 +0200 Subject: [PATCH 076/124] Modernize openweathermap weather (#99002) --- .../components/openweathermap/weather.py | 27 ++++++++++++++++--- .../weather_update_coordinator.py | 14 +++++----- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/openweathermap/weather.py b/homeassistant/components/openweathermap/weather.py index c6f95555954..bf1ae5ca7da 100644 --- a/homeassistant/components/openweathermap/weather.py +++ b/homeassistant/components/openweathermap/weather.py @@ -17,7 +17,8 @@ from homeassistant.components.weather import ( ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, Forecast, - WeatherEntity, + SingleCoordinatorWeatherEntity, + WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -26,10 +27,9 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( ATTR_API_CLOUDS, @@ -60,6 +60,8 @@ from .const import ( DOMAIN, ENTRY_NAME, ENTRY_WEATHER_COORDINATOR, + FORECAST_MODE_DAILY, + FORECAST_MODE_ONECALL_DAILY, MANUFACTURER, ) from .weather_update_coordinator import WeatherUpdateCoordinator @@ -96,7 +98,7 @@ async def async_setup_entry( async_add_entities([owm_weather], False) -class OpenWeatherMapWeather(CoordinatorEntity[WeatherUpdateCoordinator], WeatherEntity): +class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator]): """Implementation of an OpenWeatherMap sensor.""" _attr_attribution = ATTRIBUTION @@ -123,6 +125,13 @@ class OpenWeatherMapWeather(CoordinatorEntity[WeatherUpdateCoordinator], Weather manufacturer=MANUFACTURER, name=DEFAULT_NAME, ) + if weather_coordinator.forecast_mode in ( + FORECAST_MODE_DAILY, + FORECAST_MODE_ONECALL_DAILY, + ): + self._attr_supported_features = WeatherEntityFeature.FORECAST_DAILY + else: # FORECAST_MODE_DAILY or FORECAST_MODE_ONECALL_HOURLY + self._attr_supported_features = WeatherEntityFeature.FORECAST_HOURLY @property def condition(self) -> str | None: @@ -187,3 +196,13 @@ class OpenWeatherMapWeather(CoordinatorEntity[WeatherUpdateCoordinator], Weather for forecast in api_forecasts ] return cast(list[Forecast], forecasts) + + @callback + def _async_forecast_daily(self) -> list[Forecast] | None: + """Return the daily forecast in native units.""" + return self.forecast + + @callback + def _async_forecast_hourly(self) -> list[Forecast] | None: + """Return the hourly forecast in native units.""" + return self.forecast diff --git a/homeassistant/components/openweathermap/weather_update_coordinator.py b/homeassistant/components/openweathermap/weather_update_coordinator.py index cf0c941f0df..56519c46fd9 100644 --- a/homeassistant/components/openweathermap/weather_update_coordinator.py +++ b/homeassistant/components/openweathermap/weather_update_coordinator.py @@ -68,7 +68,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): self._owm_client = owm self._latitude = latitude self._longitude = longitude - self._forecast_mode = forecast_mode + self.forecast_mode = forecast_mode self._forecast_limit = None if forecast_mode == FORECAST_MODE_DAILY: self._forecast_limit = 15 @@ -90,7 +90,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): async def _get_owm_weather(self): """Poll weather data from OWM.""" - if self._forecast_mode in ( + if self.forecast_mode in ( FORECAST_MODE_ONECALL_HOURLY, FORECAST_MODE_ONECALL_DAILY, ): @@ -106,17 +106,17 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): def _get_legacy_weather_and_forecast(self): """Get weather and forecast data from OWM.""" - interval = self._get_forecast_interval() + interval = self._get_legacy_forecast_interval() weather = self._owm_client.weather_at_coords(self._latitude, self._longitude) forecast = self._owm_client.forecast_at_coords( self._latitude, self._longitude, interval, self._forecast_limit ) return LegacyWeather(weather.weather, forecast.forecast.weathers) - def _get_forecast_interval(self): + def _get_legacy_forecast_interval(self): """Get the correct forecast interval depending on the forecast mode.""" interval = "daily" - if self._forecast_mode == FORECAST_MODE_HOURLY: + if self.forecast_mode == FORECAST_MODE_HOURLY: interval = "3h" return interval @@ -153,9 +153,9 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): def _get_forecast_from_weather_response(self, weather_response): """Extract the forecast data from the weather response.""" forecast_arg = "forecast" - if self._forecast_mode == FORECAST_MODE_ONECALL_HOURLY: + if self.forecast_mode == FORECAST_MODE_ONECALL_HOURLY: forecast_arg = "forecast_hourly" - elif self._forecast_mode == FORECAST_MODE_ONECALL_DAILY: + elif self.forecast_mode == FORECAST_MODE_ONECALL_DAILY: forecast_arg = "forecast_daily" return [ self._convert_forecast(x) for x in getattr(weather_response, forecast_arg) From c2713f0aed643954b61aef5f714209d3d250a4ea Mon Sep 17 00:00:00 2001 From: Niels Perfors Date: Fri, 25 Aug 2023 10:27:35 +0200 Subject: [PATCH 077/124] Upgrade Verisure to 2.6.6 (#98258) --- homeassistant/components/verisure/coordinator.py | 14 +++++++++++--- homeassistant/components/verisure/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/verisure/coordinator.py b/homeassistant/components/verisure/coordinator.py index 3779af4fd16..f31d36aa2da 100644 --- a/homeassistant/components/verisure/coordinator.py +++ b/homeassistant/components/verisure/coordinator.py @@ -47,7 +47,7 @@ class VerisureDataUpdateCoordinator(DataUpdateCoordinator): try: await self.hass.async_add_executor_job(self.verisure.login_cookie) except VerisureLoginError as ex: - LOGGER.error("Could not log in to verisure, %s", ex) + LOGGER.error("Credentials expired for Verisure, %s", ex) raise ConfigEntryAuthFailed("Credentials expired for Verisure") from ex except VerisureError as ex: LOGGER.error("Could not log in to verisure, %s", ex) @@ -63,8 +63,16 @@ class VerisureDataUpdateCoordinator(DataUpdateCoordinator): """Fetch data from Verisure.""" try: await self.hass.async_add_executor_job(self.verisure.update_cookie) - except VerisureLoginError as ex: - raise ConfigEntryAuthFailed("Credentials expired for Verisure") from ex + except VerisureLoginError: + LOGGER.debug("Cookie expired, acquiring new cookies") + try: + await self.hass.async_add_executor_job(self.verisure.login_cookie) + except VerisureLoginError as ex: + LOGGER.error("Credentials expired for Verisure, %s", ex) + raise ConfigEntryAuthFailed("Credentials expired for Verisure") from ex + except VerisureError as ex: + LOGGER.error("Could not log in to verisure, %s", ex) + raise ConfigEntryAuthFailed("Could not log in to verisure") from ex except VerisureError as ex: raise UpdateFailed("Unable to update cookie") from ex try: diff --git a/homeassistant/components/verisure/manifest.json b/homeassistant/components/verisure/manifest.json index 98440f67e4c..7c9e7057b0c 100644 --- a/homeassistant/components/verisure/manifest.json +++ b/homeassistant/components/verisure/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["verisure"], - "requirements": ["vsure==2.6.4"] + "requirements": ["vsure==2.6.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index dd7805741a2..2aaba30788b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2647,7 +2647,7 @@ volkszaehler==0.4.0 volvooncall==0.10.3 # homeassistant.components.verisure -vsure==2.6.4 +vsure==2.6.6 # homeassistant.components.vasttrafik vtjp==0.1.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6433987c4d4..752e320aa93 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1941,7 +1941,7 @@ voip-utils==0.1.0 volvooncall==0.10.3 # homeassistant.components.verisure -vsure==2.6.4 +vsure==2.6.6 # homeassistant.components.vulcan vulcan-api==2.3.0 From 3ebf96143a65392bdb64ebc5a1500927f6fae06e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Aug 2023 03:31:43 -0500 Subject: [PATCH 078/124] Improve performance of bluetooth coordinators (#98997) --- .../components/bluetooth/update_coordinator.py | 16 ++++++++++++++-- .../bluetooth/test_passive_update_processor.py | 6 ++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/update_coordinator.py b/homeassistant/components/bluetooth/update_coordinator.py index 88263aa0a58..9c38bf2f520 100644 --- a/homeassistant/components/bluetooth/update_coordinator.py +++ b/homeassistant/components/bluetooth/update_coordinator.py @@ -39,6 +39,7 @@ class BasePassiveBluetoothCoordinator(ABC): self.mode = mode self._last_unavailable_time = 0.0 self._last_name = address + self._available = async_address_present(hass, address, connectable) @callback def async_start(self) -> CALLBACK_TYPE: @@ -85,7 +86,17 @@ class BasePassiveBluetoothCoordinator(ABC): @property def available(self) -> bool: """Return if the device is available.""" - return async_address_present(self.hass, self.address, self.connectable) + return self._available + + @callback + def _async_handle_bluetooth_event_internal( + self, + service_info: BluetoothServiceInfoBleak, + change: BluetoothChange, + ) -> None: + """Handle a bluetooth event.""" + self._available = True + self._async_handle_bluetooth_event(service_info, change) @callback def _async_start(self) -> None: @@ -93,7 +104,7 @@ class BasePassiveBluetoothCoordinator(ABC): self._on_stop.append( async_register_callback( self.hass, - self._async_handle_bluetooth_event, + self._async_handle_bluetooth_event_internal, BluetoothCallbackMatcher( address=self.address, connectable=self.connectable ), @@ -123,3 +134,4 @@ class BasePassiveBluetoothCoordinator(ABC): """Handle the device going unavailable.""" self._last_unavailable_time = service_info.time self._last_name = service_info.name + self._available = False diff --git a/tests/components/bluetooth/test_passive_update_processor.py b/tests/components/bluetooth/test_passive_update_processor.py index d11f5cd5ccd..c96fbfbfc99 100644 --- a/tests/components/bluetooth/test_passive_update_processor.py +++ b/tests/components/bluetooth/test_passive_update_processor.py @@ -406,7 +406,7 @@ async def test_exception_from_update_method( """Generate mock data.""" nonlocal run_count run_count += 1 - if run_count == 1: + if run_count == 2: raise Exception("Test exception") return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE @@ -436,6 +436,7 @@ async def test_exception_from_update_method( processor.async_add_listener(MagicMock()) inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO) + saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) assert processor.available is True # We should go unavailable once we get an exception @@ -473,7 +474,7 @@ async def test_bad_data_from_update_method( """Generate mock data.""" nonlocal run_count run_count += 1 - if run_count == 1: + if run_count == 2: return "bad_data" return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE @@ -503,6 +504,7 @@ async def test_bad_data_from_update_method( processor.async_add_listener(MagicMock()) inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO) + saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) assert processor.available is True # We should go unavailable once we get bad data From 475fd770198baf8272a749d110c6e5e6bca20052 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 25 Aug 2023 10:33:02 +0200 Subject: [PATCH 079/124] Extract SRP Energy coordinator to separate file (#98956) --- .../components/srp_energy/__init__.py | 11 +- .../components/srp_energy/coordinator.py | 77 +++++++++++++ homeassistant/components/srp_energy/sensor.py | 107 ++---------------- tests/components/srp_energy/test_sensor.py | 30 +++++ 4 files changed, 124 insertions(+), 101 deletions(-) create mode 100644 homeassistant/components/srp_energy/coordinator.py diff --git a/homeassistant/components/srp_energy/__init__.py b/homeassistant/components/srp_energy/__init__.py index ea80a29d990..98d1cdd421a 100644 --- a/homeassistant/components/srp_energy/__init__.py +++ b/homeassistant/components/srp_energy/__init__.py @@ -5,7 +5,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN, LOGGER +from .const import CONF_IS_TOU, DOMAIN, LOGGER +from .coordinator import SRPEnergyDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] @@ -24,8 +25,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api_password, ) + coordinator = SRPEnergyDataUpdateCoordinator( + hass, api_instance, entry.data[CONF_IS_TOU] + ) + + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = api_instance + hass.data[DOMAIN][entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/srp_energy/coordinator.py b/homeassistant/components/srp_energy/coordinator.py new file mode 100644 index 00000000000..9637f176886 --- /dev/null +++ b/homeassistant/components/srp_energy/coordinator.py @@ -0,0 +1,77 @@ +"""DataUpdateCoordinator for the srp_energy integration.""" +from __future__ import annotations + +import asyncio +from datetime import timedelta + +from requests.exceptions import ConnectionError as ConnectError, HTTPError, Timeout +from srpenergy.client import SrpEnergyClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from .const import DOMAIN, LOGGER, MIN_TIME_BETWEEN_UPDATES, PHOENIX_TIME_ZONE + + +class SRPEnergyDataUpdateCoordinator(DataUpdateCoordinator[float]): + """A srp_energy Data Update Coordinator.""" + + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, client: SrpEnergyClient, is_time_of_use: bool + ) -> None: + """Initialize the srp_energy data coordinator.""" + self._client = client + self._is_time_of_use = is_time_of_use + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=MIN_TIME_BETWEEN_UPDATES, + ) + + async def _async_update_data(self) -> float: + """Fetch data from API endpoint. + + This is the place to pre-process the data to lookup tables + so entities can quickly look up their data. + """ + LOGGER.debug("async_update_data enter") + try: + # Fetch srp_energy data + phx_time_zone = dt_util.get_time_zone(PHOENIX_TIME_ZONE) + end_date = dt_util.now(phx_time_zone) + start_date = end_date - timedelta(days=1) + + async with asyncio.timeout(10): + hourly_usage = await self.hass.async_add_executor_job( + self._client.usage, + start_date, + end_date, + self._is_time_of_use, + ) + + LOGGER.debug( + "async_update_data: Received %s record(s) from %s to %s", + len(hourly_usage) if hourly_usage else "None", + start_date, + end_date, + ) + + previous_daily_usage = 0.0 + for _, _, _, kwh, _ in hourly_usage: + previous_daily_usage += float(kwh) + + LOGGER.debug( + "async_update_data: previous_daily_usage %s", + previous_daily_usage, + ) + + return previous_daily_usage + except TimeoutError as timeout_err: + raise UpdateFailed("Timeout communicating with API") from timeout_err + except (ConnectError, HTTPError, Timeout, ValueError, TypeError) as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err diff --git a/homeassistant/components/srp_energy/sensor.py b/homeassistant/components/srp_energy/sensor.py index d477b65b21d..601baaee8ca 100644 --- a/homeassistant/components/srp_energy/sensor.py +++ b/homeassistant/components/srp_energy/sensor.py @@ -1,11 +1,6 @@ """Support for SRP Energy Sensor.""" from __future__ import annotations -import asyncio -from datetime import timedelta - -from requests.exceptions import ConnectionError as ConnectError, HTTPError, Timeout - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -15,88 +10,22 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt as dt_util +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - CONF_IS_TOU, - DEFAULT_NAME, - DOMAIN, - LOGGER, - MIN_TIME_BETWEEN_UPDATES, - PHOENIX_TIME_ZONE, - SENSOR_NAME, - SENSOR_TYPE, -) +from . import SRPEnergyDataUpdateCoordinator +from .const import DEFAULT_NAME, DOMAIN, SENSOR_NAME async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the SRP Energy Usage sensor.""" - # API object stored here by __init__.py - api = hass.data[DOMAIN][entry.entry_id] - is_time_of_use = entry.data[CONF_IS_TOU] - - async def async_update_data(): - """Fetch data from API endpoint. - - This is the place to pre-process the data to lookup tables - so entities can quickly look up their data. - """ - LOGGER.debug("async_update_data enter") - try: - # Fetch srp_energy data - phx_time_zone = dt_util.get_time_zone(PHOENIX_TIME_ZONE) - end_date = dt_util.now(phx_time_zone) - start_date = end_date - timedelta(days=1) - - async with asyncio.timeout(10): - hourly_usage = await hass.async_add_executor_job( - api.usage, - start_date, - end_date, - is_time_of_use, - ) - - LOGGER.debug( - "async_update_data: Received %s record(s) from %s to %s", - len(hourly_usage) if hourly_usage else "None", - start_date, - end_date, - ) - - previous_daily_usage = 0.0 - for _, _, _, kwh, _ in hourly_usage: - previous_daily_usage += float(kwh) - - LOGGER.debug( - "async_update_data: previous_daily_usage %s", - previous_daily_usage, - ) - - return previous_daily_usage - except TimeoutError as timeout_err: - raise UpdateFailed("Timeout communicating with API") from timeout_err - except (ConnectError, HTTPError, Timeout, ValueError, TypeError) as err: - raise UpdateFailed(f"Error communicating with API: {err}") from err - - coordinator = DataUpdateCoordinator( - hass, - LOGGER, - name="sensor", - update_method=async_update_data, - update_interval=MIN_TIME_BETWEEN_UPDATES, - ) - - # Fetch initial data so we have data when entities subscribe - await coordinator.async_refresh() + coordinator: SRPEnergyDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities([SrpEntity(coordinator)]) -class SrpEntity(SensorEntity): +class SrpEntity(CoordinatorEntity[SRPEnergyDataUpdateCoordinator], SensorEntity): """Implementation of a Srp Energy Usage sensor.""" _attr_attribution = "Powered by SRP Energy" @@ -104,13 +33,11 @@ class SrpEntity(SensorEntity): _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR _attr_device_class = SensorDeviceClass.ENERGY _attr_state_class = SensorStateClass.TOTAL_INCREASING - _attr_should_poll = False - def __init__(self, coordinator) -> None: + def __init__(self, coordinator: SRPEnergyDataUpdateCoordinator) -> None: """Initialize the SrpEntity class.""" + super().__init__(coordinator) self._name = SENSOR_NAME - self.type = SENSOR_TYPE - self.coordinator = coordinator @property def name(self) -> str: @@ -118,24 +45,6 @@ class SrpEntity(SensorEntity): return f"{DEFAULT_NAME} {self._name}" @property - def native_value(self) -> StateType: + def native_value(self) -> float: """Return the state of the device.""" return self.coordinator.data - - @property - def available(self) -> bool: - """Return if entity is available.""" - return self.coordinator.last_update_success - - async def async_added_to_hass(self) -> None: - """When entity is added to hass.""" - self.async_on_remove( - self.coordinator.async_add_listener(self.async_write_ha_state) - ) - - async def async_update(self) -> None: - """Update the entity. - - Only used by the generic entity update service. - """ - await self.coordinator.async_request_refresh() diff --git a/tests/components/srp_energy/test_sensor.py b/tests/components/srp_energy/test_sensor.py index 3310e9ce9cd..0e3075c6ac8 100644 --- a/tests/components/srp_energy/test_sensor.py +++ b/tests/components/srp_energy/test_sensor.py @@ -1,4 +1,9 @@ """Tests for the srp_energy sensor platform.""" +from unittest.mock import patch + +import pytest +from requests.models import HTTPError + from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( @@ -10,6 +15,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant +from tests.common import MockConfigEntry + async def test_loading_sensors(hass: HomeAssistant, init_integration) -> None: """Test the srp energy sensors.""" @@ -37,3 +44,26 @@ async def test_srp_entity(hass: HomeAssistant, init_integration) -> None: assert usage_state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY assert usage_state.attributes.get(ATTR_ICON) == "mdi:flash" + + +@pytest.mark.parametrize("error", [TimeoutError, HTTPError]) +async def test_srp_entity_update_failed( + hass: HomeAssistant, + error: Exception, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the SrpEntity.""" + + with patch( + "homeassistant.components.srp_energy.SrpEnergyClient", autospec=True + ) as srp_energy_mock: + client = srp_energy_mock.return_value + client.validate.return_value = True + client.usage.side_effect = error + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + usage_state = hass.states.get("sensor.home_energy_usage") + assert usage_state is None From 11c5e3534aeae67a3e0c3650030a00175f3f1672 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 25 Aug 2023 10:52:07 +0200 Subject: [PATCH 080/124] Add unique id to srp energy entity (#99008) --- homeassistant/components/srp_energy/sensor.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/srp_energy/sensor.py b/homeassistant/components/srp_energy/sensor.py index 601baaee8ca..a7f0f97b636 100644 --- a/homeassistant/components/srp_energy/sensor.py +++ b/homeassistant/components/srp_energy/sensor.py @@ -22,7 +22,7 @@ async def async_setup_entry( """Set up the SRP Energy Usage sensor.""" coordinator: SRPEnergyDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities([SrpEntity(coordinator)]) + async_add_entities([SrpEntity(coordinator, entry)]) class SrpEntity(CoordinatorEntity[SRPEnergyDataUpdateCoordinator], SensorEntity): @@ -34,9 +34,12 @@ class SrpEntity(CoordinatorEntity[SRPEnergyDataUpdateCoordinator], SensorEntity) _attr_device_class = SensorDeviceClass.ENERGY _attr_state_class = SensorStateClass.TOTAL_INCREASING - def __init__(self, coordinator: SRPEnergyDataUpdateCoordinator) -> None: + def __init__( + self, coordinator: SRPEnergyDataUpdateCoordinator, config_entry: ConfigEntry + ) -> None: """Initialize the SrpEntity class.""" super().__init__(coordinator) + self._attr_unique_id = f"{config_entry.entry_id}_total_usage" self._name = SENSOR_NAME @property From da9fc495ca9f5b526f53e94de78f069d7342d360 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 25 Aug 2023 11:19:40 +0200 Subject: [PATCH 081/124] Improve SRP Energy coordinator (#99010) * Improve SRP Energy coordinator * Use time instead of asyncio --- .../components/srp_energy/coordinator.py | 54 +++++++++---------- tests/components/srp_energy/test_sensor.py | 29 ++++++++-- 2 files changed, 51 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/srp_energy/coordinator.py b/homeassistant/components/srp_energy/coordinator.py index 9637f176886..a72ea4d3334 100644 --- a/homeassistant/components/srp_energy/coordinator.py +++ b/homeassistant/components/srp_energy/coordinator.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from datetime import timedelta -from requests.exceptions import ConnectionError as ConnectError, HTTPError, Timeout from srpenergy.client import SrpEnergyClient from homeassistant.config_entries import ConfigEntry @@ -14,6 +13,8 @@ from homeassistant.util import dt as dt_util from .const import DOMAIN, LOGGER, MIN_TIME_BETWEEN_UPDATES, PHOENIX_TIME_ZONE +TIMEOUT = 10 + class SRPEnergyDataUpdateCoordinator(DataUpdateCoordinator[float]): """A srp_energy Data Update Coordinator.""" @@ -40,38 +41,35 @@ class SRPEnergyDataUpdateCoordinator(DataUpdateCoordinator[float]): so entities can quickly look up their data. """ LOGGER.debug("async_update_data enter") + # Fetch srp_energy data + phx_time_zone = dt_util.get_time_zone(PHOENIX_TIME_ZONE) + end_date = dt_util.now(phx_time_zone) + start_date = end_date - timedelta(days=1) try: - # Fetch srp_energy data - phx_time_zone = dt_util.get_time_zone(PHOENIX_TIME_ZONE) - end_date = dt_util.now(phx_time_zone) - start_date = end_date - timedelta(days=1) - - async with asyncio.timeout(10): + async with asyncio.timeout(TIMEOUT): hourly_usage = await self.hass.async_add_executor_job( self._client.usage, start_date, end_date, self._is_time_of_use, ) - - LOGGER.debug( - "async_update_data: Received %s record(s) from %s to %s", - len(hourly_usage) if hourly_usage else "None", - start_date, - end_date, - ) - - previous_daily_usage = 0.0 - for _, _, _, kwh, _ in hourly_usage: - previous_daily_usage += float(kwh) - - LOGGER.debug( - "async_update_data: previous_daily_usage %s", - previous_daily_usage, - ) - - return previous_daily_usage - except TimeoutError as timeout_err: - raise UpdateFailed("Timeout communicating with API") from timeout_err - except (ConnectError, HTTPError, Timeout, ValueError, TypeError) as err: + except (ValueError, TypeError) as err: raise UpdateFailed(f"Error communicating with API: {err}") from err + + LOGGER.debug( + "async_update_data: Received %s record(s) from %s to %s", + len(hourly_usage) if hourly_usage else "None", + start_date, + end_date, + ) + + previous_daily_usage = 0.0 + for _, _, _, kwh, _ in hourly_usage: + previous_daily_usage += float(kwh) + + LOGGER.debug( + "async_update_data: previous_daily_usage %s", + previous_daily_usage, + ) + + return previous_daily_usage diff --git a/tests/components/srp_energy/test_sensor.py b/tests/components/srp_energy/test_sensor.py index 0e3075c6ac8..1ae213e4bf1 100644 --- a/tests/components/srp_energy/test_sensor.py +++ b/tests/components/srp_energy/test_sensor.py @@ -1,7 +1,7 @@ """Tests for the srp_energy sensor platform.""" +import time from unittest.mock import patch -import pytest from requests.models import HTTPError from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass @@ -46,10 +46,8 @@ async def test_srp_entity(hass: HomeAssistant, init_integration) -> None: assert usage_state.attributes.get(ATTR_ICON) == "mdi:flash" -@pytest.mark.parametrize("error", [TimeoutError, HTTPError]) async def test_srp_entity_update_failed( hass: HomeAssistant, - error: Exception, mock_config_entry: MockConfigEntry, ) -> None: """Test the SrpEntity.""" @@ -59,7 +57,30 @@ async def test_srp_entity_update_failed( ) as srp_energy_mock: client = srp_energy_mock.return_value client.validate.return_value = True - client.usage.side_effect = error + client.usage.side_effect = HTTPError + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + usage_state = hass.states.get("sensor.home_energy_usage") + assert usage_state is None + + +async def test_srp_entity_timeout( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the SrpEntity timing out.""" + + with patch( + "homeassistant.components.srp_energy.SrpEnergyClient", autospec=True + ) as srp_energy_mock, patch( + "homeassistant.components.srp_energy.coordinator.TIMEOUT", 0 + ): + client = srp_energy_mock.return_value + client.validate.return_value = True + client.usage = lambda _, __, ___: time.sleep(1) mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) From bab7d289a62039bfb3e072231d5dee6b6d75fe8a Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 25 Aug 2023 11:42:55 +0200 Subject: [PATCH 082/124] Reolink fix unknown value in select enums (#99012) --- homeassistant/components/reolink/select.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index e9dc151f33b..84d39b3d8e2 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +import logging from typing import Any from reolink_aio.api import ( @@ -23,6 +24,8 @@ from . import ReolinkData from .const import DOMAIN from .entity import ReolinkChannelCoordinatorEntity +_LOGGER = logging.getLogger(__name__) + @dataclass class ReolinkSelectEntityDescriptionMixin: @@ -135,6 +138,7 @@ class ReolinkSelectEntity(ReolinkChannelCoordinatorEntity, SelectEntity): """Initialize Reolink select entity.""" super().__init__(reolink_data, channel) self.entity_description = entity_description + self._log_error = True self._attr_unique_id = ( f"{self._host.unique_id}_{channel}_{entity_description.key}" @@ -151,7 +155,16 @@ class ReolinkSelectEntity(ReolinkChannelCoordinatorEntity, SelectEntity): if self.entity_description.value is None: return None - return self.entity_description.value(self._host.api, self._channel) + try: + option = self.entity_description.value(self._host.api, self._channel) + except ValueError: + if self._log_error: + _LOGGER.exception("Reolink '%s' has an unknown value", self.name) + self._log_error = False + return None + + self._log_error = True + return option async def async_select_option(self, option: str) -> None: """Change the selected option.""" From 3ebd7d2fd133954a24b644eeb8e8a7ccd11b31d4 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 25 Aug 2023 12:27:23 +0200 Subject: [PATCH 083/124] Fix asyncio DeprecationWarning [3.12] (#98989) * Fix asyncio DeprecationWarning [3.12] * Use AsyncMock * Rewrite ffmpeg tests * Remove test classes * Rename test file --- tests/components/ffmpeg/test_binary_sensor.py | 127 +++++++++++++++++ tests/components/ffmpeg/test_sensor.py | 130 ------------------ .../minecraft_server/test_config_flow.py | 8 +- 3 files changed, 129 insertions(+), 136 deletions(-) create mode 100644 tests/components/ffmpeg/test_binary_sensor.py delete mode 100644 tests/components/ffmpeg/test_sensor.py diff --git a/tests/components/ffmpeg/test_binary_sensor.py b/tests/components/ffmpeg/test_binary_sensor.py new file mode 100644 index 00000000000..6eec115d6f0 --- /dev/null +++ b/tests/components/ffmpeg/test_binary_sensor.py @@ -0,0 +1,127 @@ +"""The tests for Home Assistant ffmpeg binary sensor.""" +from unittest.mock import AsyncMock, patch + +from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import assert_setup_component + +CONFIG_NOISE = { + "binary_sensor": {"platform": "ffmpeg_noise", "input": "testinputvideo"} +} +CONFIG_MOTION = { + "binary_sensor": {"platform": "ffmpeg_motion", "input": "testinputvideo"} +} + + +# -- ffmpeg noise binary_sensor -- + + +async def test_noise_setup_component(hass: HomeAssistant) -> None: + """Set up ffmpeg component.""" + with assert_setup_component(1, "binary_sensor"): + await async_setup_component(hass, "binary_sensor", CONFIG_NOISE) + await hass.async_block_till_done() + + assert hass.data["ffmpeg"].binary == "ffmpeg" + assert hass.states.get("binary_sensor.ffmpeg_noise") is not None + + +@patch("haffmpeg.sensor.SensorNoise.open_sensor", side_effect=AsyncMock()) +async def test_noise_setup_component_start(mock_start, hass: HomeAssistant): + """Set up ffmpeg component.""" + with assert_setup_component(1, "binary_sensor"): + await async_setup_component(hass, "binary_sensor", CONFIG_NOISE) + await hass.async_block_till_done() + + assert hass.data["ffmpeg"].binary == "ffmpeg" + assert hass.states.get("binary_sensor.ffmpeg_noise") is not None + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + assert mock_start.called + + entity = hass.states.get("binary_sensor.ffmpeg_noise") + assert entity.state == "unavailable" + + +@patch("haffmpeg.sensor.SensorNoise") +async def test_noise_setup_component_start_callback(mock_ffmpeg, hass: HomeAssistant): + """Set up ffmpeg component.""" + mock_ffmpeg().open_sensor.side_effect = AsyncMock() + mock_ffmpeg().close = AsyncMock() + with assert_setup_component(1, "binary_sensor"): + await async_setup_component(hass, "binary_sensor", CONFIG_NOISE) + await hass.async_block_till_done() + + assert hass.data["ffmpeg"].binary == "ffmpeg" + assert hass.states.get("binary_sensor.ffmpeg_noise") is not None + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + + entity = hass.states.get("binary_sensor.ffmpeg_noise") + assert entity.state == "off" + + hass.async_add_job(mock_ffmpeg.call_args[0][1], True) + await hass.async_block_till_done() + + entity = hass.states.get("binary_sensor.ffmpeg_noise") + assert entity.state == "on" + + +# -- ffmpeg motion binary_sensor -- + + +async def test_motion_setup_component(hass: HomeAssistant) -> None: + """Set up ffmpeg component.""" + with assert_setup_component(1, "binary_sensor"): + await async_setup_component(hass, "binary_sensor", CONFIG_MOTION) + await hass.async_block_till_done() + + assert hass.data["ffmpeg"].binary == "ffmpeg" + assert hass.states.get("binary_sensor.ffmpeg_motion") is not None + + +@patch("haffmpeg.sensor.SensorMotion.open_sensor", side_effect=AsyncMock()) +async def test_motion_setup_component_start(mock_start, hass: HomeAssistant): + """Set up ffmpeg component.""" + with assert_setup_component(1, "binary_sensor"): + await async_setup_component(hass, "binary_sensor", CONFIG_MOTION) + await hass.async_block_till_done() + + assert hass.data["ffmpeg"].binary == "ffmpeg" + assert hass.states.get("binary_sensor.ffmpeg_motion") is not None + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + assert mock_start.called + + entity = hass.states.get("binary_sensor.ffmpeg_motion") + assert entity.state == "unavailable" + + +@patch("haffmpeg.sensor.SensorMotion") +async def test_motion_setup_component_start_callback(mock_ffmpeg, hass: HomeAssistant): + """Set up ffmpeg component.""" + mock_ffmpeg().open_sensor.side_effect = AsyncMock() + mock_ffmpeg().close = AsyncMock() + with assert_setup_component(1, "binary_sensor"): + await async_setup_component(hass, "binary_sensor", CONFIG_MOTION) + await hass.async_block_till_done() + + assert hass.data["ffmpeg"].binary == "ffmpeg" + assert hass.states.get("binary_sensor.ffmpeg_motion") is not None + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + + entity = hass.states.get("binary_sensor.ffmpeg_motion") + assert entity.state == "off" + + hass.async_add_job(mock_ffmpeg.call_args[0][1], True) + await hass.async_block_till_done() + + entity = hass.states.get("binary_sensor.ffmpeg_motion") + assert entity.state == "on" diff --git a/tests/components/ffmpeg/test_sensor.py b/tests/components/ffmpeg/test_sensor.py deleted file mode 100644 index a6c9c1f441a..00000000000 --- a/tests/components/ffmpeg/test_sensor.py +++ /dev/null @@ -1,130 +0,0 @@ -"""The tests for Home Assistant ffmpeg binary sensor.""" -from unittest.mock import patch - -from homeassistant.setup import setup_component - -from tests.common import assert_setup_component, get_test_home_assistant, mock_coro - - -class TestFFmpegNoiseSetup: - """Test class for ffmpeg.""" - - def setup_method(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - - self.config = { - "binary_sensor": {"platform": "ffmpeg_noise", "input": "testinputvideo"} - } - - def teardown_method(self): - """Stop everything that was started.""" - self.hass.stop() - - def test_setup_component(self): - """Set up ffmpeg component.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config) - self.hass.block_till_done() - - assert self.hass.data["ffmpeg"].binary == "ffmpeg" - assert self.hass.states.get("binary_sensor.ffmpeg_noise") is not None - - @patch("haffmpeg.sensor.SensorNoise.open_sensor", return_value=mock_coro()) - def test_setup_component_start(self, mock_start): - """Set up ffmpeg component.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config) - self.hass.block_till_done() - - assert self.hass.data["ffmpeg"].binary == "ffmpeg" - assert self.hass.states.get("binary_sensor.ffmpeg_noise") is not None - - self.hass.start() - assert mock_start.called - - entity = self.hass.states.get("binary_sensor.ffmpeg_noise") - assert entity.state == "unavailable" - - @patch("haffmpeg.sensor.SensorNoise") - def test_setup_component_start_callback(self, mock_ffmpeg): - """Set up ffmpeg component.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config) - self.hass.block_till_done() - - assert self.hass.data["ffmpeg"].binary == "ffmpeg" - assert self.hass.states.get("binary_sensor.ffmpeg_noise") is not None - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.ffmpeg_noise") - assert entity.state == "off" - - self.hass.add_job(mock_ffmpeg.call_args[0][1], True) - self.hass.block_till_done() - - entity = self.hass.states.get("binary_sensor.ffmpeg_noise") - assert entity.state == "on" - - -class TestFFmpegMotionSetup: - """Test class for ffmpeg.""" - - def setup_method(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - - self.config = { - "binary_sensor": {"platform": "ffmpeg_motion", "input": "testinputvideo"} - } - - def teardown_method(self): - """Stop everything that was started.""" - self.hass.stop() - - def test_setup_component(self): - """Set up ffmpeg component.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config) - self.hass.block_till_done() - - assert self.hass.data["ffmpeg"].binary == "ffmpeg" - assert self.hass.states.get("binary_sensor.ffmpeg_motion") is not None - - @patch("haffmpeg.sensor.SensorMotion.open_sensor", return_value=mock_coro()) - def test_setup_component_start(self, mock_start): - """Set up ffmpeg component.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config) - self.hass.block_till_done() - - assert self.hass.data["ffmpeg"].binary == "ffmpeg" - assert self.hass.states.get("binary_sensor.ffmpeg_motion") is not None - - self.hass.start() - assert mock_start.called - - entity = self.hass.states.get("binary_sensor.ffmpeg_motion") - assert entity.state == "unavailable" - - @patch("haffmpeg.sensor.SensorMotion") - def test_setup_component_start_callback(self, mock_ffmpeg): - """Set up ffmpeg component.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config) - self.hass.block_till_done() - - assert self.hass.data["ffmpeg"].binary == "ffmpeg" - assert self.hass.states.get("binary_sensor.ffmpeg_motion") is not None - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.ffmpeg_motion") - assert entity.state == "off" - - self.hass.add_job(mock_ffmpeg.call_args[0][1], True) - self.hass.block_till_done() - - entity = self.hass.states.get("binary_sensor.ffmpeg_motion") - assert entity.state == "on" diff --git a/tests/components/minecraft_server/test_config_flow.py b/tests/components/minecraft_server/test_config_flow.py index ac5ae7dbc6e..3a201f15bf3 100644 --- a/tests/components/minecraft_server/test_config_flow.py +++ b/tests/components/minecraft_server/test_config_flow.py @@ -1,7 +1,6 @@ """Test the Minecraft Server config flow.""" -import asyncio -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import aiodns from mcstatus.status_response import JavaStatusResponse @@ -72,9 +71,6 @@ USER_INPUT_PORT_TOO_LARGE = { CONF_HOST: "mc.dummyserver.com:65536", } -SRV_RECORDS = asyncio.Future() -SRV_RECORDS.set_result([QueryMock()]) - async def test_show_config_form(hass: HomeAssistant) -> None: """Test if initial configuration form is shown.""" @@ -173,7 +169,7 @@ async def test_connection_succeeded_with_srv_record(hass: HomeAssistant) -> None """Test config entry in case of a successful connection with a SRV record.""" with patch( "aiodns.DNSResolver.query", - return_value=SRV_RECORDS, + side_effect=AsyncMock(return_value=[QueryMock()]), ), patch( "mcstatus.server.JavaServer.async_status", return_value=JavaStatusResponse( From 3f2d2a85b7fb238ad2f2de5f2d349901e9c157ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Fri, 25 Aug 2023 12:53:26 +0200 Subject: [PATCH 084/124] Update AEMET-OpenData to v0.4.0 (#99015) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update AEMET-OpenData to v0.4.0 Signed-off-by: Álvaro Fernández Rojas * Trigger Github CI --------- Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/aemet/__init__.py | 5 +++-- homeassistant/components/aemet/config_flow.py | 8 +++----- homeassistant/components/aemet/manifest.json | 2 +- .../components/aemet/weather_update_coordinator.py | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 11 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/aemet/__init__.py b/homeassistant/components/aemet/__init__.py index 68e7bb6c5e0..772dcd0276b 100644 --- a/homeassistant/components/aemet/__init__.py +++ b/homeassistant/components/aemet/__init__.py @@ -1,7 +1,7 @@ """The AEMET OpenData component.""" import logging -from aemet_opendata.interface import AEMET +from aemet_opendata.interface import AEMET, ConnectionOptions from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME @@ -28,7 +28,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: longitude = entry.data[CONF_LONGITUDE] station_updates = entry.options.get(CONF_STATION_UPDATES, True) - aemet = AEMET(aiohttp_client.async_get_clientsession(hass), api_key) + options = ConnectionOptions(api_key, station_updates) + aemet = AEMET(aiohttp_client.async_get_clientsession(hass), options) weather_coordinator = WeatherUpdateCoordinator( hass, aemet, latitude, longitude, station_updates ) diff --git a/homeassistant/components/aemet/config_flow.py b/homeassistant/components/aemet/config_flow.py index 129f513025a..4f3531b19e7 100644 --- a/homeassistant/components/aemet/config_flow.py +++ b/homeassistant/components/aemet/config_flow.py @@ -1,8 +1,8 @@ """Config flow for AEMET OpenData.""" from __future__ import annotations -from aemet_opendata import AEMET from aemet_opendata.exceptions import AuthError +from aemet_opendata.interface import AEMET, ConnectionOptions import voluptuous as vol from homeassistant import config_entries @@ -40,10 +40,8 @@ class AemetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(f"{latitude}-{longitude}") self._abort_if_unique_id_configured() - aemet = AEMET( - aiohttp_client.async_get_clientsession(self.hass), - user_input[CONF_API_KEY], - ) + options = ConnectionOptions(user_input[CONF_API_KEY], False) + aemet = AEMET(aiohttp_client.async_get_clientsession(self.hass), options) try: await aemet.get_conventional_observation_stations(False) except AuthError: diff --git a/homeassistant/components/aemet/manifest.json b/homeassistant/components/aemet/manifest.json index a460d9e16bc..4d1b25908ef 100644 --- a/homeassistant/components/aemet/manifest.json +++ b/homeassistant/components/aemet/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/aemet", "iot_class": "cloud_polling", "loggers": ["aemet_opendata"], - "requirements": ["AEMET-OpenData==0.3.0"] + "requirements": ["AEMET-OpenData==0.4.0"] } diff --git a/homeassistant/components/aemet/weather_update_coordinator.py b/homeassistant/components/aemet/weather_update_coordinator.py index d44160116f2..66a1a2eb891 100644 --- a/homeassistant/components/aemet/weather_update_coordinator.py +++ b/homeassistant/components/aemet/weather_update_coordinator.py @@ -11,6 +11,7 @@ from aemet_opendata.const import ( AEMET_ATTR_DAY, AEMET_ATTR_DIRECTION, AEMET_ATTR_ELABORATED, + AEMET_ATTR_FEEL_TEMPERATURE, AEMET_ATTR_FORECAST, AEMET_ATTR_HUMIDITY, AEMET_ATTR_ID, @@ -32,7 +33,6 @@ from aemet_opendata.const import ( AEMET_ATTR_STATION_TEMPERATURE, AEMET_ATTR_STORM_PROBABILITY, AEMET_ATTR_TEMPERATURE, - AEMET_ATTR_TEMPERATURE_FEELING, AEMET_ATTR_WIND, AEMET_ATTR_WIND_GUST, ATTR_DATA, @@ -563,7 +563,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): @staticmethod def _get_temperature_feeling(day_data, hour): """Get temperature from weather data.""" - val = get_forecast_hour_value(day_data[AEMET_ATTR_TEMPERATURE_FEELING], hour) + val = get_forecast_hour_value(day_data[AEMET_ATTR_FEEL_TEMPERATURE], hour) return format_int(val) def _get_town_id(self): diff --git a/requirements_all.txt b/requirements_all.txt index 2aaba30788b..b915a38368f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2,7 +2,7 @@ -r requirements.txt # homeassistant.components.aemet -AEMET-OpenData==0.3.0 +AEMET-OpenData==0.4.0 # homeassistant.components.aladdin_connect AIOAladdinConnect==0.1.57 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 752e320aa93..a292cfb78ac 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -4,7 +4,7 @@ -r requirements_test.txt # homeassistant.components.aemet -AEMET-OpenData==0.3.0 +AEMET-OpenData==0.4.0 # homeassistant.components.aladdin_connect AIOAladdinConnect==0.1.57 From d79e8b7a029d268ac9dcd0808fcc2e42b6f892b8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 25 Aug 2023 06:35:31 -0500 Subject: [PATCH 085/124] Avoid fetching state and charging state multiple time for hkc icon (#98995) --- .../components/homekit_controller/sensor.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index d7230de0832..5803b8aa839 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -466,21 +466,23 @@ class HomeKitBatterySensor(HomeKitSensor): @property def icon(self) -> str: """Return the sensor icon.""" - if not self.available or self.state is None: + native_value = self.native_value + if not self.available or native_value is None: return "mdi:battery-unknown" # This is similar to the logic in helpers.icon, but we have delegated the # decision about what mdi:battery-alert is to the device. icon = "mdi:battery" - if self.is_charging and self.state > 10: - percentage = int(round(self.state / 20 - 0.01)) * 20 + is_charging = self.is_charging + if is_charging and native_value > 10: + percentage = int(round(native_value / 20 - 0.01)) * 20 icon += f"-charging-{percentage}" - elif self.is_charging: + elif is_charging: icon += "-outline" elif self.is_low_battery: icon += "-alert" - elif self.state < 95: - percentage = max(int(round(self.state / 10 - 0.01)) * 10, 10) + elif native_value < 95: + percentage = max(int(round(native_value / 10 - 0.01)) * 10, 10) icon += f"-{percentage}" return icon From 4fb00e448c45bee93a04f321feb45db7bd9a6b2d Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 25 Aug 2023 13:40:08 +0200 Subject: [PATCH 086/124] Use snapshot assertion for rdw diagnostics test (#99027) --- .../rdw/snapshots/test_diagnostics.ambr | 30 ++++++++++++++++ tests/components/rdw/test_diagnostics.py | 35 ++++--------------- 2 files changed, 36 insertions(+), 29 deletions(-) create mode 100644 tests/components/rdw/snapshots/test_diagnostics.ambr diff --git a/tests/components/rdw/snapshots/test_diagnostics.ambr b/tests/components/rdw/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..6da03b67245 --- /dev/null +++ b/tests/components/rdw/snapshots/test_diagnostics.ambr @@ -0,0 +1,30 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'apk_expiration': '2022-01-04', + 'ascription_date': '2021-11-04', + 'ascription_possible': True, + 'brand': 'Skoda', + 'energy_label': 'A', + 'engine_capacity': 999, + 'exported': False, + 'first_admission': '2013-01-04', + 'interior': 'hatchback', + 'last_odometer_registration_year': 2021, + 'liability_insured': False, + 'license_plate': '11ZKZ3', + 'list_price': 10697, + 'mass_driveable': 940, + 'mass_empty': 840, + 'model': 'Citigo', + 'number_of_cylinders': 3, + 'number_of_doors': 0, + 'number_of_seats': 4, + 'number_of_wheelchair_seats': 0, + 'number_of_wheels': 4, + 'odometer_judgement': 'Logisch', + 'pending_recall': False, + 'taxi': None, + 'vehicle_type': 'Personenauto', + }) +# --- diff --git a/tests/components/rdw/test_diagnostics.py b/tests/components/rdw/test_diagnostics.py index 0e21779ff37..28b7714fcce 100644 --- a/tests/components/rdw/test_diagnostics.py +++ b/tests/components/rdw/test_diagnostics.py @@ -1,4 +1,5 @@ """Tests for the diagnostics data provided by the RDW integration.""" +from syrupy import SnapshotAssertion from homeassistant.core import HomeAssistant @@ -11,34 +12,10 @@ async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - assert await get_diagnostics_for_config_entry( - hass, hass_client, init_integration - ) == { - "apk_expiration": "2022-01-04", - "ascription_date": "2021-11-04", - "ascription_possible": True, - "brand": "Skoda", - "energy_label": "A", - "engine_capacity": 999, - "exported": False, - "interior": "hatchback", - "last_odometer_registration_year": 2021, - "liability_insured": False, - "license_plate": "11ZKZ3", - "list_price": 10697, - "first_admission": "2013-01-04", - "mass_empty": 840, - "mass_driveable": 940, - "model": "Citigo", - "number_of_cylinders": 3, - "number_of_doors": 0, - "number_of_seats": 4, - "number_of_wheelchair_seats": 0, - "number_of_wheels": 4, - "odometer_judgement": "Logisch", - "pending_recall": False, - "taxi": None, - "vehicle_type": "Personenauto", - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) From 8161810159a560c114576d8533a67a38f80f1a45 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 25 Aug 2023 14:03:51 +0200 Subject: [PATCH 087/124] Use freezegun in opensky tests (#99039) --- tests/components/opensky/test_sensor.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/components/opensky/test_sensor.py b/tests/components/opensky/test_sensor.py index eb17721929c..b637a0d0356 100644 --- a/tests/components/opensky/test_sensor.py +++ b/tests/components/opensky/test_sensor.py @@ -3,6 +3,7 @@ from datetime import timedelta import json from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory from python_opensky import StatesResponse from syrupy import SnapshotAssertion @@ -16,7 +17,6 @@ from homeassistant.const import CONF_PLATFORM, CONF_RADIUS, Platform from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util from .conftest import ComponentSetup @@ -78,6 +78,7 @@ async def test_sensor_altitude( async def test_sensor_updating( hass: HomeAssistant, config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, setup_integration: ComponentSetup, snapshot: SnapshotAssertion, ): @@ -97,8 +98,8 @@ async def test_sensor_updating( hass.bus.async_listen(EVENT_OPENSKY_EXIT, event_listener) async def skip_time_and_check_events() -> None: - future = dt_util.utcnow() + timedelta(minutes=15) - async_fire_time_changed(hass, future) + freezer.tick(timedelta(minutes=15)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert events == snapshot From 64306ec053a603284b70a54341e33e98edde00a2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 25 Aug 2023 15:58:52 +0200 Subject: [PATCH 088/124] Use freezegun in solaredge tests (#99043) --- .../components/solaredge/test_coordinator.py | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/tests/components/solaredge/test_coordinator.py b/tests/components/solaredge/test_coordinator.py index 7b746a2ae05..550040a9b25 100644 --- a/tests/components/solaredge/test_coordinator.py +++ b/tests/components/solaredge/test_coordinator.py @@ -1,6 +1,8 @@ """Tests for the SolarEdge coordinator services.""" from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory + from homeassistant.components.solaredge.const import ( CONF_SITE_ID, DEFAULT_NAME, @@ -10,7 +12,6 @@ from homeassistant.components.solaredge.const import ( from homeassistant.components.solaredge.sensor import SENSOR_TYPES from homeassistant.const import CONF_API_KEY, CONF_NAME, STATE_UNKNOWN from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -20,7 +21,7 @@ API_KEY = "a1b2c3d4e5f6g7h8" @patch("homeassistant.components.solaredge.Solaredge") async def test_solaredgeoverviewdataservice_energy_values_validity( - mock_solaredge, hass: HomeAssistant + mock_solaredge, hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Test overview energy data validity.""" mock_config_entry = MockConfigEntry( @@ -46,7 +47,8 @@ async def test_solaredgeoverviewdataservice_energy_values_validity( } } mock_solaredge().get_overview.return_value = mock_overview_data - async_fire_time_changed(hass, dt_util.utcnow() + OVERVIEW_UPDATE_DELAY) + freezer.tick(OVERVIEW_UPDATE_DELAY) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.solaredge_lifetime_energy") assert state @@ -55,7 +57,8 @@ async def test_solaredgeoverviewdataservice_energy_values_validity( # Invalid energy values, lifeTimeData energy is lower than last year, month or day. mock_overview_data["overview"]["lifeTimeData"]["energy"] = 0 mock_solaredge().get_overview.return_value = mock_overview_data - async_fire_time_changed(hass, dt_util.utcnow() + OVERVIEW_UPDATE_DELAY) + freezer.tick(OVERVIEW_UPDATE_DELAY) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.solaredge_lifetime_energy") @@ -65,7 +68,8 @@ async def test_solaredgeoverviewdataservice_energy_values_validity( # New valid energy values update mock_overview_data["overview"]["lifeTimeData"]["energy"] = 100001 mock_solaredge().get_overview.return_value = mock_overview_data - async_fire_time_changed(hass, dt_util.utcnow() + OVERVIEW_UPDATE_DELAY) + freezer.tick(OVERVIEW_UPDATE_DELAY) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.solaredge_lifetime_energy") @@ -75,7 +79,8 @@ async def test_solaredgeoverviewdataservice_energy_values_validity( # Invalid energy values, lastYearData energy is lower than last month or day. mock_overview_data["overview"]["lastYearData"]["energy"] = 0 mock_solaredge().get_overview.return_value = mock_overview_data - async_fire_time_changed(hass, dt_util.utcnow() + OVERVIEW_UPDATE_DELAY) + freezer.tick(OVERVIEW_UPDATE_DELAY) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.solaredge_energy_this_year") @@ -92,7 +97,8 @@ async def test_solaredgeoverviewdataservice_energy_values_validity( mock_overview_data["overview"]["lastMonthData"]["energy"] = 0.0 mock_overview_data["overview"]["lastDayData"]["energy"] = 0.0 mock_solaredge().get_overview.return_value = mock_overview_data - async_fire_time_changed(hass, dt_util.utcnow() + OVERVIEW_UPDATE_DELAY) + freezer.tick(OVERVIEW_UPDATE_DELAY) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.solaredge_lifetime_energy") From 65d555b138a2349fe1eb97f93e6186ce63a5bc7f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 25 Aug 2023 15:59:08 +0200 Subject: [PATCH 089/124] Use freezegun in qnap_qsw tests (#99041) --- tests/components/qnap_qsw/test_coordinator.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/components/qnap_qsw/test_coordinator.py b/tests/components/qnap_qsw/test_coordinator.py index 61d1fa04200..b0163f7b7ec 100644 --- a/tests/components/qnap_qsw/test_coordinator.py +++ b/tests/components/qnap_qsw/test_coordinator.py @@ -3,6 +3,7 @@ from unittest.mock import patch from aioqsw.exceptions import APIError, QswError +from freezegun.api import FrozenDateTimeFactory from homeassistant.components.qnap_qsw.const import DOMAIN from homeassistant.components.qnap_qsw.coordinator import ( @@ -11,7 +12,6 @@ from homeassistant.components.qnap_qsw.coordinator import ( ) from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.util.dt import utcnow from .util import ( CONFIG, @@ -31,7 +31,9 @@ from .util import ( from tests.common import MockConfigEntry, async_fire_time_changed -async def test_coordinator_client_connector_error(hass: HomeAssistant) -> None: +async def test_coordinator_client_connector_error( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test ClientConnectorError on coordinator update.""" entry = MockConfigEntry(domain=DOMAIN, data=CONFIG) @@ -99,7 +101,8 @@ async def test_coordinator_client_connector_error(hass: HomeAssistant) -> None: mock_users_login.reset_mock() mock_system_sensor.side_effect = QswError - async_fire_time_changed(hass, utcnow() + DATA_SCAN_INTERVAL) + freezer.tick(DATA_SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() mock_system_sensor.assert_called_once() @@ -110,14 +113,16 @@ async def test_coordinator_client_connector_error(hass: HomeAssistant) -> None: assert state.state == STATE_UNAVAILABLE mock_firmware_update_check.side_effect = APIError - async_fire_time_changed(hass, utcnow() + FW_SCAN_INTERVAL) + freezer.tick(FW_SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() mock_firmware_update_check.assert_called_once() mock_firmware_update_check.reset_mock() mock_firmware_update_check.side_effect = QswError - async_fire_time_changed(hass, utcnow() + FW_SCAN_INTERVAL) + freezer.tick(FW_SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() mock_firmware_update_check.assert_called_once() From 346674a1a8508284cef4a764c80628ebb9bf6853 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 25 Aug 2023 15:59:30 +0200 Subject: [PATCH 090/124] Use freezegun in wled tests (#99048) --- tests/components/wled/conftest.py | 10 +++++++++- tests/components/wled/test_button.py | 2 +- tests/components/wled/test_light.py | 16 +++++++++++----- tests/components/wled/test_number.py | 9 ++++++--- tests/components/wled/test_select.py | 17 ++++++++++++----- tests/components/wled/test_switch.py | 9 ++++++--- 6 files changed, 45 insertions(+), 18 deletions(-) diff --git a/tests/components/wled/conftest.py b/tests/components/wled/conftest.py index 824801fe44b..bbbdd4e1cbe 100644 --- a/tests/components/wled/conftest.py +++ b/tests/components/wled/conftest.py @@ -2,6 +2,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from wled import Device as WLEDDevice @@ -67,7 +68,10 @@ def mock_wled(device_fixture: str) -> Generator[MagicMock, None, None]: @pytest.fixture async def init_integration( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_wled: MagicMock + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_wled: MagicMock, ) -> MockConfigEntry: """Set up the WLED integration for testing.""" mock_config_entry.add_to_hass(hass) @@ -75,4 +79,8 @@ async def init_integration( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() + # Let some time pass so coordinators can be reliably triggered by bumping + # time by SCAN_INTERVAL + freezer.tick(1) + return mock_config_entry diff --git a/tests/components/wled/test_button.py b/tests/components/wled/test_button.py index c1f3165e5bc..92a13baf43c 100644 --- a/tests/components/wled/test_button.py +++ b/tests/components/wled/test_button.py @@ -13,7 +13,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er pytestmark = [ pytest.mark.usefixtures("init_integration"), - pytest.mark.freeze_time("2021-11-04 17:37:00+01:00"), + pytest.mark.freeze_time("2021-11-04 17:36:59+01:00"), ] diff --git a/tests/components/wled/test_light.py b/tests/components/wled/test_light.py index 16aba21392b..678b4a44459 100644 --- a/tests/components/wled/test_light.py +++ b/tests/components/wled/test_light.py @@ -2,6 +2,7 @@ import json from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory import pytest from wled import Device as WLEDDevice, WLEDConnectionError, WLEDError @@ -27,7 +28,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture @@ -177,6 +177,7 @@ async def test_master_change_state( @pytest.mark.parametrize("device_fixture", ["rgb_single_segment"]) async def test_dynamically_handle_segments( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, mock_wled: MagicMock, ) -> None: """Test if a new/deleted segment is dynamically added/removed.""" @@ -190,7 +191,8 @@ async def test_dynamically_handle_segments( json.loads(load_fixture("wled/rgb.json")) ) - async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert (master := hass.states.get("light.wled_rgb_light_master")) @@ -202,7 +204,8 @@ async def test_dynamically_handle_segments( # Test adding if segment shows up again, including the master entity mock_wled.update.return_value = return_value - async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert (master := hass.states.get("light.wled_rgb_light_master")) @@ -216,6 +219,7 @@ async def test_dynamically_handle_segments( @pytest.mark.parametrize("device_fixture", ["rgb_single_segment"]) async def test_single_segment_behavior( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, mock_wled: MagicMock, ) -> None: """Test the behavior of the integration with a single segment.""" @@ -228,7 +232,8 @@ async def test_single_segment_behavior( # Test segment brightness takes master into account device.state.brightness = 100 device.state.segments[0].brightness = 255 - async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert (state := hass.states.get("light.wled_rgb_light")) @@ -236,7 +241,8 @@ async def test_single_segment_behavior( # Test segment is off when master is off device.state.on = False - async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("light.wled_rgb_light") assert state diff --git a/tests/components/wled/test_number.py b/tests/components/wled/test_number.py index 59f2fb12332..e91ec4f2e66 100644 --- a/tests/components/wled/test_number.py +++ b/tests/components/wled/test_number.py @@ -2,6 +2,7 @@ import json from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion from wled import Device as WLEDDevice, WLEDConnectionError, WLEDError @@ -16,7 +17,6 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed, load_fixture @@ -113,6 +113,7 @@ async def test_numbers( ) async def test_speed_dynamically_handle_segments( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, mock_wled: MagicMock, entity_id_segment0: str, entity_id_segment1: str, @@ -130,7 +131,8 @@ async def test_speed_dynamically_handle_segments( json.loads(load_fixture("wled/rgb.json")) ) - async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert (segment0 := hass.states.get(entity_id_segment0)) @@ -140,7 +142,8 @@ async def test_speed_dynamically_handle_segments( # Test remove segment again... mock_wled.update.return_value = return_value - async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert (segment0 := hass.states.get(entity_id_segment0)) diff --git a/tests/components/wled/test_select.py b/tests/components/wled/test_select.py index caf1fa24868..219ec945021 100644 --- a/tests/components/wled/test_select.py +++ b/tests/components/wled/test_select.py @@ -2,6 +2,7 @@ import json from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion from wled import Device as WLEDDevice, WLEDConnectionError, WLEDError @@ -17,7 +18,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed, load_fixture @@ -125,6 +125,7 @@ async def test_color_palette_state( @pytest.mark.parametrize("device_fixture", ["rgb_single_segment"]) async def test_color_palette_dynamically_handle_segments( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, mock_wled: MagicMock, ) -> None: """Test if a new/deleted segment is dynamically added/removed.""" @@ -137,7 +138,8 @@ async def test_color_palette_dynamically_handle_segments( json.loads(load_fixture("wled/rgb.json")) ) - async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert (segment0 := hass.states.get("select.wled_rgb_light_color_palette")) @@ -149,7 +151,8 @@ async def test_color_palette_dynamically_handle_segments( # Test adding if segment shows up again, including the master entity mock_wled.update.return_value = return_value - async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert (segment0 := hass.states.get("select.wled_rgb_light_color_palette")) @@ -175,13 +178,15 @@ async def test_playlist_unavailable_without_playlists(hass: HomeAssistant) -> No @pytest.mark.parametrize("device_fixture", ["rgbw"]) async def test_old_style_preset_active( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, mock_wled: MagicMock, ) -> None: """Test unknown preset returned (when old style/unknown) preset is active.""" # Set device preset state to a random number mock_wled.update.return_value.state.preset = 99 - async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert (state := hass.states.get("select.wled_rgbw_light_preset")) @@ -191,13 +196,15 @@ async def test_old_style_preset_active( @pytest.mark.parametrize("device_fixture", ["rgbw"]) async def test_old_style_playlist_active( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, mock_wled: MagicMock, ) -> None: """Test when old style playlist cycle is active.""" # Set device playlist to 0, which meant "cycle" previously. mock_wled.update.return_value.state.playlist = 0 - async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert (state := hass.states.get("select.wled_rgbw_light_playlist")) diff --git a/tests/components/wled/test_switch.py b/tests/components/wled/test_switch.py index 70804e07eb9..40b7783fc04 100644 --- a/tests/components/wled/test_switch.py +++ b/tests/components/wled/test_switch.py @@ -2,6 +2,7 @@ import json from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion from wled import Device as WLEDDevice, WLEDConnectionError, WLEDError @@ -19,7 +20,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed, load_fixture @@ -132,6 +132,7 @@ async def test_switch_state( @pytest.mark.parametrize("device_fixture", ["rgb_single_segment"]) async def test_switch_dynamically_handle_segments( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, mock_wled: MagicMock, ) -> None: """Test if a new/deleted segment is dynamically added/removed.""" @@ -146,7 +147,8 @@ async def test_switch_dynamically_handle_segments( json.loads(load_fixture("wled/rgb.json")) ) - async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert (segment0 := hass.states.get("switch.wled_rgb_light_reverse")) @@ -156,7 +158,8 @@ async def test_switch_dynamically_handle_segments( # Test remove segment again... mock_wled.update.return_value = return_value - async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert (segment0 := hass.states.get("switch.wled_rgb_light_reverse")) From 943db9e0d5c75ba20004f7cd2b299da84ee9aabd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 25 Aug 2023 15:59:52 +0200 Subject: [PATCH 091/124] Use freezegun in devolo_home_network tests (#99029) --- .../devolo_home_network/test_binary_sensor.py | 9 +++-- .../test_device_tracker.py | 15 +++++--- .../devolo_home_network/test_sensor.py | 9 +++-- .../devolo_home_network/test_switch.py | 34 ++++++++++--------- .../devolo_home_network/test_update.py | 17 +++++++--- 5 files changed, 52 insertions(+), 32 deletions(-) diff --git a/tests/components/devolo_home_network/test_binary_sensor.py b/tests/components/devolo_home_network/test_binary_sensor.py index 7a6395c20f1..17d95fc51a3 100644 --- a/tests/components/devolo_home_network/test_binary_sensor.py +++ b/tests/components/devolo_home_network/test_binary_sensor.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock from devolo_plc_api.exceptions.device import DeviceUnavailable +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -13,7 +14,6 @@ from homeassistant.components.devolo_home_network.const import ( from homeassistant.const import STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt as dt_util from . import configure_integration from .const import PLCNET_ATTACHED @@ -40,6 +40,7 @@ async def test_update_attached_to_router( hass: HomeAssistant, mock_device: MockDevice, entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, ) -> None: """Test state change of a attached_to_router binary sensor device.""" @@ -57,7 +58,8 @@ async def test_update_attached_to_router( mock_device.plcnet.async_get_network_overview = AsyncMock( side_effect=DeviceUnavailable ) - async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) + freezer.tick(LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(state_key) @@ -68,7 +70,8 @@ async def test_update_attached_to_router( mock_device.plcnet.async_get_network_overview = AsyncMock( return_value=PLCNET_ATTACHED ) - async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) + freezer.tick(LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(state_key) diff --git a/tests/components/devolo_home_network/test_device_tracker.py b/tests/components/devolo_home_network/test_device_tracker.py index 324f8b44041..8f58b1154de 100644 --- a/tests/components/devolo_home_network/test_device_tracker.py +++ b/tests/components/devolo_home_network/test_device_tracker.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock from devolo_plc_api.exceptions.device import DeviceUnavailable +from freezegun.api import FrozenDateTimeFactory from syrupy.assertion import SnapshotAssertion from homeassistant.components.device_tracker import DOMAIN as PLATFORM @@ -12,7 +13,6 @@ from homeassistant.components.devolo_home_network.const import ( from homeassistant.const import STATE_NOT_HOME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt as dt_util from . import configure_integration from .const import CONNECTED_STATIONS, DISCOVERY_INFO, NO_CONNECTED_STATIONS @@ -28,6 +28,7 @@ async def test_device_tracker( hass: HomeAssistant, mock_device: MockDevice, entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, ) -> None: """Test device tracker states.""" @@ -37,13 +38,15 @@ async def test_device_tracker( entry = configure_integration(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) + freezer.tick(LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() # Enable entity entity_registry.async_update_entity(state_key, disabled_by=None) await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) + freezer.tick(LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(state_key) == snapshot @@ -52,7 +55,8 @@ async def test_device_tracker( mock_device.device.async_get_wifi_connected_station = AsyncMock( return_value=NO_CONNECTED_STATIONS ) - async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) + freezer.tick(LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(state_key) @@ -63,7 +67,8 @@ async def test_device_tracker( mock_device.device.async_get_wifi_connected_station = AsyncMock( side_effect=DeviceUnavailable ) - async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) + freezer.tick(LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(state_key) diff --git a/tests/components/devolo_home_network/test_sensor.py b/tests/components/devolo_home_network/test_sensor.py index dc7842e5fbd..230457f5617 100644 --- a/tests/components/devolo_home_network/test_sensor.py +++ b/tests/components/devolo_home_network/test_sensor.py @@ -3,6 +3,7 @@ from datetime import timedelta from unittest.mock import AsyncMock from devolo_plc_api.exceptions.device import DeviceUnavailable +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -14,7 +15,6 @@ from homeassistant.components.sensor import DOMAIN from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt as dt_util from . import configure_integration from .mock import MockDevice @@ -62,6 +62,7 @@ async def test_sensor( hass: HomeAssistant, mock_device: MockDevice, entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, name: str, get_method: str, @@ -80,7 +81,8 @@ async def test_sensor( # Emulate device failure setattr(mock_device.device, get_method, AsyncMock(side_effect=DeviceUnavailable)) setattr(mock_device.plcnet, get_method, AsyncMock(side_effect=DeviceUnavailable)) - async_fire_time_changed(hass, dt_util.utcnow() + interval) + freezer.tick(interval) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(state_key) @@ -89,7 +91,8 @@ async def test_sensor( # Emulate state change mock_device.reset() - async_fire_time_changed(hass, dt_util.utcnow() + interval) + freezer.tick(interval) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(state_key) diff --git a/tests/components/devolo_home_network/test_switch.py b/tests/components/devolo_home_network/test_switch.py index 00c06a6acc1..c77a77e87de 100644 --- a/tests/components/devolo_home_network/test_switch.py +++ b/tests/components/devolo_home_network/test_switch.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, patch from devolo_plc_api.device_api import WifiGuestAccessGet from devolo_plc_api.exceptions.device import DevicePasswordProtected, DeviceUnavailable +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -24,7 +25,6 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.update_coordinator import REQUEST_REFRESH_DEFAULT_COOLDOWN -from homeassistant.util import dt as dt_util from . import configure_integration from .mock import MockDevice @@ -75,6 +75,7 @@ async def test_update_enable_guest_wifi( hass: HomeAssistant, mock_device: MockDevice, entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, ) -> None: """Test state change of a enable_guest_wifi switch device.""" @@ -92,7 +93,8 @@ async def test_update_enable_guest_wifi( mock_device.device.async_get_wifi_guest_access.return_value = WifiGuestAccessGet( enabled=True ) - async_fire_time_changed(hass, dt_util.utcnow() + SHORT_UPDATE_INTERVAL) + freezer.tick(SHORT_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(state_key) @@ -116,9 +118,8 @@ async def test_update_enable_guest_wifi( assert state.state == STATE_OFF turn_off.assert_called_once_with(False) - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=REQUEST_REFRESH_DEFAULT_COOLDOWN) - ) + freezer.tick(REQUEST_REFRESH_DEFAULT_COOLDOWN) + async_fire_time_changed(hass) await hass.async_block_till_done() # Switch on @@ -138,9 +139,8 @@ async def test_update_enable_guest_wifi( assert state.state == STATE_ON turn_on.assert_called_once_with(True) - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=REQUEST_REFRESH_DEFAULT_COOLDOWN) - ) + freezer.tick(REQUEST_REFRESH_DEFAULT_COOLDOWN) + async_fire_time_changed(hass) await hass.async_block_till_done() # Device unavailable @@ -164,6 +164,7 @@ async def test_update_enable_leds( hass: HomeAssistant, mock_device: MockDevice, entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, ) -> None: """Test state change of a enable_leds switch device.""" @@ -179,7 +180,8 @@ async def test_update_enable_leds( # Emulate state change mock_device.device.async_get_led_setting.return_value = True - async_fire_time_changed(hass, dt_util.utcnow() + SHORT_UPDATE_INTERVAL) + freezer.tick(SHORT_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(state_key) @@ -201,9 +203,8 @@ async def test_update_enable_leds( assert state.state == STATE_OFF turn_off.assert_called_once_with(False) - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=REQUEST_REFRESH_DEFAULT_COOLDOWN) - ) + freezer.tick(REQUEST_REFRESH_DEFAULT_COOLDOWN) + async_fire_time_changed(hass) await hass.async_block_till_done() # Switch on @@ -221,9 +222,8 @@ async def test_update_enable_leds( assert state.state == STATE_ON turn_on.assert_called_once_with(True) - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=REQUEST_REFRESH_DEFAULT_COOLDOWN) - ) + freezer.tick(REQUEST_REFRESH_DEFAULT_COOLDOWN) + async_fire_time_changed(hass) await hass.async_block_till_done() # Device unavailable @@ -253,6 +253,7 @@ async def test_update_enable_leds( async def test_device_failure( hass: HomeAssistant, mock_device: MockDevice, + freezer: FrozenDateTimeFactory, name: str, get_method: str, update_interval: timedelta, @@ -270,7 +271,8 @@ async def test_device_failure( api = getattr(mock_device.device, get_method) api.side_effect = DeviceUnavailable - async_fire_time_changed(hass, dt_util.utcnow() + update_interval) + freezer.tick(update_interval) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(state_key) diff --git a/tests/components/devolo_home_network/test_update.py b/tests/components/devolo_home_network/test_update.py index f5ef0bc9381..97d313d9273 100644 --- a/tests/components/devolo_home_network/test_update.py +++ b/tests/components/devolo_home_network/test_update.py @@ -1,6 +1,7 @@ """Tests for the devolo Home Network update.""" from devolo_plc_api.device_api import UPDATE_NOT_AVAILABLE, UpdateFirmwareCheck from devolo_plc_api.exceptions.device import DevicePasswordProtected, DeviceUnavailable +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.devolo_home_network.const import ( @@ -18,7 +19,6 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity import EntityCategory -from homeassistant.util import dt as dt_util from . import configure_integration from .const import FIRMWARE_UPDATE_AVAILABLE @@ -41,7 +41,10 @@ async def test_update_setup(hass: HomeAssistant) -> None: async def test_update_firmware( - hass: HomeAssistant, mock_device: MockDevice, entity_registry: er.EntityRegistry + hass: HomeAssistant, + mock_device: MockDevice, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, ) -> None: """Test updating a device.""" entry = configure_integration(hass) @@ -75,7 +78,8 @@ async def test_update_firmware( mock_device.device.async_check_firmware_available.return_value = ( UpdateFirmwareCheck(result=UPDATE_NOT_AVAILABLE) ) - async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) + freezer.tick(LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(state_key) @@ -86,7 +90,9 @@ async def test_update_firmware( async def test_device_failure_check( - hass: HomeAssistant, mock_device: MockDevice + hass: HomeAssistant, + mock_device: MockDevice, + freezer: FrozenDateTimeFactory, ) -> None: """Test device failure during check.""" entry = configure_integration(hass) @@ -100,7 +106,8 @@ async def test_device_failure_check( assert state is not None mock_device.device.async_check_firmware_available.side_effect = DeviceUnavailable - async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) + freezer.tick(LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(state_key) From e0af9de87730d42e815b89614c6074db4ff24734 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 25 Aug 2023 16:00:11 +0200 Subject: [PATCH 092/124] Use freezegun in motioneye tests (#99038) --- tests/components/motioneye/test_sensor.py | 22 ++++++++++------- tests/components/motioneye/test_switch.py | 29 ++++++++++++++--------- 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/tests/components/motioneye/test_sensor.py b/tests/components/motioneye/test_sensor.py index 5494e69d9e9..659738ef2c5 100644 --- a/tests/components/motioneye/test_sensor.py +++ b/tests/components/motioneye/test_sensor.py @@ -3,6 +3,7 @@ import copy from datetime import timedelta from unittest.mock import AsyncMock, patch +from freezegun.api import FrozenDateTimeFactory from motioneye_client.const import KEY_ACTIONS from homeassistant.components.motioneye import get_motioneye_device_identifier @@ -14,7 +15,6 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util from . import ( TEST_CAMERA, @@ -28,7 +28,9 @@ from . import ( from tests.common import async_fire_time_changed -async def test_sensor_actions(hass: HomeAssistant) -> None: +async def test_sensor_actions( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test the actions sensor.""" register_test_entity( hass, @@ -51,7 +53,8 @@ async def test_sensor_actions(hass: HomeAssistant) -> None: # When the next refresh is called return the updated values. client.async_get_cameras = AsyncMock(return_value={"cameras": [updated_camera]}) - async_fire_time_changed(hass, dt_util.utcnow() + DEFAULT_SCAN_INTERVAL) + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() entity_state = hass.states.get(TEST_SENSOR_ACTION_ENTITY_ID) @@ -60,7 +63,8 @@ async def test_sensor_actions(hass: HomeAssistant) -> None: assert entity_state.attributes.get(KEY_ACTIONS) == ["one"] del updated_camera[KEY_ACTIONS] - async_fire_time_changed(hass, dt_util.utcnow() + DEFAULT_SCAN_INTERVAL) + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() entity_state = hass.states.get(TEST_SENSOR_ACTION_ENTITY_ID) @@ -99,7 +103,9 @@ async def test_sensor_device_info(hass: HomeAssistant) -> None: assert TEST_SENSOR_ACTION_ENTITY_ID in entities_from_device -async def test_sensor_actions_can_be_enabled(hass: HomeAssistant) -> None: +async def test_sensor_actions_can_be_enabled( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Verify the action sensor can be enabled.""" client = create_mock_motioneye_client() await setup_mock_motioneye_config_entry(hass, client=client) @@ -122,10 +128,8 @@ async def test_sensor_actions_can_be_enabled(hass: HomeAssistant) -> None: assert not updated_entry.disabled await hass.async_block_till_done() - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), - ) + freezer.tick(timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1)) + async_fire_time_changed(hass) await hass.async_block_till_done() entity_state = hass.states.get(TEST_SENSOR_ACTION_ENTITY_ID) diff --git a/tests/components/motioneye/test_switch.py b/tests/components/motioneye/test_switch.py index f0fe4f1faba..cc193f5fb60 100644 --- a/tests/components/motioneye/test_switch.py +++ b/tests/components/motioneye/test_switch.py @@ -3,6 +3,7 @@ import copy from datetime import timedelta from unittest.mock import AsyncMock, call, patch +from freezegun.api import FrozenDateTimeFactory from motioneye_client.const import ( KEY_MOTION_DETECTION, KEY_MOVIES, @@ -19,7 +20,6 @@ from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util from . import ( TEST_CAMERA, @@ -34,7 +34,9 @@ from . import ( from tests.common import async_fire_time_changed -async def test_switch_turn_on_off(hass: HomeAssistant) -> None: +async def test_switch_turn_on_off( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test turning the switch on and off.""" client = create_mock_motioneye_client() await setup_mock_motioneye_config_entry(hass, client=client) @@ -60,7 +62,8 @@ async def test_switch_turn_on_off(hass: HomeAssistant) -> None: blocking=True, ) - async_fire_time_changed(hass, dt_util.utcnow() + DEFAULT_SCAN_INTERVAL) + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() # Verify correct parameters are passed to the library. @@ -85,7 +88,8 @@ async def test_switch_turn_on_off(hass: HomeAssistant) -> None: # Verify correct parameters are passed to the library. assert client.async_set_camera.call_args == call(TEST_CAMERA_ID, TEST_CAMERA) - async_fire_time_changed(hass, dt_util.utcnow() + DEFAULT_SCAN_INTERVAL) + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() # Verify the switch turns on. @@ -94,7 +98,9 @@ async def test_switch_turn_on_off(hass: HomeAssistant) -> None: assert entity_state.state == "on" -async def test_switch_state_update_from_coordinator(hass: HomeAssistant) -> None: +async def test_switch_state_update_from_coordinator( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test that coordinator data impacts state.""" client = create_mock_motioneye_client() await setup_mock_motioneye_config_entry(hass, client=client) @@ -108,7 +114,8 @@ async def test_switch_state_update_from_coordinator(hass: HomeAssistant) -> None updated_cameras["cameras"][0][KEY_MOTION_DETECTION] = False client.async_get_cameras = AsyncMock(return_value=updated_cameras) - async_fire_time_changed(hass, dt_util.utcnow() + DEFAULT_SCAN_INTERVAL) + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() # Verify the switch turns off. @@ -144,7 +151,9 @@ async def test_switch_has_correct_entities(hass: HomeAssistant) -> None: assert not entity_state -async def test_disabled_switches_can_be_enabled(hass: HomeAssistant) -> None: +async def test_disabled_switches_can_be_enabled( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Verify disabled switches can be enabled.""" client = create_mock_motioneye_client() await setup_mock_motioneye_config_entry(hass, client=client) @@ -174,10 +183,8 @@ async def test_disabled_switches_can_be_enabled(hass: HomeAssistant) -> None: assert not updated_entry.disabled await hass.async_block_till_done() - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), - ) + freezer.tick(timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1)) + async_fire_time_changed(hass) await hass.async_block_till_done() entity_state = hass.states.get(entity_id) From ee073e9e3e8f9d151da6ebfee3b9d3e3f18ac45e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 25 Aug 2023 16:00:30 +0200 Subject: [PATCH 093/124] Use freezegun in lacrosse_view tests (#99036) --- tests/components/lacrosse_view/test_init.py | 26 ++++++++++----------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/tests/components/lacrosse_view/test_init.py b/tests/components/lacrosse_view/test_init.py index 557f8c4234a..2b3f5927bd2 100644 --- a/tests/components/lacrosse_view/test_init.py +++ b/tests/components/lacrosse_view/test_init.py @@ -1,8 +1,8 @@ """Test the LaCrosse View initialization.""" -from datetime import datetime, timedelta +from datetime import timedelta from unittest.mock import patch -from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory from lacrosse_view import HTTPError, LoginError from homeassistant.components.lacrosse_view.const import DOMAIN @@ -74,7 +74,7 @@ async def test_http_error(hass: HomeAssistant) -> None: assert entries[0].state == ConfigEntryState.SETUP_RETRY -async def test_new_token(hass: HomeAssistant) -> None: +async def test_new_token(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test new token.""" config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) @@ -92,19 +92,20 @@ async def test_new_token(hass: HomeAssistant) -> None: assert len(entries) == 1 assert entries[0].state == ConfigEntryState.LOADED - one_hour_after = datetime.now() + timedelta(hours=1) - with patch("lacrosse_view.LaCrosse.login", return_value=True) as login, patch( "lacrosse_view.LaCrosse.get_sensors", return_value=[TEST_SENSOR], - ), freeze_time(one_hour_after): - async_fire_time_changed(hass, one_hour_after) + ): + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) await hass.async_block_till_done() login.assert_called_once() -async def test_failed_token(hass: HomeAssistant) -> None: +async def test_failed_token( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test if a reauth flow occurs when token refresh fails.""" config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) @@ -122,12 +123,9 @@ async def test_failed_token(hass: HomeAssistant) -> None: assert len(entries) == 1 assert entries[0].state == ConfigEntryState.LOADED - one_hour_after = datetime.now() + timedelta(hours=1) - - with patch( - "lacrosse_view.LaCrosse.login", side_effect=LoginError("Test") - ), freeze_time(one_hour_after): - async_fire_time_changed(hass, one_hour_after) + with patch("lacrosse_view.LaCrosse.login", side_effect=LoginError("Test")): + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) await hass.async_block_till_done() entries = hass.config_entries.async_entries(DOMAIN) From f18a277cac431b1878daa10cd2592df22eec3c4c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 25 Aug 2023 16:00:50 +0200 Subject: [PATCH 094/124] Use freezegun in ws66i tests (#99049) --- tests/components/ws66i/test_media_player.py | 47 ++++++++++++++------- 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/tests/components/ws66i/test_media_player.py b/tests/components/ws66i/test_media_player.py index 2cdab824040..c4a10197a34 100644 --- a/tests/components/ws66i/test_media_player.py +++ b/tests/components/ws66i/test_media_player.py @@ -2,6 +2,8 @@ from collections import defaultdict from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory + from homeassistant.components.media_player import ( ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE_LIST, @@ -31,7 +33,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util.dt import utcnow from tests.common import MockConfigEntry, async_fire_time_changed @@ -174,7 +175,7 @@ async def _call_media_player_service(hass, name, data): ) -async def test_update(hass: HomeAssistant) -> None: +async def test_update(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test updating values from ws66i.""" ws66i = MockWs66i() _ = await _setup_ws66i_with_options(hass, ws66i) @@ -191,7 +192,8 @@ async def test_update(hass: HomeAssistant) -> None: ws66i.set_volume(11, MAX_VOL) with patch.object(MockWs66i, "open") as method_call: - async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) + freezer.tick(POLL_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert not method_call.called @@ -203,7 +205,9 @@ async def test_update(hass: HomeAssistant) -> None: assert state.attributes[ATTR_INPUT_SOURCE] == "three" -async def test_failed_update(hass: HomeAssistant) -> None: +async def test_failed_update( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test updating failure from ws66i.""" ws66i = MockWs66i() _ = await _setup_ws66i_with_options(hass, ws66i) @@ -219,23 +223,27 @@ async def test_failed_update(hass: HomeAssistant) -> None: ws66i.set_source(11, 3) ws66i.set_volume(11, MAX_VOL) - async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) + freezer.tick(POLL_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() # Failed update, close called with patch.object(MockWs66i, "zone_status", return_value=None): - async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) + freezer.tick(POLL_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.is_state(ZONE_1_ID, STATE_UNAVAILABLE) # A connection re-attempt fails with patch.object(MockWs66i, "zone_status", return_value=None): - async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) + freezer.tick(POLL_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() # A connection re-attempt succeeds - async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) + freezer.tick(POLL_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() # confirm entity is back on @@ -295,14 +303,17 @@ async def test_select_source(hass: HomeAssistant) -> None: assert ws66i.zones[11].source == 3 -async def test_source_select(hass: HomeAssistant) -> None: +async def test_source_select( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test source selection simulated from keypad.""" ws66i = MockWs66i() _ = await _setup_ws66i_with_options(hass, ws66i) ws66i.set_source(11, 5) - async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) + freezer.tick(POLL_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(ZONE_1_ID) @@ -341,7 +352,9 @@ async def test_mute_volume(hass: HomeAssistant) -> None: assert ws66i.zones[11].mute -async def test_volume_up_down(hass: HomeAssistant) -> None: +async def test_volume_up_down( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test increasing volume by one.""" ws66i = MockWs66i() _ = await _setup_ws66i(hass, ws66i) @@ -354,26 +367,30 @@ async def test_volume_up_down(hass: HomeAssistant) -> None: await _call_media_player_service( hass, SERVICE_VOLUME_DOWN, {"entity_id": ZONE_1_ID} ) - async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) + freezer.tick(POLL_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() # should not go below zero assert ws66i.zones[11].volume == 0 await _call_media_player_service(hass, SERVICE_VOLUME_UP, {"entity_id": ZONE_1_ID}) - async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) + freezer.tick(POLL_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert ws66i.zones[11].volume == 1 await _call_media_player_service( hass, SERVICE_VOLUME_SET, {"entity_id": ZONE_1_ID, "volume_level": 1.0} ) - async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) + freezer.tick(POLL_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert ws66i.zones[11].volume == MAX_VOL await _call_media_player_service(hass, SERVICE_VOLUME_UP, {"entity_id": ZONE_1_ID}) - async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) + freezer.tick(POLL_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() # should not go above 38 (MAX_VOL) assert ws66i.zones[11].volume == MAX_VOL From f99743bedb44513fdf332edbbca752af035b8947 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 25 Aug 2023 16:01:28 +0200 Subject: [PATCH 095/124] Use freezegun in tomorrowio tests (#99044) --- tests/components/tomorrowio/test_init.py | 82 +++++++++++------------- 1 file changed, 38 insertions(+), 44 deletions(-) diff --git a/tests/components/tomorrowio/test_init.py b/tests/components/tomorrowio/test_init.py index 5fd954859b1..fe17bbe79b7 100644 --- a/tests/components/tomorrowio/test_init.py +++ b/tests/components/tomorrowio/test_init.py @@ -1,6 +1,7 @@ """Tests for Tomorrow.io init.""" from datetime import timedelta -from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory from homeassistant.components.tomorrowio.config_flow import ( _get_config_schema, @@ -11,7 +12,6 @@ from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.util import dt as dt_util from .const import MIN_CONFIG @@ -42,10 +42,9 @@ async def test_load_and_unload(hass: HomeAssistant) -> None: async def test_update_intervals( - hass: HomeAssistant, tomorrowio_config_entry_update + hass: HomeAssistant, freezer: FrozenDateTimeFactory, tomorrowio_config_entry_update ) -> None: """Test coordinator update intervals.""" - now = dt_util.utcnow() data = _get_config_schema(hass, SOURCE_USER)(MIN_CONFIG) data[CONF_NAME] = "test" config_entry = MockConfigEntry( @@ -56,63 +55,58 @@ async def test_update_intervals( version=1, ) config_entry.add_to_hass(hass) - with patch("homeassistant.helpers.update_coordinator.utcnow", return_value=now): - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - assert len(tomorrowio_config_entry_update.call_args_list) == 1 + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert len(tomorrowio_config_entry_update.call_args_list) == 1 tomorrowio_config_entry_update.reset_mock() # Before the update interval, no updates yet - future = now + timedelta(minutes=30) - with patch("homeassistant.helpers.update_coordinator.utcnow", return_value=future): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - assert len(tomorrowio_config_entry_update.call_args_list) == 0 + freezer.tick(timedelta(minutes=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(tomorrowio_config_entry_update.call_args_list) == 0 tomorrowio_config_entry_update.reset_mock() # On the update interval, we get a new update - future = now + timedelta(minutes=32) - with patch("homeassistant.helpers.update_coordinator.utcnow", return_value=future): - async_fire_time_changed(hass, now + timedelta(minutes=32)) - await hass.async_block_till_done() - assert len(tomorrowio_config_entry_update.call_args_list) == 1 + freezer.tick(timedelta(minutes=2)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(tomorrowio_config_entry_update.call_args_list) == 1 - tomorrowio_config_entry_update.reset_mock() + tomorrowio_config_entry_update.reset_mock() - # Adding a second config entry should cause the update interval to double - config_entry_2 = MockConfigEntry( - domain=DOMAIN, - data=data, - options={CONF_TIMESTEP: 1}, - unique_id=f"{_get_unique_id(hass, data)}_1", - version=1, - ) - config_entry_2.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry_2.entry_id) - await hass.async_block_till_done() - assert config_entry.data[CONF_API_KEY] == config_entry_2.data[CONF_API_KEY] - # We should get an immediate call once the new config entry is setup for a - # partial update - assert len(tomorrowio_config_entry_update.call_args_list) == 1 + # Adding a second config entry should cause the update interval to double + config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data=data, + options={CONF_TIMESTEP: 1}, + unique_id=f"{_get_unique_id(hass, data)}_1", + version=1, + ) + config_entry_2.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry_2.entry_id) + await hass.async_block_till_done() + assert config_entry.data[CONF_API_KEY] == config_entry_2.data[CONF_API_KEY] + # We should get an immediate call once the new config entry is setup for a + # partial update + assert len(tomorrowio_config_entry_update.call_args_list) == 1 tomorrowio_config_entry_update.reset_mock() # We should get no new calls on our old interval - future = now + timedelta(minutes=64) - with patch("homeassistant.helpers.update_coordinator.utcnow", return_value=future): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - assert len(tomorrowio_config_entry_update.call_args_list) == 0 + freezer.tick(timedelta(minutes=32)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(tomorrowio_config_entry_update.call_args_list) == 0 tomorrowio_config_entry_update.reset_mock() # We should get two calls on our new interval, one for each entry - future = now + timedelta(minutes=96) - with patch("homeassistant.helpers.update_coordinator.utcnow", return_value=future): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - assert len(tomorrowio_config_entry_update.call_args_list) == 2 + freezer.tick(timedelta(minutes=32)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(tomorrowio_config_entry_update.call_args_list) == 2 tomorrowio_config_entry_update.reset_mock() From 81aa35a9ce38a887c028393a3e30b55f4fe48f86 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 25 Aug 2023 16:01:48 +0200 Subject: [PATCH 096/124] Use freezegun in version tests (#99047) --- tests/components/version/common.py | 9 +++++---- tests/components/version/test_sensor.py | 11 ++++++++--- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/tests/components/version/common.py b/tests/components/version/common.py index 3e3ae6c3970..c4759604a44 100644 --- a/tests/components/version/common.py +++ b/tests/components/version/common.py @@ -4,6 +4,8 @@ from __future__ import annotations from typing import Any, Final from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory + from homeassistant import config_entries from homeassistant.components.version.const import ( DEFAULT_CONFIGURATION, @@ -14,7 +16,6 @@ from homeassistant.components.version.const import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -37,6 +38,7 @@ TEST_DEFAULT_IMPORT_CONFIG: Final = { async def mock_get_version_update( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, version: str = MOCK_VERSION, data: dict[str, Any] = MOCK_VERSION_DATA, side_effect: Exception = None, @@ -47,9 +49,8 @@ async def mock_get_version_update( return_value=(version, data), side_effect=side_effect, ): - async_fire_time_changed( - hass, dt_util.utcnow() + UPDATE_COORDINATOR_UPDATE_INTERVAL - ) + freezer.tick(UPDATE_COORDINATOR_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() diff --git a/tests/components/version/test_sensor.py b/tests/components/version/test_sensor.py index 1c7f9040b22..0a3e89494f1 100644 --- a/tests/components/version/test_sensor.py +++ b/tests/components/version/test_sensor.py @@ -1,6 +1,7 @@ """The test for the version sensor platform.""" from __future__ import annotations +from freezegun.api import FrozenDateTimeFactory from pyhaversion.exceptions import HaVersionException import pytest @@ -19,15 +20,19 @@ async def test_version_sensor(hass: HomeAssistant) -> None: assert "channel" not in state.attributes -async def test_update(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: +async def test_update( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, +) -> None: """Test updates.""" await setup_version_integration(hass) assert hass.states.get("sensor.local_installation").state == MOCK_VERSION - await mock_get_version_update(hass, version="1970.1.1") + await mock_get_version_update(hass, freezer, version="1970.1.1") assert hass.states.get("sensor.local_installation").state == "1970.1.1" assert "Error fetching version data" not in caplog.text - await mock_get_version_update(hass, side_effect=HaVersionException) + await mock_get_version_update(hass, freezer, side_effect=HaVersionException) assert hass.states.get("sensor.local_installation").state == "unavailable" assert "Error fetching version data" in caplog.text From 676f59fdedaedc257e1a3931993549d78c17aba6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 25 Aug 2023 16:02:07 +0200 Subject: [PATCH 097/124] Use freezegun in trafikverket_ferry tests (#99045) --- .../trafikverket_ferry/test_coordinator.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/components/trafikverket_ferry/test_coordinator.py b/tests/components/trafikverket_ferry/test_coordinator.py index 591486474d3..c0fbe7537cc 100644 --- a/tests/components/trafikverket_ferry/test_coordinator.py +++ b/tests/components/trafikverket_ferry/test_coordinator.py @@ -24,6 +24,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed async def test_coordinator( hass: HomeAssistant, entity_registry_enabled_by_default: None, + freezer: FrozenDateTimeFactory, monkeypatch: pytest.MonkeyPatch, get_ferries: list[FerryStop], ) -> None: @@ -59,7 +60,8 @@ async def test_coordinator( datetime(dt_util.now().year + 2, 5, 1, 12, 0, tzinfo=dt_util.UTC), ) - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=6)) + freezer.tick(timedelta(minutes=6)) + async_fire_time_changed(hass) await hass.async_block_till_done() mock_data.assert_called_once() state1 = hass.states.get("sensor.harbor1_departure_from") @@ -71,7 +73,8 @@ async def test_coordinator( mock_data.reset_mock() mock_data.side_effect = NoFerryFound() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=6)) + freezer.tick(timedelta(minutes=6)) + async_fire_time_changed(hass) await hass.async_block_till_done() mock_data.assert_called_once() state1 = hass.states.get("sensor.harbor1_departure_from") @@ -80,7 +83,8 @@ async def test_coordinator( mock_data.return_value = get_ferries mock_data.side_effect = None - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=6)) + freezer.tick(timedelta(minutes=6)) + async_fire_time_changed(hass) await hass.async_block_till_done() # mock_data.assert_called_once() state1 = hass.states.get("sensor.harbor1_departure_from") @@ -88,7 +92,8 @@ async def test_coordinator( mock_data.reset_mock() mock_data.side_effect = InvalidAuthentication() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=6)) + freezer.tick(timedelta(minutes=6)) + async_fire_time_changed(hass) await hass.async_block_till_done() mock_data.assert_called_once() state1 = hass.states.get("sensor.harbor1_departure_from") From 75743ed947602cbb0d80b56ab3033de2e3be47de Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 25 Aug 2023 16:02:25 +0200 Subject: [PATCH 098/124] Use freezegun in here_travel_time tests (#99032) --- .../here_travel_time/test_sensor.py | 34 ++++++++----------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/tests/components/here_travel_time/test_sensor.py b/tests/components/here_travel_time/test_sensor.py index 91439a11d95..28228788cf5 100644 --- a/tests/components/here_travel_time/test_sensor.py +++ b/tests/components/here_travel_time/test_sensor.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import MagicMock, patch +from freezegun.api import FrozenDateTimeFactory from here_routing import ( HERERoutingError, HERERoutingTooManyRequestsError, @@ -64,7 +65,6 @@ from homeassistant.const import ( ) from homeassistant.core import CoreState, HomeAssistant, State from homeassistant.setup import async_setup_component -from homeassistant.util.dt import utcnow from .conftest import RESPONSE, TRANSIT_RESPONSE from .const import ( @@ -662,7 +662,9 @@ async def test_transit_errors( async def test_routing_rate_limit( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, ) -> None: """Test that rate limiting is applied when encountering HTTP 429.""" with patch( @@ -689,9 +691,8 @@ async def test_routing_rate_limit( "Rate limit for this service has been reached" ), ): - async_fire_time_changed( - hass, utcnow() + timedelta(seconds=DEFAULT_SCAN_INTERVAL + 1) - ) + freezer.tick(timedelta(seconds=DEFAULT_SCAN_INTERVAL + 1)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get("sensor.test_distance").state == "unavailable" @@ -701,18 +702,17 @@ async def test_routing_rate_limit( "here_routing.HERERoutingApi.route", return_value=RESPONSE, ): - async_fire_time_changed( - hass, - utcnow() - + timedelta(seconds=DEFAULT_SCAN_INTERVAL * BACKOFF_MULTIPLIER + 1), - ) + freezer.tick(timedelta(seconds=DEFAULT_SCAN_INTERVAL * BACKOFF_MULTIPLIER + 1)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get("sensor.test_distance").state == "13.682" assert "Resetting update interval to" in caplog.text async def test_transit_rate_limit( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, ) -> None: """Test that rate limiting is applied when encountering HTTP 429.""" with patch( @@ -747,9 +747,8 @@ async def test_transit_rate_limit( "Rate limit for this service has been reached" ), ): - async_fire_time_changed( - hass, utcnow() + timedelta(seconds=DEFAULT_SCAN_INTERVAL + 1) - ) + freezer.tick(timedelta(seconds=DEFAULT_SCAN_INTERVAL + 1)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get("sensor.test_distance").state == "unavailable" @@ -759,11 +758,8 @@ async def test_transit_rate_limit( "here_transit.HERETransitApi.route", return_value=TRANSIT_RESPONSE, ): - async_fire_time_changed( - hass, - utcnow() - + timedelta(seconds=DEFAULT_SCAN_INTERVAL * BACKOFF_MULTIPLIER + 1), - ) + freezer.tick(timedelta(seconds=DEFAULT_SCAN_INTERVAL * BACKOFF_MULTIPLIER + 1)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get("sensor.test_distance").state == "1.883" assert "Resetting update interval to" in caplog.text From cb8842b1fb56872ad6a62ee75e181de4755e7fea Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 25 Aug 2023 16:02:47 +0200 Subject: [PATCH 099/124] Use freezegun in landisgyr_heat_meter tests (#99037) --- tests/components/landisgyr_heat_meter/test_sensor.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/components/landisgyr_heat_meter/test_sensor.py b/tests/components/landisgyr_heat_meter/test_sensor.py index e28ebe695b3..5ed2a397ccd 100644 --- a/tests/components/landisgyr_heat_meter/test_sensor.py +++ b/tests/components/landisgyr_heat_meter/test_sensor.py @@ -2,6 +2,7 @@ import datetime from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest import serial from syrupy import SnapshotAssertion @@ -122,7 +123,9 @@ async def test_create_sensors( @patch(API_HEAT_METER_SERVICE) -async def test_exception_on_polling(mock_heat_meter, hass: HomeAssistant) -> None: +async def test_exception_on_polling( + mock_heat_meter, hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test sensor.""" entry_data = { "device": "/dev/USB0", @@ -148,7 +151,8 @@ async def test_exception_on_polling(mock_heat_meter, hass: HomeAssistant) -> Non # Now 'disable' the connection and wait for polling and see if it fails mock_heat_meter().read.side_effect = serial.serialutil.SerialException - async_fire_time_changed(hass, dt_util.utcnow() + POLLING_INTERVAL) + freezer.tick(POLLING_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.heat_meter_heat_usage_gj") assert state.state == STATE_UNAVAILABLE @@ -159,7 +163,8 @@ async def test_exception_on_polling(mock_heat_meter, hass: HomeAssistant) -> Non mock_heat_meter().read.return_value = mock_heat_meter_response mock_heat_meter().read.side_effect = None - async_fire_time_changed(hass, dt_util.utcnow() + POLLING_INTERVAL) + freezer.tick(POLLING_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.heat_meter_heat_usage_gj") assert state From e6728f2f19534c8692d1f5c00aa6bffc885fa16a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 25 Aug 2023 16:03:12 +0200 Subject: [PATCH 100/124] Use freezegun in kraken tests (#99035) --- tests/components/kraken/test_sensor.py | 47 ++++++++++++-------------- 1 file changed, 21 insertions(+), 26 deletions(-) diff --git a/tests/components/kraken/test_sensor.py b/tests/components/kraken/test_sensor.py index 1435e0d6b04..8efac3017e0 100644 --- a/tests/components/kraken/test_sensor.py +++ b/tests/components/kraken/test_sensor.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory from pykrakenapi.pykrakenapi import KrakenAPIError from homeassistant.components.kraken.const import ( @@ -13,7 +14,6 @@ from homeassistant.components.kraken.const import ( from homeassistant.const import CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_START from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util from .const import ( MISSING_PAIR_TICKER_INFORMATION_RESPONSE, @@ -25,11 +25,9 @@ from .const import ( from tests.common import MockConfigEntry, async_fire_time_changed -async def test_sensor(hass: HomeAssistant) -> None: +async def test_sensor(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test that sensor has a value.""" - utcnow = dt_util.utcnow() - # Patching 'utcnow' to gain more control over the timed update. - with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch( + with patch( "pykrakenapi.KrakenAPI.get_tradable_asset_pairs", return_value=TRADEABLE_ASSET_PAIR_RESPONSE, ), patch( @@ -230,11 +228,11 @@ async def test_sensor(hass: HomeAssistant) -> None: assert xbt_usd_opening_price_today.state == "0.0003513" -async def test_sensors_available_after_restart(hass: HomeAssistant) -> None: +async def test_sensors_available_after_restart( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test that all sensors are added again after a restart.""" - utcnow = dt_util.utcnow() - # Patching 'utcnow' to gain more control over the timed update. - with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch( + with patch( "pykrakenapi.KrakenAPI.get_tradable_asset_pairs", return_value=TRADEABLE_ASSET_PAIR_RESPONSE, ), patch( @@ -271,11 +269,11 @@ async def test_sensors_available_after_restart(hass: HomeAssistant) -> None: assert sensor.state == "0.0003494" -async def test_sensors_added_after_config_update(hass: HomeAssistant) -> None: +async def test_sensors_added_after_config_update( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test that sensors are added when another tracked asset pair is added.""" - utcnow = dt_util.utcnow() - # Patching 'utcnow' to gain more control over the timed update. - with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch( + with patch( "pykrakenapi.KrakenAPI.get_tradable_asset_pairs", return_value=TRADEABLE_ASSET_PAIR_RESPONSE, ), patch( @@ -309,19 +307,18 @@ async def test_sensors_added_after_config_update(hass: HomeAssistant) -> None: CONF_TRACKED_ASSET_PAIRS: [DEFAULT_TRACKED_ASSET_PAIR, "ADA/XBT"], }, ) - async_fire_time_changed( - hass, utcnow + timedelta(seconds=DEFAULT_SCAN_INTERVAL * 2) - ) + freezer.tick(timedelta(seconds=DEFAULT_SCAN_INTERVAL * 2)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get("sensor.ada_xbt_ask") -async def test_missing_pair_marks_sensor_unavailable(hass: HomeAssistant) -> None: +async def test_missing_pair_marks_sensor_unavailable( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test that a missing tradable asset pair marks the sensor unavailable.""" - utcnow = dt_util.utcnow() - # Patching 'utcnow' to gain more control over the timed update. - with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch( + with patch( "pykrakenapi.KrakenAPI.get_tradable_asset_pairs", return_value=TRADEABLE_ASSET_PAIR_RESPONSE, ) as tradeable_asset_pairs_mock, patch( @@ -353,16 +350,14 @@ async def test_missing_pair_marks_sensor_unavailable(hass: HomeAssistant) -> Non ticket_information_mock.side_effect = KrakenAPIError( "EQuery:Unknown asset pair" ) - async_fire_time_changed( - hass, utcnow + timedelta(seconds=DEFAULT_SCAN_INTERVAL * 2) - ) + freezer.tick(timedelta(seconds=DEFAULT_SCAN_INTERVAL * 2)) + async_fire_time_changed(hass) await hass.async_block_till_done() ticket_information_mock.side_effect = None ticket_information_mock.return_value = MISSING_PAIR_TICKER_INFORMATION_RESPONSE - async_fire_time_changed( - hass, utcnow + timedelta(seconds=DEFAULT_SCAN_INTERVAL * 2) - ) + freezer.tick(timedelta(seconds=DEFAULT_SCAN_INTERVAL * 2)) + async_fire_time_changed(hass) await hass.async_block_till_done() sensor = hass.states.get("sensor.xbt_usd_ask") From 1f9c1802332d5396e512083f4378894dbf34188f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 25 Aug 2023 16:03:29 +0200 Subject: [PATCH 101/124] Use freezegun in iotawatt tests (#99034) --- tests/components/iotawatt/test_sensor.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/tests/components/iotawatt/test_sensor.py b/tests/components/iotawatt/test_sensor.py index d9017955c75..5646115f59a 100644 --- a/tests/components/iotawatt/test_sensor.py +++ b/tests/components/iotawatt/test_sensor.py @@ -1,6 +1,8 @@ """Test setting up sensors.""" from datetime import timedelta +from freezegun.api import FrozenDateTimeFactory + from homeassistant.components.sensor import ( ATTR_STATE_CLASS, SensorDeviceClass, @@ -15,14 +17,15 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from homeassistant.util.dt import utcnow from . import INPUT_SENSOR, OUTPUT_SENSOR from tests.common import async_fire_time_changed -async def test_sensor_type_input(hass: HomeAssistant, mock_iotawatt) -> None: +async def test_sensor_type_input( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_iotawatt +) -> None: """Test input sensors work.""" assert await async_setup_component(hass, "iotawatt", {}) await hass.async_block_till_done() @@ -31,7 +34,8 @@ async def test_sensor_type_input(hass: HomeAssistant, mock_iotawatt) -> None: # Discover this sensor during a regular update. mock_iotawatt.getSensors.return_value["sensors"]["my_sensor_key"] = INPUT_SENSOR - async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert len(hass.states.async_entity_ids()) == 1 @@ -47,13 +51,16 @@ async def test_sensor_type_input(hass: HomeAssistant, mock_iotawatt) -> None: assert state.attributes["type"] == "Input" mock_iotawatt.getSensors.return_value["sensors"].pop("my_sensor_key") - async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get("sensor.my_sensor") is None -async def test_sensor_type_output(hass: HomeAssistant, mock_iotawatt) -> None: +async def test_sensor_type_output( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_iotawatt +) -> None: """Tests the sensor type of Output.""" mock_iotawatt.getSensors.return_value["sensors"][ "my_watthour_sensor_key" @@ -73,7 +80,8 @@ async def test_sensor_type_output(hass: HomeAssistant, mock_iotawatt) -> None: assert state.attributes["type"] == "Output" mock_iotawatt.getSensors.return_value["sensors"].pop("my_watthour_sensor_key") - async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get("sensor.my_watthour_sensor") is None From 3b07181d8760c2bff0244b0d49221802051c10ac Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 25 Aug 2023 16:03:51 +0200 Subject: [PATCH 102/124] Use freezegun in fully_kiosk tests (#99031) --- tests/components/fully_kiosk/test_binary_sensor.py | 9 ++++++--- tests/components/fully_kiosk/test_sensor.py | 8 ++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/components/fully_kiosk/test_binary_sensor.py b/tests/components/fully_kiosk/test_binary_sensor.py index 5b88854b020..db37139b0ba 100644 --- a/tests/components/fully_kiosk/test_binary_sensor.py +++ b/tests/components/fully_kiosk/test_binary_sensor.py @@ -1,6 +1,7 @@ """Test the Fully Kiosk Browser binary sensors.""" from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory from fullykiosk import FullyKioskError from homeassistant.components.binary_sensor import BinarySensorDeviceClass @@ -15,13 +16,13 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed async def test_binary_sensors( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, mock_fully_kiosk: MagicMock, init_integration: MockConfigEntry, ) -> None: @@ -76,7 +77,8 @@ async def test_binary_sensors( # Test unknown/missing data mock_fully_kiosk.getDeviceInfo.return_value = {} - async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("binary_sensor.amazon_fire_plugged_in") @@ -85,7 +87,8 @@ async def test_binary_sensors( # Test failed update mock_fully_kiosk.getDeviceInfo.side_effect = FullyKioskError("error", "status") - async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("binary_sensor.amazon_fire_plugged_in") diff --git a/tests/components/fully_kiosk/test_sensor.py b/tests/components/fully_kiosk/test_sensor.py index cc8b30640b5..05fd002a205 100644 --- a/tests/components/fully_kiosk/test_sensor.py +++ b/tests/components/fully_kiosk/test_sensor.py @@ -1,6 +1,7 @@ """Test the Fully Kiosk Browser sensors.""" from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory from fullykiosk import FullyKioskError from homeassistant.components.fully_kiosk.const import DOMAIN, UPDATE_INTERVAL @@ -25,6 +26,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed async def test_sensors_sensors( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, mock_fully_kiosk: MagicMock, init_integration: MockConfigEntry, ) -> None: @@ -141,7 +143,8 @@ async def test_sensors_sensors( # Test unknown/missing data mock_fully_kiosk.getDeviceInfo.return_value = {} - async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.amazon_fire_internal_storage_free_space") @@ -150,7 +153,8 @@ async def test_sensors_sensors( # Test failed update mock_fully_kiosk.getDeviceInfo.side_effect = FullyKioskError("error", "status") - async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.amazon_fire_internal_storage_free_space") From dd39e8fe64de533b3329fc76843ee220a3ea1dcb Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 25 Aug 2023 16:04:28 +0200 Subject: [PATCH 103/124] Use freezegun in hue tests (#99033) --- tests/components/hue/test_sensor_v1.py | 31 ++++++++++++-------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/tests/components/hue/test_sensor_v1.py b/tests/components/hue/test_sensor_v1.py index d5ac8406f24..1edaf18774f 100644 --- a/tests/components/hue/test_sensor_v1.py +++ b/tests/components/hue/test_sensor_v1.py @@ -3,6 +3,7 @@ import asyncio from unittest.mock import Mock import aiohue +from freezegun.api import FrozenDateTimeFactory from homeassistant.components import hue from homeassistant.components.hue.const import ATTR_HUE_EVENT @@ -10,7 +11,6 @@ from homeassistant.components.hue.v1 import sensor_base from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import async_get -from homeassistant.util import dt as dt_util from .conftest import create_mock_bridge, setup_platform @@ -448,7 +448,9 @@ async def test_update_unauthorized(hass: HomeAssistant, mock_bridge_v1) -> None: assert len(mock_bridge_v1.handle_unauthorized_error.mock_calls) == 1 -async def test_hue_events(hass: HomeAssistant, mock_bridge_v1, device_reg) -> None: +async def test_hue_events( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_bridge_v1, device_reg +) -> None: """Test that hue remotes fire events when pressed.""" mock_bridge_v1.mock_sensor_responses.append(SENSOR_RESPONSE) @@ -475,9 +477,8 @@ async def test_hue_events(hass: HomeAssistant, mock_bridge_v1, device_reg) -> No mock_bridge_v1.mock_sensor_responses.append(new_sensor_response) # Force updates to run again - async_fire_time_changed( - hass, dt_util.utcnow() + sensor_base.SensorManager.SCAN_INTERVAL - ) + freezer.tick(sensor_base.SensorManager.SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert len(mock_bridge_v1.mock_requests) == 2 @@ -504,9 +505,8 @@ async def test_hue_events(hass: HomeAssistant, mock_bridge_v1, device_reg) -> No mock_bridge_v1.mock_sensor_responses.append(new_sensor_response) # Force updates to run again - async_fire_time_changed( - hass, dt_util.utcnow() + sensor_base.SensorManager.SCAN_INTERVAL - ) + freezer.tick(sensor_base.SensorManager.SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert len(mock_bridge_v1.mock_requests) == 3 @@ -530,9 +530,8 @@ async def test_hue_events(hass: HomeAssistant, mock_bridge_v1, device_reg) -> No mock_bridge_v1.mock_sensor_responses.append(new_sensor_response) # Force updates to run again - async_fire_time_changed( - hass, dt_util.utcnow() + sensor_base.SensorManager.SCAN_INTERVAL - ) + freezer.tick(sensor_base.SensorManager.SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert len(mock_bridge_v1.mock_requests) == 4 @@ -575,9 +574,8 @@ async def test_hue_events(hass: HomeAssistant, mock_bridge_v1, device_reg) -> No mock_bridge_v1.mock_sensor_responses.append(new_sensor_response) # Force updates to run again - async_fire_time_changed( - hass, dt_util.utcnow() + sensor_base.SensorManager.SCAN_INTERVAL - ) + freezer.tick(sensor_base.SensorManager.SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert len(mock_bridge_v1.mock_requests) == 5 @@ -589,9 +587,8 @@ async def test_hue_events(hass: HomeAssistant, mock_bridge_v1, device_reg) -> No mock_bridge_v1.mock_sensor_responses.append(new_sensor_response) # Force updates to run again - async_fire_time_changed( - hass, dt_util.utcnow() + sensor_base.SensorManager.SCAN_INTERVAL - ) + freezer.tick(sensor_base.SensorManager.SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() hue_aurora_device = device_reg.async_get_device( From 5617a738c0847e37a5c81bc5ace4cd5da3ab4f08 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 25 Aug 2023 16:04:51 +0200 Subject: [PATCH 104/124] Use freezegun in airly tests (#99028) --- tests/components/airly/test_init.py | 80 ++++++++++++++--------------- 1 file changed, 39 insertions(+), 41 deletions(-) diff --git a/tests/components/airly/test_init.py b/tests/components/airly/test_init.py index f360beb8c51..9b69607e6aa 100644 --- a/tests/components/airly/test_init.py +++ b/tests/components/airly/test_init.py @@ -1,7 +1,7 @@ """Test init of Airly integration.""" from typing import Any -from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM @@ -11,7 +11,6 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.util.dt import utcnow from . import API_POINT_URL, init_integration @@ -99,7 +98,9 @@ async def test_config_with_turned_off_station( async def test_update_interval( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, ) -> None: """Test correct update interval when the number of configured instances changes.""" REMAINING_REQUESTS = 15 @@ -135,50 +136,47 @@ async def test_update_interval( assert entry.state is ConfigEntryState.LOADED update_interval = set_update_interval(instances, REMAINING_REQUESTS) - future = utcnow() + update_interval - with patch("homeassistant.util.dt.utcnow") as mock_utcnow: - mock_utcnow.return_value = future - async_fire_time_changed(hass, future) - await hass.async_block_till_done() + freezer.tick(update_interval) + async_fire_time_changed(hass) + await hass.async_block_till_done() - # call_count should increase by one because we have one instance configured - assert aioclient_mock.call_count == 2 + # call_count should increase by one because we have one instance configured + assert aioclient_mock.call_count == 2 - # Now we add the second Airly instance - entry = MockConfigEntry( - domain=DOMAIN, - title="Work", - unique_id="66.66-111.11", - data={ - "api_key": "foo", - "latitude": 66.66, - "longitude": 111.11, - "name": "Work", - }, - ) + # Now we add the second Airly instance + entry = MockConfigEntry( + domain=DOMAIN, + title="Work", + unique_id="66.66-111.11", + data={ + "api_key": "foo", + "latitude": 66.66, + "longitude": 111.11, + "name": "Work", + }, + ) - aioclient_mock.get( - "https://airapi.airly.eu/v2/measurements/point?lat=66.660000&lng=111.110000", - text=load_fixture("valid_station.json", "airly"), - headers=HEADERS, - ) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - instances = 2 + aioclient_mock.get( + "https://airapi.airly.eu/v2/measurements/point?lat=66.660000&lng=111.110000", + text=load_fixture("valid_station.json", "airly"), + headers=HEADERS, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + instances = 2 - assert aioclient_mock.call_count == 3 - assert len(hass.config_entries.async_entries(DOMAIN)) == 2 - assert entry.state is ConfigEntryState.LOADED + assert aioclient_mock.call_count == 3 + assert len(hass.config_entries.async_entries(DOMAIN)) == 2 + assert entry.state is ConfigEntryState.LOADED - update_interval = set_update_interval(instances, REMAINING_REQUESTS) - future = utcnow() + update_interval - mock_utcnow.return_value = future - async_fire_time_changed(hass, future) - await hass.async_block_till_done() + update_interval = set_update_interval(instances, REMAINING_REQUESTS) + freezer.tick(update_interval) + async_fire_time_changed(hass) + await hass.async_block_till_done() - # call_count should increase by two because we have two instances configured - assert aioclient_mock.call_count == 5 + # call_count should increase by two because we have two instances configured + assert aioclient_mock.call_count == 5 async def test_unload_entry( From 0d3663c52a7e59001bc8f5f59c3f09179cd437c2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 25 Aug 2023 16:05:16 +0200 Subject: [PATCH 105/124] Use freezegun in fronius tests (#99030) --- tests/components/fronius/test_coordinator.py | 38 +++++++++----------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/tests/components/fronius/test_coordinator.py b/tests/components/fronius/test_coordinator.py index a0e420c5b52..d4f42fadb06 100644 --- a/tests/components/fronius/test_coordinator.py +++ b/tests/components/fronius/test_coordinator.py @@ -1,13 +1,13 @@ """Test the Fronius update coordinators.""" from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory from pyfronius import BadStatusError, FroniusError from homeassistant.components.fronius.coordinator import ( FroniusInverterUpdateCoordinator, ) from homeassistant.core import HomeAssistant -from homeassistant.util import dt as dt_util from . import mock_responses, setup_fronius_integration @@ -16,7 +16,9 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_adaptive_update_interval( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, ) -> None: """Test coordinators changing their update interval when inverter not available.""" with patch("pyfronius.Fronius.current_inverter_data") as mock_inverter_data: @@ -25,9 +27,8 @@ async def test_adaptive_update_interval( mock_inverter_data.assert_called_once() mock_inverter_data.reset_mock() - async_fire_time_changed( - hass, dt_util.utcnow() + FroniusInverterUpdateCoordinator.default_interval - ) + freezer.tick(FroniusInverterUpdateCoordinator.default_interval) + async_fire_time_changed(hass, None) await hass.async_block_till_done() mock_inverter_data.assert_called_once() mock_inverter_data.reset_mock() @@ -35,33 +36,28 @@ async def test_adaptive_update_interval( mock_inverter_data.side_effect = FroniusError() # first 3 bad requests at default interval - 4th has different interval for _ in range(3): - async_fire_time_changed( - hass, - dt_util.utcnow() + FroniusInverterUpdateCoordinator.default_interval, - ) + freezer.tick(FroniusInverterUpdateCoordinator.default_interval) + async_fire_time_changed(hass, None) await hass.async_block_till_done() assert mock_inverter_data.call_count == 3 mock_inverter_data.reset_mock() - async_fire_time_changed( - hass, dt_util.utcnow() + FroniusInverterUpdateCoordinator.error_interval - ) + freezer.tick(FroniusInverterUpdateCoordinator.error_interval) + async_fire_time_changed(hass, None) await hass.async_block_till_done() assert mock_inverter_data.call_count == 1 mock_inverter_data.reset_mock() mock_inverter_data.side_effect = None # next successful request resets to default interval - async_fire_time_changed( - hass, dt_util.utcnow() + FroniusInverterUpdateCoordinator.error_interval - ) + freezer.tick(FroniusInverterUpdateCoordinator.error_interval) + async_fire_time_changed(hass, None) await hass.async_block_till_done() mock_inverter_data.assert_called_once() mock_inverter_data.reset_mock() - async_fire_time_changed( - hass, dt_util.utcnow() + FroniusInverterUpdateCoordinator.default_interval - ) + freezer.tick(FroniusInverterUpdateCoordinator.default_interval) + async_fire_time_changed(hass, None) await hass.async_block_till_done() mock_inverter_data.assert_called_once() mock_inverter_data.reset_mock() @@ -70,10 +66,8 @@ async def test_adaptive_update_interval( mock_inverter_data.side_effect = BadStatusError("mock_endpoint", 8) # first 3 requests at default interval - 4th has different interval for _ in range(3): - async_fire_time_changed( - hass, - dt_util.utcnow() + FroniusInverterUpdateCoordinator.default_interval, - ) + freezer.tick(FroniusInverterUpdateCoordinator.default_interval) + async_fire_time_changed(hass, None) await hass.async_block_till_done() # BadStatusError does 3 silent retries for inverter endpoint * 3 request intervals = 9 assert mock_inverter_data.call_count == 9 From c827af5826fae1e1f44dbaa515b3ee23bfdedd84 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 25 Aug 2023 16:05:44 +0200 Subject: [PATCH 106/124] Use freezegun in uptimerobot tests (#99046) --- tests/components/uptimerobot/test_init.py | 41 ++++++++++++++++------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/tests/components/uptimerobot/test_init.py b/tests/components/uptimerobot/test_init.py index bba5af07be3..67fac2437f0 100644 --- a/tests/components/uptimerobot/test_init.py +++ b/tests/components/uptimerobot/test_init.py @@ -1,6 +1,7 @@ """Test the UptimeRobot init.""" from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from pyuptimerobot import UptimeRobotAuthenticationException, UptimeRobotException @@ -12,7 +13,6 @@ from homeassistant.components.uptimerobot.const import ( from homeassistant.const import STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from homeassistant.util import dt as dt_util from .common import ( MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA, @@ -93,7 +93,9 @@ async def test_reauthentication_trigger_key_read_only( async def test_reauthentication_trigger_after_setup( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, ) -> None: """Test reauthentication trigger.""" mock_config_entry = await setup_uptimerobot_integration(hass) @@ -106,7 +108,8 @@ async def test_reauthentication_trigger_after_setup( "pyuptimerobot.UptimeRobot.async_get_monitors", side_effect=UptimeRobotAuthenticationException, ): - async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) + freezer.tick(COORDINATOR_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() flows = hass.config_entries.flow.async_progress() @@ -125,7 +128,10 @@ async def test_reauthentication_trigger_after_setup( assert flow["context"]["entry_id"] == mock_config_entry.entry_id -async def test_integration_reload(hass: HomeAssistant) -> None: +async def test_integration_reload( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: """Test integration reload.""" mock_entry = await setup_uptimerobot_integration(hass) @@ -134,7 +140,8 @@ async def test_integration_reload(hass: HomeAssistant) -> None: return_value=mock_uptimerobot_api_response(), ): assert await hass.config_entries.async_reload(mock_entry.entry_id) - async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) + freezer.tick(COORDINATOR_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() entry = hass.config_entries.async_get_entry(mock_entry.entry_id) @@ -143,7 +150,9 @@ async def test_integration_reload(hass: HomeAssistant) -> None: async def test_update_errors( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, ) -> None: """Test errors during updates.""" await setup_uptimerobot_integration(hass) @@ -152,7 +161,8 @@ async def test_update_errors( "pyuptimerobot.UptimeRobot.async_get_monitors", side_effect=UptimeRobotException, ): - async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) + freezer.tick(COORDINATOR_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert ( hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state @@ -163,7 +173,8 @@ async def test_update_errors( "pyuptimerobot.UptimeRobot.async_get_monitors", return_value=mock_uptimerobot_api_response(), ): - async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) + freezer.tick(COORDINATOR_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state == STATE_ON @@ -171,7 +182,8 @@ async def test_update_errors( "pyuptimerobot.UptimeRobot.async_get_monitors", return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ERROR), ): - async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) + freezer.tick(COORDINATOR_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert ( hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state @@ -181,7 +193,10 @@ async def test_update_errors( assert "Error fetching uptimerobot data: test error from API" in caplog.text -async def test_device_management(hass: HomeAssistant) -> None: +async def test_device_management( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: """Test that we are adding and removing devices for monitors returned from the API.""" mock_entry = await setup_uptimerobot_integration(hass) dev_reg = dr.async_get(hass) @@ -201,7 +216,8 @@ async def test_device_management(hass: HomeAssistant) -> None: data=[MOCK_UPTIMEROBOT_MONITOR, {**MOCK_UPTIMEROBOT_MONITOR, "id": 12345}] ), ): - async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) + freezer.tick(COORDINATOR_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() devices = dr.async_entries_for_config_entry(dev_reg, mock_entry.entry_id) @@ -218,7 +234,8 @@ async def test_device_management(hass: HomeAssistant) -> None: "pyuptimerobot.UptimeRobot.async_get_monitors", return_value=mock_uptimerobot_api_response(), ): - async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) + freezer.tick(COORDINATOR_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() await hass.async_block_till_done() From 452caee41a8f480a952e2a03200757c3e0e10bd0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 25 Aug 2023 16:06:14 +0200 Subject: [PATCH 107/124] Use freezegun in pvpc_hourly_pricing tests (#99040) --- .../pvpc_hourly_pricing/test_config_flow.py | 145 +++++++++--------- 1 file changed, 73 insertions(+), 72 deletions(-) diff --git a/tests/components/pvpc_hourly_pricing/test_config_flow.py b/tests/components/pvpc_hourly_pricing/test_config_flow.py index 8623830f0dd..e22ab03eb60 100644 --- a/tests/components/pvpc_hourly_pricing/test_config_flow.py +++ b/tests/components/pvpc_hourly_pricing/test_config_flow.py @@ -1,7 +1,7 @@ """Tests for the pvpc_hourly_pricing config_flow.""" from datetime import datetime, timedelta -from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory from homeassistant import config_entries, data_entry_flow from homeassistant.components.pvpc_hourly_pricing import ( @@ -25,7 +25,9 @@ _MOCK_TIME_VALID_RESPONSES = datetime(2023, 1, 6, 12, 0, tzinfo=dt_util.UTC) async def test_config_flow( - hass: HomeAssistant, pvpc_aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + pvpc_aioclient_mock: AiohttpClientMocker, ) -> None: """Test config flow for pvpc_hourly_pricing. @@ -35,6 +37,7 @@ async def test_config_flow( - Check removal and add again to check state restoration - Configure options to change power and tariff to "2.0TD" """ + freezer.move_to(_MOCK_TIME_VALID_RESPONSES) hass.config.set_time_zone("Europe/Madrid") tst_config = { CONF_NAME: "test", @@ -43,84 +46,82 @@ async def test_config_flow( ATTR_POWER_P3: 5.75, } - with freeze_time(_MOCK_TIME_VALID_RESPONSES) as mock_time: - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == data_entry_flow.FlowResultType.FORM + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM - result = await hass.config_entries.flow.async_configure( - result["flow_id"], tst_config - ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + result = await hass.config_entries.flow.async_configure( + result["flow_id"], tst_config + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - await hass.async_block_till_done() - state = hass.states.get("sensor.test") - check_valid_state(state, tariff=TARIFFS[1]) - assert pvpc_aioclient_mock.call_count == 1 + await hass.async_block_till_done() + state = hass.states.get("sensor.test") + check_valid_state(state, tariff=TARIFFS[1]) + assert pvpc_aioclient_mock.call_count == 1 - # Check abort when configuring another with same tariff - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - result = await hass.config_entries.flow.async_configure( - result["flow_id"], tst_config - ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert pvpc_aioclient_mock.call_count == 1 + # Check abort when configuring another with same tariff + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], tst_config + ) + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert pvpc_aioclient_mock.call_count == 1 - # Check removal - registry = er.async_get(hass) - registry_entity = registry.async_get("sensor.test") - assert await hass.config_entries.async_remove(registry_entity.config_entry_id) + # Check removal + registry = er.async_get(hass) + registry_entity = registry.async_get("sensor.test") + assert await hass.config_entries.async_remove(registry_entity.config_entry_id) - # and add it again with UI - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == data_entry_flow.FlowResultType.FORM + # and add it again with UI + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM - result = await hass.config_entries.flow.async_configure( - result["flow_id"], tst_config - ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + result = await hass.config_entries.flow.async_configure( + result["flow_id"], tst_config + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - await hass.async_block_till_done() - state = hass.states.get("sensor.test") - check_valid_state(state, tariff=TARIFFS[1]) - assert pvpc_aioclient_mock.call_count == 2 - assert state.attributes["period"] == "P3" - assert state.attributes["next_period"] == "P2" - assert state.attributes["available_power"] == 5750 + await hass.async_block_till_done() + state = hass.states.get("sensor.test") + check_valid_state(state, tariff=TARIFFS[1]) + assert pvpc_aioclient_mock.call_count == 2 + assert state.attributes["period"] == "P3" + assert state.attributes["next_period"] == "P2" + assert state.attributes["available_power"] == 5750 - # check options flow - current_entries = hass.config_entries.async_entries(DOMAIN) - assert len(current_entries) == 1 - config_entry = current_entries[0] + # check options flow + current_entries = hass.config_entries.async_entries(DOMAIN) + assert len(current_entries) == 1 + config_entry = current_entries[0] - result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "init" + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ATTR_POWER: 3.0, ATTR_POWER_P3: 4.6}, - ) - await hass.async_block_till_done() - state = hass.states.get("sensor.test") - check_valid_state(state, tariff=TARIFFS[1]) - assert pvpc_aioclient_mock.call_count == 3 - assert state.attributes["period"] == "P3" - assert state.attributes["next_period"] == "P2" - assert state.attributes["available_power"] == 4600 + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ATTR_POWER: 3.0, ATTR_POWER_P3: 4.6}, + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.test") + check_valid_state(state, tariff=TARIFFS[1]) + assert pvpc_aioclient_mock.call_count == 3 + assert state.attributes["period"] == "P3" + assert state.attributes["next_period"] == "P2" + assert state.attributes["available_power"] == 4600 - # check update failed - ts_future = _MOCK_TIME_VALID_RESPONSES + timedelta(days=1) - mock_time.move_to(ts_future) - async_fire_time_changed(hass, ts_future) - await hass.async_block_till_done() - state = hass.states.get("sensor.test") - check_valid_state(state, tariff=TARIFFS[0], value="unavailable") - assert "period" not in state.attributes - assert pvpc_aioclient_mock.call_count == 4 + # check update failed + freezer.tick(timedelta(days=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get("sensor.test") + check_valid_state(state, tariff=TARIFFS[0], value="unavailable") + assert "period" not in state.attributes + assert pvpc_aioclient_mock.call_count == 4 From b0952bc54a3feba67b7a94e0fa3b06b93d077c67 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 25 Aug 2023 16:06:43 +0200 Subject: [PATCH 108/124] Use freezegun in shelly tests (#99042) --- tests/components/shelly/__init__.py | 18 +-- tests/components/shelly/test_binary_sensor.py | 12 +- tests/components/shelly/test_coordinator.py | 106 ++++++++---------- tests/components/shelly/test_sensor.py | 10 +- tests/components/shelly/test_update.py | 17 +-- 5 files changed, 80 insertions(+), 83 deletions(-) diff --git a/tests/components/shelly/__init__.py b/tests/components/shelly/__init__.py index 67f47b0e7e3..464118ac99b 100644 --- a/tests/components/shelly/__init__.py +++ b/tests/components/shelly/__init__.py @@ -7,6 +7,7 @@ from datetime import timedelta from typing import Any from unittest.mock import Mock +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.shelly.const import ( @@ -20,7 +21,6 @@ from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac from homeassistant.helpers.entity_registry import async_get -from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -78,17 +78,21 @@ def inject_rpc_device_event( mock_rpc_device.mock_event() -async def mock_rest_update(hass: HomeAssistant, seconds=REST_SENSORS_UPDATE_INTERVAL): +async def mock_rest_update( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + seconds=REST_SENSORS_UPDATE_INTERVAL, +): """Move time to create REST sensors update event.""" - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=seconds)) + freezer.tick(timedelta(seconds=seconds)) + async_fire_time_changed(hass) await hass.async_block_till_done() -async def mock_polling_rpc_update(hass: HomeAssistant): +async def mock_polling_rpc_update(hass: HomeAssistant, freezer: FrozenDateTimeFactory): """Move time to create polling RPC sensors update event.""" - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=RPC_SENSORS_POLLING_INTERVAL) - ) + freezer.tick(timedelta(seconds=RPC_SENSORS_POLLING_INTERVAL)) + async_fire_time_changed(hass) await hass.async_block_till_done() diff --git a/tests/components/shelly/test_binary_sensor.py b/tests/components/shelly/test_binary_sensor.py index ebc5089f884..a54b5398b11 100644 --- a/tests/components/shelly/test_binary_sensor.py +++ b/tests/components/shelly/test_binary_sensor.py @@ -1,4 +1,6 @@ """Tests for Shelly binary sensor platform.""" +from freezegun.api import FrozenDateTimeFactory + from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.shelly.const import SLEEP_PERIOD_MULTIPLIER from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN @@ -54,7 +56,7 @@ async def test_block_binary_sensor_extra_state_attr( async def test_block_rest_binary_sensor( - hass: HomeAssistant, mock_block_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_block_device, monkeypatch ) -> None: """Test block REST binary sensor.""" entity_id = register_entity(hass, BINARY_SENSOR_DOMAIN, "test_name_cloud", "cloud") @@ -64,13 +66,13 @@ async def test_block_rest_binary_sensor( assert hass.states.get(entity_id).state == STATE_OFF monkeypatch.setitem(mock_block_device.status["cloud"], "connected", True) - await mock_rest_update(hass) + await mock_rest_update(hass, freezer) assert hass.states.get(entity_id).state == STATE_ON async def test_block_rest_binary_sensor_connected_battery_devices( - hass: HomeAssistant, mock_block_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_block_device, monkeypatch ) -> None: """Test block REST binary sensor for connected battery devices.""" entity_id = register_entity(hass, BINARY_SENSOR_DOMAIN, "test_name_cloud", "cloud") @@ -84,11 +86,11 @@ async def test_block_rest_binary_sensor_connected_battery_devices( monkeypatch.setitem(mock_block_device.status["cloud"], "connected", True) # Verify no update on fast intervals - await mock_rest_update(hass) + await mock_rest_update(hass, freezer) assert hass.states.get(entity_id).state == STATE_OFF # Verify update on slow intervals - await mock_rest_update(hass, seconds=SLEEP_PERIOD_MULTIPLIER * 3600) + await mock_rest_update(hass, freezer, seconds=SLEEP_PERIOD_MULTIPLIER * 3600) assert hass.states.get(entity_id).state == STATE_ON diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index 5a8bb234f30..a7fa64962e9 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -3,6 +3,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError +from freezegun.api import FrozenDateTimeFactory from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN @@ -26,7 +27,6 @@ from homeassistant.helpers.device_registry import ( async_get as async_get_dev_reg, ) import homeassistant.helpers.issue_registry as ir -from homeassistant.util import dt as dt_util from . import ( MOCK_MAC, @@ -46,7 +46,7 @@ DEVICE_BLOCK_ID = 4 async def test_block_reload_on_cfg_change( - hass: HomeAssistant, mock_block_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_block_device, monkeypatch ) -> None: """Test block reload on config change.""" await init_integration(hass, 1) @@ -66,16 +66,15 @@ async def test_block_reload_on_cfg_change( assert hass.states.get("switch.test_name_channel_1") is not None # Wait for debouncer - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=ENTRY_RELOAD_COOLDOWN) - ) + freezer.tick(timedelta(seconds=ENTRY_RELOAD_COOLDOWN)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get("switch.test_name_channel_1") is None async def test_block_no_reload_on_bulb_changes( - hass: HomeAssistant, mock_block_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_block_device, monkeypatch ) -> None: """Test block no reload on bulb mode/effect change.""" await init_integration(hass, 1, model="SHBLB-1") @@ -96,9 +95,8 @@ async def test_block_no_reload_on_bulb_changes( assert hass.states.get("switch.test_name_channel_1") is not None # Wait for debouncer - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=ENTRY_RELOAD_COOLDOWN) - ) + freezer.tick(timedelta(seconds=ENTRY_RELOAD_COOLDOWN)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get("switch.test_name_channel_1") is not None @@ -112,16 +110,15 @@ async def test_block_no_reload_on_bulb_changes( assert hass.states.get("switch.test_name_channel_1") is not None # Wait for debouncer - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=ENTRY_RELOAD_COOLDOWN) - ) + freezer.tick(timedelta(seconds=ENTRY_RELOAD_COOLDOWN)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get("switch.test_name_channel_1") is not None async def test_block_polling_auth_error( - hass: HomeAssistant, mock_block_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_block_device, monkeypatch ) -> None: """Test block device polling authentication error.""" monkeypatch.setattr( @@ -134,9 +131,8 @@ async def test_block_polling_auth_error( assert entry.state == ConfigEntryState.LOADED # Move time to generate polling - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=UPDATE_PERIOD_MULTIPLIER * 15) - ) + freezer.tick(timedelta(seconds=UPDATE_PERIOD_MULTIPLIER * 15)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert entry.state == ConfigEntryState.LOADED @@ -154,7 +150,7 @@ async def test_block_polling_auth_error( async def test_block_rest_update_auth_error( - hass: HomeAssistant, mock_block_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_block_device, monkeypatch ) -> None: """Test block REST update authentication error.""" register_entity(hass, BINARY_SENSOR_DOMAIN, "test_name_cloud", "cloud") @@ -170,7 +166,7 @@ async def test_block_rest_update_auth_error( assert entry.state == ConfigEntryState.LOADED - await mock_rest_update(hass) + await mock_rest_update(hass, freezer) assert entry.state == ConfigEntryState.LOADED @@ -187,7 +183,7 @@ async def test_block_rest_update_auth_error( async def test_block_polling_connection_error( - hass: HomeAssistant, mock_block_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_block_device, monkeypatch ) -> None: """Test block device polling connection error.""" monkeypatch.setattr( @@ -200,16 +196,15 @@ async def test_block_polling_connection_error( assert hass.states.get("switch.test_name_channel_1").state == STATE_ON # Move time to generate polling - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=UPDATE_PERIOD_MULTIPLIER * 15) - ) + freezer.tick(timedelta(seconds=UPDATE_PERIOD_MULTIPLIER * 15)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get("switch.test_name_channel_1").state == STATE_UNAVAILABLE async def test_block_rest_update_connection_error( - hass: HomeAssistant, mock_block_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_block_device, monkeypatch ) -> None: """Test block REST update connection error.""" entity_id = register_entity(hass, BINARY_SENSOR_DOMAIN, "test_name_cloud", "cloud") @@ -217,7 +212,7 @@ async def test_block_rest_update_connection_error( monkeypatch.setitem(mock_block_device.status, "uptime", 1) await init_integration(hass, 1) - await mock_rest_update(hass) + await mock_rest_update(hass, freezer) assert hass.states.get(entity_id).state == STATE_ON monkeypatch.setattr( @@ -225,13 +220,13 @@ async def test_block_rest_update_connection_error( "update_shelly", AsyncMock(side_effect=DeviceConnectionError), ) - await mock_rest_update(hass) + await mock_rest_update(hass, freezer) assert hass.states.get(entity_id).state == STATE_UNAVAILABLE async def test_block_sleeping_device_no_periodic_updates( - hass: HomeAssistant, mock_block_device + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_block_device ) -> None: """Test block sleeping device no periodic updates.""" entity_id = f"{SENSOR_DOMAIN}.test_name_temperature" @@ -244,9 +239,8 @@ async def test_block_sleeping_device_no_periodic_updates( assert hass.states.get(entity_id).state == "22.1" # Move time to generate polling - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=SLEEP_PERIOD_MULTIPLIER * 1000) - ) + freezer.tick(timedelta(seconds=UPDATE_PERIOD_MULTIPLIER * 1000)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_UNAVAILABLE @@ -322,7 +316,7 @@ async def test_block_button_click_event( async def test_rpc_reload_on_cfg_change( - hass: HomeAssistant, mock_rpc_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_rpc_device, monkeypatch ) -> None: """Test RPC reload on config change.""" await init_integration(hass, 2) @@ -356,16 +350,15 @@ async def test_rpc_reload_on_cfg_change( assert hass.states.get("switch.test_name_test_switch_0") is not None # Wait for debouncer - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=ENTRY_RELOAD_COOLDOWN) - ) + freezer.tick(timedelta(seconds=ENTRY_RELOAD_COOLDOWN)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get("switch.test_name_test_switch_0") is None async def test_rpc_reload_with_invalid_auth( - hass: HomeAssistant, mock_rpc_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_rpc_device, monkeypatch ) -> None: """Test RPC when InvalidAuthError is raising during config entry reload.""" with patch( @@ -398,9 +391,8 @@ async def test_rpc_reload_with_invalid_auth( await hass.async_block_till_done() # Move time to generate reconnect - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=RPC_RECONNECT_INTERVAL) - ) + freezer.tick(timedelta(seconds=RPC_RECONNECT_INTERVAL)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert entry.state == ConfigEntryState.LOADED @@ -455,7 +447,7 @@ async def test_rpc_click_event( async def test_rpc_update_entry_sleep_period( - hass: HomeAssistant, mock_rpc_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_rpc_device, monkeypatch ) -> None: """Test RPC update entry sleep period.""" entry = await init_integration(hass, 2, sleep_period=600) @@ -475,16 +467,15 @@ async def test_rpc_update_entry_sleep_period( # Move time to generate sleep period update monkeypatch.setitem(mock_rpc_device.status["sys"], "wakeup_period", 3600) - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=600 * SLEEP_PERIOD_MULTIPLIER) - ) + freezer.tick(timedelta(seconds=600 * SLEEP_PERIOD_MULTIPLIER)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert entry.data["sleep_period"] == 3600 async def test_rpc_sleeping_device_no_periodic_updates( - hass: HomeAssistant, mock_rpc_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_rpc_device, monkeypatch ) -> None: """Test RPC sleeping device no periodic updates.""" entity_id = f"{SENSOR_DOMAIN}.test_name_temperature" @@ -504,16 +495,15 @@ async def test_rpc_sleeping_device_no_periodic_updates( assert hass.states.get(entity_id).state == "22.9" # Move time to generate polling - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=SLEEP_PERIOD_MULTIPLIER * 1000) - ) + freezer.tick(timedelta(seconds=SLEEP_PERIOD_MULTIPLIER * 1000)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_UNAVAILABLE async def test_rpc_reconnect_auth_error( - hass: HomeAssistant, mock_rpc_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_rpc_device, monkeypatch ) -> None: """Test RPC reconnect authentication error.""" entry = await init_integration(hass, 2) @@ -530,9 +520,8 @@ async def test_rpc_reconnect_auth_error( assert entry.state == ConfigEntryState.LOADED # Move time to generate reconnect - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=RPC_RECONNECT_INTERVAL) - ) + freezer.tick(timedelta(seconds=RPC_RECONNECT_INTERVAL)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert entry.state == ConfigEntryState.LOADED @@ -550,7 +539,7 @@ async def test_rpc_reconnect_auth_error( async def test_rpc_polling_auth_error( - hass: HomeAssistant, mock_rpc_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_rpc_device, monkeypatch ) -> None: """Test RPC polling authentication error.""" register_entity(hass, SENSOR_DOMAIN, "test_name_rssi", "wifi-rssi") @@ -566,7 +555,7 @@ async def test_rpc_polling_auth_error( assert entry.state == ConfigEntryState.LOADED - await mock_polling_rpc_update(hass) + await mock_polling_rpc_update(hass, freezer) assert entry.state == ConfigEntryState.LOADED @@ -583,7 +572,7 @@ async def test_rpc_polling_auth_error( async def test_rpc_reconnect_error( - hass: HomeAssistant, mock_rpc_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_rpc_device, monkeypatch ) -> None: """Test RPC reconnect error.""" await init_integration(hass, 2) @@ -600,16 +589,15 @@ async def test_rpc_reconnect_error( ) # Move time to generate reconnect - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=RPC_RECONNECT_INTERVAL) - ) + freezer.tick(timedelta(seconds=RPC_RECONNECT_INTERVAL)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get("switch.test_name_test_switch_0").state == STATE_UNAVAILABLE async def test_rpc_polling_connection_error( - hass: HomeAssistant, mock_rpc_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_rpc_device, monkeypatch ) -> None: """Test RPC polling connection error.""" entity_id = register_entity(hass, SENSOR_DOMAIN, "test_name_rssi", "wifi-rssi") @@ -625,13 +613,13 @@ async def test_rpc_polling_connection_error( assert hass.states.get(entity_id).state == "-63" - await mock_polling_rpc_update(hass) + await mock_polling_rpc_update(hass, freezer) assert hass.states.get(entity_id).state == STATE_UNAVAILABLE async def test_rpc_polling_disconnected( - hass: HomeAssistant, mock_rpc_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_rpc_device, monkeypatch ) -> None: """Test RPC polling device disconnected.""" entity_id = register_entity(hass, SENSOR_DOMAIN, "test_name_rssi", "wifi-rssi") @@ -641,6 +629,6 @@ async def test_rpc_polling_disconnected( assert hass.states.get(entity_id).state == "-63" - await mock_polling_rpc_update(hass) + await mock_polling_rpc_update(hass, freezer) assert hass.states.get(entity_id).state == STATE_UNAVAILABLE diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index fe79b1d010a..630ee551e89 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -1,4 +1,6 @@ """Tests for Shelly sensor platform.""" +from freezegun.api import FrozenDateTimeFactory + from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.shelly.const import DOMAIN from homeassistant.const import ( @@ -89,7 +91,7 @@ async def test_power_factory_without_unit_migration( async def test_block_rest_sensor( - hass: HomeAssistant, mock_block_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_block_device, monkeypatch ) -> None: """Test block REST sensor.""" entity_id = register_entity(hass, SENSOR_DOMAIN, "test_name_rssi", "rssi") @@ -98,7 +100,7 @@ async def test_block_rest_sensor( assert hass.states.get(entity_id).state == "-64" monkeypatch.setitem(mock_block_device.status["wifi_sta"], "rssi", -71) - await mock_rest_update(hass) + await mock_rest_update(hass, freezer) assert hass.states.get(entity_id).state == "-71" @@ -304,7 +306,7 @@ async def test_rpc_sensor_error( async def test_rpc_polling_sensor( - hass: HomeAssistant, mock_rpc_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_rpc_device, monkeypatch ) -> None: """Test RPC polling sensor.""" entity_id = register_entity(hass, SENSOR_DOMAIN, "test_name_rssi", "wifi-rssi") @@ -313,7 +315,7 @@ async def test_rpc_polling_sensor( assert hass.states.get(entity_id).state == "-63" mutate_rpc_device_status(monkeypatch, mock_rpc_device, "wifi", "rssi", "-70") - await mock_polling_rpc_update(hass) + await mock_polling_rpc_update(hass, freezer) assert hass.states.get(entity_id).state == "-70" diff --git a/tests/components/shelly/test_update.py b/tests/components/shelly/test_update.py index ed5dd81339e..1ff2ac99814 100644 --- a/tests/components/shelly/test_update.py +++ b/tests/components/shelly/test_update.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.shelly.const import DOMAIN @@ -37,7 +38,7 @@ from tests.common import mock_restore_cache async def test_block_update( - hass: HomeAssistant, mock_block_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_block_device, monkeypatch ) -> None: """Test block device update entity.""" entity_registry = async_get(hass) @@ -75,7 +76,7 @@ async def test_block_update( assert state.attributes[ATTR_IN_PROGRESS] is True monkeypatch.setitem(mock_block_device.status["update"], "old_version", "2") - await mock_rest_update(hass) + await mock_rest_update(hass, freezer) state = hass.states.get("update.test_name_firmware_update") assert state.state == STATE_OFF @@ -85,7 +86,7 @@ async def test_block_update( async def test_block_beta_update( - hass: HomeAssistant, mock_block_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_block_device, monkeypatch ) -> None: """Test block device beta update entity.""" entity_registry = async_get(hass) @@ -108,7 +109,7 @@ async def test_block_beta_update( assert state.attributes[ATTR_IN_PROGRESS] is False monkeypatch.setitem(mock_block_device.status["update"], "beta_version", "2b") - await mock_rest_update(hass) + await mock_rest_update(hass, freezer) state = hass.states.get("update.test_name_beta_firmware_update") assert state.state == STATE_ON @@ -131,7 +132,7 @@ async def test_block_beta_update( assert state.attributes[ATTR_IN_PROGRESS] is True monkeypatch.setitem(mock_block_device.status["update"], "old_version", "2b") - await mock_rest_update(hass) + await mock_rest_update(hass, freezer) state = hass.states.get("update.test_name_beta_firmware_update") assert state.state == STATE_OFF @@ -389,7 +390,7 @@ async def test_rpc_restored_sleeping_update_no_last_state( async def test_rpc_beta_update( - hass: HomeAssistant, mock_rpc_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_rpc_device, monkeypatch ) -> None: """Test RPC device beta update entity.""" entity_registry = async_get(hass) @@ -425,7 +426,7 @@ async def test_rpc_beta_update( "beta": {"version": "2b"}, }, ) - await mock_rest_update(hass) + await mock_rest_update(hass, freezer) state = hass.states.get("update.test_name_beta_firmware_update") assert state.state == STATE_ON @@ -448,7 +449,7 @@ async def test_rpc_beta_update( assert state.attributes[ATTR_IN_PROGRESS] is True monkeypatch.setitem(mock_rpc_device.shelly, "ver", "2b") - await mock_rest_update(hass) + await mock_rest_update(hass, freezer) state = hass.states.get("update.test_name_beta_firmware_update") assert state.state == STATE_OFF From f96c1516f83194ce94d1ffc743386452481fa584 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 25 Aug 2023 16:46:10 +0200 Subject: [PATCH 109/124] Use snapshot assertion for gios diagnostics test (#98984) --- tests/components/gios/__init__.py | 1 + .../gios/fixtures/diagnostics_data.json | 50 ------------- .../gios/snapshots/test_diagnostics.ambr | 72 +++++++++++++++++++ tests/components/gios/test_diagnostics.py | 30 ++------ 4 files changed, 79 insertions(+), 74 deletions(-) delete mode 100644 tests/components/gios/fixtures/diagnostics_data.json create mode 100644 tests/components/gios/snapshots/test_diagnostics.ambr diff --git a/tests/components/gios/__init__.py b/tests/components/gios/__init__.py index 6c39ee35303..946cceac786 100644 --- a/tests/components/gios/__init__.py +++ b/tests/components/gios/__init__.py @@ -21,6 +21,7 @@ async def init_integration( title="Home", unique_id="123", data={"station_id": 123, "name": "Home"}, + entry_id="86129426118ae32020417a53712d6eef", ) indexes = json.loads(load_fixture("gios/indexes.json")) diff --git a/tests/components/gios/fixtures/diagnostics_data.json b/tests/components/gios/fixtures/diagnostics_data.json deleted file mode 100644 index feee534ec31..00000000000 --- a/tests/components/gios/fixtures/diagnostics_data.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "aqi": { - "name": "AQI", - "id": null, - "index": null, - "value": "good" - }, - "c6h6": { - "name": "benzene", - "id": 658, - "index": "very_good", - "value": 0.23789 - }, - "co": { - "name": "carbon monoxide", - "id": 660, - "index": "good", - "value": 251.874 - }, - "no2": { - "name": "nitrogen dioxide", - "id": 665, - "index": "good", - "value": 7.13411 - }, - "o3": { - "name": "ozone", - "id": 667, - "index": "good", - "value": 95.7768 - }, - "pm10": { - "name": "particulate matter 10", - "id": 14395, - "index": "good", - "value": 16.8344 - }, - "pm25": { - "name": "particulate matter 2.5", - "id": 670, - "index": "good", - "value": 4 - }, - "so2": { - "name": "sulfur dioxide", - "id": 672, - "index": "very_good", - "value": 4.35478 - } -} diff --git a/tests/components/gios/snapshots/test_diagnostics.ambr b/tests/components/gios/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..67691602fcf --- /dev/null +++ b/tests/components/gios/snapshots/test_diagnostics.ambr @@ -0,0 +1,72 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'config_entry': dict({ + 'data': dict({ + 'name': 'Home', + 'station_id': 123, + }), + 'disabled_by': None, + 'domain': 'gios', + 'entry_id': '86129426118ae32020417a53712d6eef', + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Home', + 'unique_id': '123', + 'version': 1, + }), + 'coordinator_data': dict({ + 'aqi': dict({ + 'id': None, + 'index': None, + 'name': 'AQI', + 'value': 'good', + }), + 'c6h6': dict({ + 'id': 658, + 'index': 'very_good', + 'name': 'benzene', + 'value': 0.23789, + }), + 'co': dict({ + 'id': 660, + 'index': 'good', + 'name': 'carbon monoxide', + 'value': 251.874, + }), + 'no2': dict({ + 'id': 665, + 'index': 'good', + 'name': 'nitrogen dioxide', + 'value': 7.13411, + }), + 'o3': dict({ + 'id': 667, + 'index': 'good', + 'name': 'ozone', + 'value': 95.7768, + }), + 'pm10': dict({ + 'id': 14395, + 'index': 'good', + 'name': 'particulate matter 10', + 'value': 16.8344, + }), + 'pm25': dict({ + 'id': 670, + 'index': 'good', + 'name': 'particulate matter 2.5', + 'value': 4, + }), + 'so2': dict({ + 'id': 672, + 'index': 'very_good', + 'name': 'sulfur dioxide', + 'value': 4.35478, + }), + }), + }) +# --- diff --git a/tests/components/gios/test_diagnostics.py b/tests/components/gios/test_diagnostics.py index 0b9560a96e1..903de4872a2 100644 --- a/tests/components/gios/test_diagnostics.py +++ b/tests/components/gios/test_diagnostics.py @@ -1,39 +1,21 @@ """Test GIOS diagnostics.""" -import json + +from syrupy import SnapshotAssertion from homeassistant.core import HomeAssistant from . import init_integration -from tests.common import load_fixture from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator async def test_entry_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" entry = await init_integration(hass) - coordinator_data = json.loads(load_fixture("diagnostics_data.json", "gios")) - - result = await get_diagnostics_for_config_entry(hass, hass_client, entry) - - assert result["config_entry"] == { - "entry_id": entry.entry_id, - "version": 1, - "domain": "gios", - "title": "Home", - "data": { - "station_id": 123, - "name": "Home", - }, - "options": {}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "unique_id": "123", - "disabled_by": None, - } - assert result["coordinator_data"] == coordinator_data + assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot From 27f7399071787451bed73b196333d786830843e5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 25 Aug 2023 16:46:23 +0200 Subject: [PATCH 110/124] Modernize accuweather weather (#99001) --- .../components/accuweather/weather.py | 15 +- .../accuweather/snapshots/test_weather.ambr | 225 ++++++++++++++++++ tests/components/accuweather/test_weather.py | 96 +++++++- 3 files changed, 331 insertions(+), 5 deletions(-) create mode 100644 tests/components/accuweather/snapshots/test_weather.ambr diff --git a/homeassistant/components/accuweather/weather.py b/homeassistant/components/accuweather/weather.py index 518714b3874..d446b4b58d9 100644 --- a/homeassistant/components/accuweather/weather.py +++ b/homeassistant/components/accuweather/weather.py @@ -17,7 +17,8 @@ from homeassistant.components.weather import ( ATTR_FORECAST_UV_INDEX, ATTR_FORECAST_WIND_BEARING, Forecast, - WeatherEntity, + SingleCoordinatorWeatherEntity, + WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -27,9 +28,8 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utc_from_timestamp from . import AccuWeatherDataUpdateCoordinator @@ -58,7 +58,7 @@ async def async_setup_entry( class AccuWeatherEntity( - CoordinatorEntity[AccuWeatherDataUpdateCoordinator], WeatherEntity + SingleCoordinatorWeatherEntity[AccuWeatherDataUpdateCoordinator] ): """Define an AccuWeather entity.""" @@ -76,6 +76,8 @@ class AccuWeatherEntity( self._attr_unique_id = coordinator.location_key self._attr_attribution = ATTRIBUTION self._attr_device_info = coordinator.device_info + if self.coordinator.forecast: + self._attr_supported_features = WeatherEntityFeature.FORECAST_DAILY @property def condition(self) -> str | None: @@ -174,3 +176,8 @@ class AccuWeatherEntity( } for item in self.coordinator.data[ATTR_FORECAST] ] + + @callback + def _async_forecast_daily(self) -> list[Forecast] | None: + """Return the daily forecast in native units.""" + return self.forecast diff --git a/tests/components/accuweather/snapshots/test_weather.ambr b/tests/components/accuweather/snapshots/test_weather.ambr new file mode 100644 index 00000000000..521393af71b --- /dev/null +++ b/tests/components/accuweather/snapshots/test_weather.ambr @@ -0,0 +1,225 @@ +# serializer version: 1 +# name: test_forecast_service + dict({ + 'forecast': list([ + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 58, + 'condition': 'lightning-rainy', + 'datetime': '2020-07-26T05:00:00+00:00', + 'precipitation': 2.5, + 'precipitation_probability': 60, + 'temperature': 29.5, + 'templow': 15.4, + 'uv_index': 5, + 'wind_bearing': 166, + 'wind_gust_speed': 29.6, + 'wind_speed': 13.0, + }), + dict({ + 'apparent_temperature': 28.9, + 'cloud_coverage': 52, + 'condition': 'partlycloudy', + 'datetime': '2020-07-27T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'temperature': 26.2, + 'templow': 15.9, + 'uv_index': 7, + 'wind_bearing': 297, + 'wind_gust_speed': 14.8, + 'wind_speed': 9.3, + }), + dict({ + 'apparent_temperature': 31.6, + 'cloud_coverage': 65, + 'condition': 'partlycloudy', + 'datetime': '2020-07-28T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 31.7, + 'templow': 16.8, + 'uv_index': 7, + 'wind_bearing': 198, + 'wind_gust_speed': 24.1, + 'wind_speed': 16.7, + }), + dict({ + 'apparent_temperature': 26.5, + 'cloud_coverage': 45, + 'condition': 'partlycloudy', + 'datetime': '2020-07-29T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 9, + 'temperature': 24.0, + 'templow': 11.7, + 'uv_index': 6, + 'wind_bearing': 293, + 'wind_gust_speed': 24.1, + 'wind_speed': 13.0, + }), + dict({ + 'apparent_temperature': 22.2, + 'cloud_coverage': 50, + 'condition': 'partlycloudy', + 'datetime': '2020-07-30T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'temperature': 21.4, + 'templow': 12.2, + 'uv_index': 7, + 'wind_bearing': 280, + 'wind_gust_speed': 27.8, + 'wind_speed': 18.5, + }), + ]), + }) +# --- +# name: test_forecast_subscription + list([ + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 58, + 'condition': 'lightning-rainy', + 'datetime': '2020-07-26T05:00:00+00:00', + 'precipitation': 2.5, + 'precipitation_probability': 60, + 'temperature': 29.5, + 'templow': 15.4, + 'uv_index': 5, + 'wind_bearing': 166, + 'wind_gust_speed': 29.6, + 'wind_speed': 13.0, + }), + dict({ + 'apparent_temperature': 28.9, + 'cloud_coverage': 52, + 'condition': 'partlycloudy', + 'datetime': '2020-07-27T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'temperature': 26.2, + 'templow': 15.9, + 'uv_index': 7, + 'wind_bearing': 297, + 'wind_gust_speed': 14.8, + 'wind_speed': 9.3, + }), + dict({ + 'apparent_temperature': 31.6, + 'cloud_coverage': 65, + 'condition': 'partlycloudy', + 'datetime': '2020-07-28T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 31.7, + 'templow': 16.8, + 'uv_index': 7, + 'wind_bearing': 198, + 'wind_gust_speed': 24.1, + 'wind_speed': 16.7, + }), + dict({ + 'apparent_temperature': 26.5, + 'cloud_coverage': 45, + 'condition': 'partlycloudy', + 'datetime': '2020-07-29T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 9, + 'temperature': 24.0, + 'templow': 11.7, + 'uv_index': 6, + 'wind_bearing': 293, + 'wind_gust_speed': 24.1, + 'wind_speed': 13.0, + }), + dict({ + 'apparent_temperature': 22.2, + 'cloud_coverage': 50, + 'condition': 'partlycloudy', + 'datetime': '2020-07-30T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'temperature': 21.4, + 'templow': 12.2, + 'uv_index': 7, + 'wind_bearing': 280, + 'wind_gust_speed': 27.8, + 'wind_speed': 18.5, + }), + ]) +# --- +# name: test_forecast_subscription.1 + list([ + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 58, + 'condition': 'lightning-rainy', + 'datetime': '2020-07-26T05:00:00+00:00', + 'precipitation': 2.5, + 'precipitation_probability': 60, + 'temperature': 29.5, + 'templow': 15.4, + 'uv_index': 5, + 'wind_bearing': 166, + 'wind_gust_speed': 29.6, + 'wind_speed': 13.0, + }), + dict({ + 'apparent_temperature': 28.9, + 'cloud_coverage': 52, + 'condition': 'partlycloudy', + 'datetime': '2020-07-27T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'temperature': 26.2, + 'templow': 15.9, + 'uv_index': 7, + 'wind_bearing': 297, + 'wind_gust_speed': 14.8, + 'wind_speed': 9.3, + }), + dict({ + 'apparent_temperature': 31.6, + 'cloud_coverage': 65, + 'condition': 'partlycloudy', + 'datetime': '2020-07-28T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 31.7, + 'templow': 16.8, + 'uv_index': 7, + 'wind_bearing': 198, + 'wind_gust_speed': 24.1, + 'wind_speed': 16.7, + }), + dict({ + 'apparent_temperature': 26.5, + 'cloud_coverage': 45, + 'condition': 'partlycloudy', + 'datetime': '2020-07-29T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 9, + 'temperature': 24.0, + 'templow': 11.7, + 'uv_index': 6, + 'wind_bearing': 293, + 'wind_gust_speed': 24.1, + 'wind_speed': 13.0, + }), + dict({ + 'apparent_temperature': 22.2, + 'cloud_coverage': 50, + 'condition': 'partlycloudy', + 'datetime': '2020-07-30T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'temperature': 21.4, + 'templow': 12.2, + 'uv_index': 7, + 'wind_bearing': 280, + 'wind_gust_speed': 27.8, + 'wind_speed': 18.5, + }), + ]) +# --- diff --git a/tests/components/accuweather/test_weather.py b/tests/components/accuweather/test_weather.py index b9e66d51874..1d970e322e4 100644 --- a/tests/components/accuweather/test_weather.py +++ b/tests/components/accuweather/test_weather.py @@ -2,6 +2,9 @@ from datetime import timedelta from unittest.mock import PropertyMock, patch +from freezegun.api import FrozenDateTimeFactory +from syrupy.assertion import SnapshotAssertion + from homeassistant.components.accuweather.const import ATTRIBUTION from homeassistant.components.weather import ( ATTR_FORECAST, @@ -27,8 +30,16 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_GUST_SPEED, ATTR_WEATHER_WIND_SPEED, + DOMAIN as WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + WeatherEntityFeature, +) +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + STATE_UNAVAILABLE, ) -from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ENTITY_ID, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -41,6 +52,7 @@ from tests.common import ( load_json_array_fixture, load_json_object_fixture, ) +from tests.typing import WebSocketGenerator async def test_weather_without_forecast(hass: HomeAssistant) -> None: @@ -64,6 +76,7 @@ async def test_weather_without_forecast(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_WEATHER_WIND_GUST_SPEED) == 20.3 assert state.attributes.get(ATTR_WEATHER_UV_INDEX) == 6 assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert ATTR_SUPPORTED_FEATURES not in state.attributes entry = registry.async_get("weather.home") assert entry @@ -90,6 +103,9 @@ async def test_weather_with_forecast(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_WEATHER_WIND_GUST_SPEED) == 20.3 assert state.attributes.get(ATTR_WEATHER_UV_INDEX) == 6 assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert ( + state.attributes[ATTR_SUPPORTED_FEATURES] == WeatherEntityFeature.FORECAST_DAILY + ) forecast = state.attributes.get(ATTR_FORECAST)[0] assert forecast.get(ATTR_FORECAST_CONDITION) == "lightning-rainy" assert forecast.get(ATTR_FORECAST_PRECIPITATION) == 2.5 @@ -186,3 +202,81 @@ async def test_unsupported_condition_icon_data(hass: HomeAssistant) -> None: state = hass.states.get("weather.home") assert state.attributes.get(ATTR_FORECAST_CONDITION) is None + + +async def test_forecast_service( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Test multiple forecast.""" + await init_integration(hass, forecast=True) + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": "weather.home", + "type": "daily", + }, + blocking=True, + return_response=True, + ) + assert response["forecast"] != [] + assert response == snapshot + + +async def test_forecast_subscription( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, +) -> None: + """Test multiple forecast.""" + client = await hass_ws_client(hass) + + await init_integration(hass, forecast=True) + + await client.send_json_auto_id( + { + "type": "weather/subscribe_forecast", + "forecast_type": "daily", + "entity_id": "weather.home", + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + forecast1 = msg["event"]["forecast"] + + assert forecast1 != [] + assert forecast1 == snapshot + + current = load_json_object_fixture("accuweather/current_conditions_data.json") + forecast = load_json_array_fixture("accuweather/forecast_data.json") + + with patch( + "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", + return_value=current, + ), patch( + "homeassistant.components.accuweather.AccuWeather.async_get_daily_forecast", + return_value=forecast, + ), patch( + "homeassistant.components.accuweather.AccuWeather.requests_remaining", + new_callable=PropertyMock, + return_value=10, + ): + freezer.tick(timedelta(minutes=80) + timedelta(seconds=1)) + await hass.async_block_till_done() + msg = await client.receive_json() + + assert msg["id"] == subscription_id + assert msg["type"] == "event" + forecast2 = msg["event"]["forecast"] + + assert forecast2 != [] + assert forecast2 == snapshot From 49897341ba992770186e886d85ece788c3bc362a Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 25 Aug 2023 17:56:22 +0200 Subject: [PATCH 111/124] Add lawn_mower platform to MQTT (#98831) * Add MQTT lawn_mower platform * Use separate command topics and templates * Remove unrelated change --- .../components/mqtt/abbreviations.py | 8 + .../components/mqtt/config_integration.py | 5 + homeassistant/components/mqtt/const.py | 2 + homeassistant/components/mqtt/discovery.py | 1 + homeassistant/components/mqtt/lawn_mower.py | 254 +++++ tests/components/mqtt/test_lawn_mower.py | 888 ++++++++++++++++++ 6 files changed, 1158 insertions(+) create mode 100644 homeassistant/components/mqtt/lawn_mower.py create mode 100644 tests/components/mqtt/test_lawn_mower.py diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 43f14eba1c5..eb9ab56208e 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -3,6 +3,8 @@ ABBREVIATIONS = { "act_t": "action_topic", "act_tpl": "action_template", + "act_stat_t": "activity_state_topic", + "act_val_tpl": "activity_value_template", "atype": "automation_type", "aux_cmd_t": "aux_command_topic", "aux_stat_tpl": "aux_state_template", @@ -54,6 +56,8 @@ ABBREVIATIONS = { "dir_val_tpl": "direction_value_template", "dock_t": "docked_topic", "dock_tpl": "docked_template", + "dock_cmd_t": "dock_command_topic", + "dock_cmd_tpl": "dock_command_template", "e": "encoding", "en": "enabled_by_default", "ent_cat": "entity_category", @@ -121,6 +125,8 @@ ABBREVIATIONS = { "osc_cmd_tpl": "oscillation_command_template", "osc_stat_t": "oscillation_state_topic", "osc_val_tpl": "oscillation_value_template", + "pause_cmd_t": "pause_command_topic", + "pause_mw_cmd_tpl": "pause_command_template", "pct_cmd_t": "percentage_command_topic", "pct_cmd_tpl": "percentage_command_template", "pct_stat_t": "percentage_state_topic", @@ -215,6 +221,8 @@ ABBREVIATIONS = { "stat_tpl": "state_template", "stat_val_tpl": "state_value_template", "step": "step", + "strt_mw_cmd_t": "start_mowing_command_topic", + "strt_mw_cmd_tpl": "start_mowing_command_template", "stype": "subtype", "sug_dsp_prc": "suggested_display_precision", "sup_dur": "support_duration", diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index cd4470ef22d..79e977a90cd 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -26,6 +26,7 @@ from . import ( fan as fan_platform, humidifier as humidifier_platform, image as image_platform, + lawn_mower as lawn_mower_platform, light as light_platform, lock as lock_platform, number as number_platform, @@ -99,6 +100,10 @@ CONFIG_SCHEMA_BASE = vol.Schema( cv.ensure_list, [image_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] ), + Platform.LAWN_MOWER.value: vol.All( + cv.ensure_list, + [lawn_mower_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] + ), Platform.LOCK.value: vol.All( cv.ensure_list, [lock_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index c0589f60cbe..685e45700b5 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -134,6 +134,7 @@ PLATFORMS = [ Platform.FAN, Platform.HUMIDIFIER, Platform.IMAGE, + Platform.LAWN_MOWER, Platform.LIGHT, Platform.LOCK, Platform.NUMBER, @@ -161,6 +162,7 @@ RELOADABLE_PLATFORMS = [ Platform.HUMIDIFIER, Platform.IMAGE, Platform.LIGHT, + Platform.LAWN_MOWER, Platform.LOCK, Platform.NUMBER, Platform.SCENE, diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index e701937a048..37885b628d2 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -61,6 +61,7 @@ SUPPORTED_COMPONENTS = [ "fan", "humidifier", "image", + "lawn_mower", "light", "lock", "number", diff --git a/homeassistant/components/mqtt/lawn_mower.py b/homeassistant/components/mqtt/lawn_mower.py new file mode 100644 index 00000000000..44db3581f8b --- /dev/null +++ b/homeassistant/components/mqtt/lawn_mower.py @@ -0,0 +1,254 @@ +"""Support for MQTT lawn mowers.""" +from __future__ import annotations + +from collections.abc import Callable +import contextlib +import functools +import logging + +import voluptuous as vol + +from homeassistant.components import lawn_mower +from homeassistant.components.lawn_mower import ( + LawnMowerActivity, + LawnMowerEntity, + LawnMowerEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from . import subscription +from .config import MQTT_BASE_SCHEMA +from .const import ( + CONF_ENCODING, + CONF_QOS, + CONF_RETAIN, + DEFAULT_OPTIMISTIC, + DEFAULT_RETAIN, +) +from .debug_info import log_messages +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .models import ( + MqttCommandTemplate, + MqttValueTemplate, + PublishPayloadType, + ReceiveMessage, + ReceivePayloadType, +) +from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic + +_LOGGER = logging.getLogger(__name__) + +CONF_ACTIVITY_STATE_TOPIC = "activity_state_topic" +CONF_ACTIVITY_VALUE_TEMPLATE = "activity_value_template" +CONF_DOCK_COMMAND_TOPIC = "dock_command_topic" +CONF_DOCK_COMMAND_TEMPLATE = "dock_command_template" +CONF_PAUSE_COMMAND_TOPIC = "pause_command_topic" +CONF_PAUSE_COMMAND_TEMPLATE = "pause_command_template" +CONF_START_MOWING_COMMAND_TOPIC = "start_mowing_command_topic" +CONF_START_MOWING_COMMAND_TEMPLATE = "start_mowing_command_template" + +DEFAULT_NAME = "MQTT Lawn Mower" +ENTITY_ID_FORMAT = lawn_mower.DOMAIN + ".{}" + +MQTT_LAWN_MOWER_ATTRIBUTES_BLOCKED: frozenset[str] = frozenset() + +FEATURE_DOCK = "dock" +FEATURE_PAUSE = "pause" +FEATURE_START_MOWING = "start_mowing" + +PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend( + { + vol.Optional(CONF_ACTIVITY_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_ACTIVITY_STATE_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_DOCK_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_DOCK_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_NAME): vol.Any(cv.string, None), + vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, + vol.Optional(CONF_PAUSE_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_PAUSE_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, + vol.Optional(CONF_START_MOWING_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_START_MOWING_COMMAND_TOPIC): valid_publish_topic, + }, +).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) + +DISCOVERY_SCHEMA = vol.All(PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA)) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up MQTT lawn mower through YAML and through MQTT discovery.""" + setup = functools.partial( + _async_setup_entity, hass, async_add_entities, config_entry=config_entry + ) + await async_setup_entry_helper(hass, lawn_mower.DOMAIN, setup, DISCOVERY_SCHEMA) + + +async def _async_setup_entity( + hass: HomeAssistant, + async_add_entities: AddEntitiesCallback, + config: ConfigType, + config_entry: ConfigEntry, + discovery_data: DiscoveryInfoType | None = None, +) -> None: + """Set up the MQTT lawn mower.""" + async_add_entities([MqttLawnMower(hass, config, config_entry, discovery_data)]) + + +class MqttLawnMower(MqttEntity, LawnMowerEntity, RestoreEntity): + """Representation of an MQTT lawn mower.""" + + _default_name = DEFAULT_NAME + _entity_id_format = ENTITY_ID_FORMAT + _attributes_extra_blocked = MQTT_LAWN_MOWER_ATTRIBUTES_BLOCKED + _command_templates: dict[str, Callable[[PublishPayloadType], PublishPayloadType]] + _command_topics: dict[str, str] + _value_template: Callable[[ReceivePayloadType], ReceivePayloadType] + _optimistic: bool = False + + def __init__( + self, + hass: HomeAssistant, + config: ConfigType, + config_entry: ConfigEntry, + discovery_data: DiscoveryInfoType | None, + ) -> None: + """Initialize the MQTT lawn mower.""" + self._attr_current_option = None + LawnMowerEntity.__init__(self) + MqttEntity.__init__(self, hass, config, config_entry, discovery_data) + + @staticmethod + def config_schema() -> vol.Schema: + """Return the config schema.""" + return DISCOVERY_SCHEMA + + def _setup_from_config(self, config: ConfigType) -> None: + """(Re)Setup the entity.""" + self._optimistic = config[CONF_OPTIMISTIC] + + self._value_template = MqttValueTemplate( + config.get(CONF_ACTIVITY_VALUE_TEMPLATE), entity=self + ).async_render_with_possible_json_value + supported_features = LawnMowerEntityFeature(0) + self._command_topics = {} + if CONF_DOCK_COMMAND_TOPIC in config: + self._command_topics[FEATURE_DOCK] = config[CONF_DOCK_COMMAND_TOPIC] + supported_features |= LawnMowerEntityFeature.DOCK + if CONF_PAUSE_COMMAND_TOPIC in config: + self._command_topics[FEATURE_PAUSE] = config[CONF_PAUSE_COMMAND_TOPIC] + supported_features |= LawnMowerEntityFeature.PAUSE + if CONF_START_MOWING_COMMAND_TOPIC in config: + self._command_topics[FEATURE_START_MOWING] = config[ + CONF_START_MOWING_COMMAND_TOPIC + ] + supported_features |= LawnMowerEntityFeature.START_MOWING + self._attr_supported_features = supported_features + self._command_templates = {} + self._command_templates[FEATURE_DOCK] = MqttCommandTemplate( + config.get(CONF_DOCK_COMMAND_TEMPLATE), entity=self + ).async_render + self._command_templates[FEATURE_PAUSE] = MqttCommandTemplate( + config.get(CONF_PAUSE_COMMAND_TEMPLATE), entity=self + ).async_render + self._command_templates[FEATURE_START_MOWING] = MqttCommandTemplate( + config.get(CONF_START_MOWING_COMMAND_TEMPLATE), entity=self + ).async_render + + def _prepare_subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + + @callback + @log_messages(self.hass, self.entity_id) + def message_received(msg: ReceiveMessage) -> None: + """Handle new MQTT messages.""" + payload = str(self._value_template(msg.payload)) + if not payload: + _LOGGER.debug( + "Invalid empty activity payload from topic %s, for entity %s", + msg.topic, + self.entity_id, + ) + return + if payload.lower() == "none": + self._attr_activity = None + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + return + + try: + self._attr_activity = LawnMowerActivity(payload) + except ValueError: + _LOGGER.error( + "Invalid activity for %s: '%s' (valid activies: %s)", + self.entity_id, + payload, + [option.value for option in LawnMowerActivity], + ) + return + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + + if self._config.get(CONF_ACTIVITY_STATE_TOPIC) is None: + # Force into optimistic mode. + self._optimistic = True + else: + self._sub_state = subscription.async_prepare_subscribe_topics( + self.hass, + self._sub_state, + { + CONF_ACTIVITY_STATE_TOPIC: { + "topic": self._config.get(CONF_ACTIVITY_STATE_TOPIC), + "msg_callback": message_received, + "qos": self._config[CONF_QOS], + "encoding": self._config[CONF_ENCODING] or None, + } + }, + ) + + async def _subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + await subscription.async_subscribe_topics(self.hass, self._sub_state) + + if self._optimistic and (last_state := await self.async_get_last_state()): + with contextlib.suppress(ValueError): + self._attr_activity = LawnMowerActivity(last_state.state) + + @property + def assumed_state(self) -> bool: + """Return true if we do optimistic updates.""" + return self._optimistic + + async def _async_operate(self, option: str, activity: LawnMowerActivity) -> None: + """Execute operation.""" + payload = self._command_templates[option](option) + if self._optimistic: + self._attr_activity = activity + self.async_write_ha_state() + + await self.async_publish( + self._command_topics[option], + payload, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + self._config[CONF_ENCODING], + ) + + async def async_start_mowing(self) -> None: + """Start or resume mowing.""" + await self._async_operate("start_mowing", LawnMowerActivity.MOWING) + + async def async_dock(self) -> None: + """Dock the mower.""" + await self._async_operate("dock", LawnMowerActivity.DOCKED) + + async def async_pause(self) -> None: + """Pause the lawn mower.""" + await self._async_operate("pause", LawnMowerActivity.PAUSED) diff --git a/tests/components/mqtt/test_lawn_mower.py b/tests/components/mqtt/test_lawn_mower.py new file mode 100644 index 00000000000..b7130cac3bf --- /dev/null +++ b/tests/components/mqtt/test_lawn_mower.py @@ -0,0 +1,888 @@ +"""The tests for mqtt lawn_mower component.""" +import copy +import json +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components import lawn_mower, mqtt +from homeassistant.components.lawn_mower import ( + DOMAIN as LAWN_MOWER_DOMAIN, + SERVICE_DOCK, + SERVICE_PAUSE, + SERVICE_START_MOWING, + LawnMowerEntityFeature, +) +from homeassistant.components.mqtt.lawn_mower import MQTT_LAWN_MOWER_ATTRIBUTES_BLOCKED +from homeassistant.const import ( + ATTR_ASSUMED_STATE, + ATTR_ENTITY_ID, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant, State + +from .test_common import ( + help_test_availability_when_connection_lost, + help_test_availability_without_topic, + help_test_custom_availability_payload, + help_test_default_availability_payload, + help_test_discovery_broken, + help_test_discovery_removal, + help_test_discovery_setup, + help_test_discovery_update, + help_test_discovery_update_attr, + help_test_discovery_update_unchanged, + help_test_encoding_subscribable_topics, + help_test_entity_debug_info_message, + help_test_entity_device_info_remove, + help_test_entity_device_info_update, + help_test_entity_device_info_with_connection, + help_test_entity_device_info_with_identifier, + help_test_entity_id_update_discovery_update, + help_test_entity_id_update_subscriptions, + help_test_publishing_with_custom_encoding, + help_test_reloadable, + help_test_setting_attribute_via_mqtt_json_message, + help_test_setting_attribute_with_template, + help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_unique_id, + help_test_unload_config_entry_with_platform, + help_test_update_with_json_attrs_bad_json, + help_test_update_with_json_attrs_not_dict, +) + +from tests.common import async_fire_mqtt_message, mock_restore_cache +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient + +ATTR_ACTIVITY = "activity" + +DEFAULT_FEATURES = ( + LawnMowerEntityFeature.START_MOWING + | LawnMowerEntityFeature.PAUSE + | LawnMowerEntityFeature.DOCK +) + +DEFAULT_CONFIG = { + mqtt.DOMAIN: { + lawn_mower.DOMAIN: { + "name": "test", + "dock_command_topic": "dock-test-topic", + "pause_command_topic": "pause-test-topic", + "start_mowing_command_topic": "start_mowing-test-topic", + } + } +} + + +@pytest.fixture(autouse=True) +def lawn_mower_platform_only(): + """Only setup the lawn_mower platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LAWN_MOWER]): + yield + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + lawn_mower.DOMAIN: { + "activity_state_topic": "test/lawn_mower_stat", + "dock_command_topic": "dock-test-topic", + "pause_command_topic": "pause-test-topic", + "start_mowing_command_topic": "start_mowing-test-topic", + "name": "Test Lawn Mower", + } + } + } + ], +) +async def test_run_lawn_mower_setup_and_state_updates( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test that it sets up correctly fetches the given payload.""" + await mqtt_mock_entry() + + async_fire_mqtt_message(hass, "test/lawn_mower_stat", "mowing") + + await hass.async_block_till_done() + + state = hass.states.get("lawn_mower.test_lawn_mower") + assert state.state == "mowing" + + async_fire_mqtt_message(hass, "test/lawn_mower_stat", "docked") + + await hass.async_block_till_done() + + state = hass.states.get("lawn_mower.test_lawn_mower") + assert state.state == "docked" + + # empty payloads are ignored + async_fire_mqtt_message(hass, "test/lawn_mower_stat", "") + + await hass.async_block_till_done() + + state = hass.states.get("lawn_mower.test_lawn_mower") + assert state.state == "docked" + + +@pytest.mark.parametrize( + ("hass_config", "expected_features"), + [ + ( + DEFAULT_CONFIG, + DEFAULT_FEATURES, + ), + ( + { + mqtt.DOMAIN: { + lawn_mower.DOMAIN: { + "pause_command_topic": "pause-test-topic", + "name": "test", + } + } + }, + LawnMowerEntityFeature.PAUSE, + ), + ( + { + mqtt.DOMAIN: { + lawn_mower.DOMAIN: { + "dock_command_topic": "dock-test-topic", + "start_mowing_command_topic": "start_mowing-test-topic", + "name": "test", + } + } + }, + LawnMowerEntityFeature.START_MOWING | LawnMowerEntityFeature.DOCK, + ), + ], +) +async def test_supported_features( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + expected_features: LawnMowerEntityFeature | None, +) -> None: + """Test conditional enablement of supported features.""" + await mqtt_mock_entry() + assert ( + hass.states.get("lawn_mower.test").attributes["supported_features"] + == expected_features + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + lawn_mower.DOMAIN: { + "activity_state_topic": "test/lawn_mower_stat", + "name": "Test Lawn Mower", + "activity_value_template": "{{ value_json.val }}", + } + } + } + ], +) +async def test_value_template( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test that it fetches the given payload with a template.""" + await mqtt_mock_entry() + + async_fire_mqtt_message(hass, "test/lawn_mower_stat", '{"val":"mowing"}') + + await hass.async_block_till_done() + + state = hass.states.get("lawn_mower.test_lawn_mower") + assert state.state == "mowing" + + async_fire_mqtt_message(hass, "test/lawn_mower_stat", '{"val":"paused"}') + + await hass.async_block_till_done() + + state = hass.states.get("lawn_mower.test_lawn_mower") + assert state.state == "paused" + + async_fire_mqtt_message(hass, "test/lawn_mower_stat", '{"val": null}') + + await hass.async_block_till_done() + + state = hass.states.get("lawn_mower.test_lawn_mower") + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG], +) +async def test_run_lawn_mower_service_optimistic( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test that service calls work in optimistic mode.""" + + fake_state = State("lawn_mower.test", "docked") + mock_restore_cache(hass, (fake_state,)) + + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("lawn_mower.test") + assert state.state == "docked" + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await hass.services.async_call( + lawn_mower.DOMAIN, + SERVICE_START_MOWING, + {ATTR_ENTITY_ID: "lawn_mower.test"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "start_mowing-test-topic", "start_mowing", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("lawn_mower.test") + assert state.state == "mowing" + + await hass.services.async_call( + lawn_mower.DOMAIN, + SERVICE_PAUSE, + {ATTR_ENTITY_ID: "lawn_mower.test"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "pause-test-topic", "pause", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("lawn_mower.test") + assert state.state == "paused" + + await hass.services.async_call( + lawn_mower.DOMAIN, + SERVICE_DOCK, + {ATTR_ENTITY_ID: "lawn_mower.test"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with("dock-test-topic", "dock", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("lawn_mower.test") + assert state.state == "docked" + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + lawn_mower.DOMAIN: { + "pause_command_topic": "test/lawn_mower_pause_cmd", + "name": "Test Lawn Mower", + } + } + } + ], +) +async def test_restore_lawn_mower_from_invalid_state( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test that restoring the state skips invalid values.""" + fake_state = State("lawn_mower.test_lawn_mower", "unknown") + mock_restore_cache(hass, (fake_state,)) + + await mqtt_mock_entry() + + state = hass.states.get("lawn_mower.test_lawn_mower") + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + lawn_mower.DOMAIN: { + "name": "Test Lawn Mower", + "dock_command_topic": "test/lawn_mower_dock_cmd", + "dock_command_template": '{"action": "{{ value }}"}', + "pause_command_topic": "test/lawn_mower_pause_cmd", + "pause_command_template": '{"action": "{{ value }}"}', + "start_mowing_command_topic": "test/lawn_mower_start_mowing_cmd", + "start_mowing_command_template": '{"action": "{{ value }}"}', + } + } + } + ], +) +async def test_run_lawn_mower_service_optimistic_with_command_templates( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test that service calls work in optimistic mode and with a command_template.""" + fake_state = State("lawn_mower.test_lawn_mower", "docked") + mock_restore_cache(hass, (fake_state,)) + + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("lawn_mower.test_lawn_mower") + assert state.state == "docked" + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await hass.services.async_call( + lawn_mower.DOMAIN, + SERVICE_START_MOWING, + {ATTR_ENTITY_ID: "lawn_mower.test_lawn_mower"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "test/lawn_mower_start_mowing_cmd", '{"action": "start_mowing"}', 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("lawn_mower.test_lawn_mower") + assert state.state == "mowing" + + await hass.services.async_call( + lawn_mower.DOMAIN, + SERVICE_PAUSE, + {ATTR_ENTITY_ID: "lawn_mower.test_lawn_mower"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "test/lawn_mower_pause_cmd", '{"action": "pause"}', 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("lawn_mower.test_lawn_mower") + assert state.state == "paused" + + await hass.services.async_call( + lawn_mower.DOMAIN, + SERVICE_DOCK, + {ATTR_ENTITY_ID: "lawn_mower.test_lawn_mower"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "test/lawn_mower_dock_cmd", '{"action": "dock"}', 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("lawn_mower.test_lawn_mower") + assert state.state == "docked" + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_availability_when_connection_lost( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability after MQTT disconnection.""" + await help_test_availability_when_connection_lost( + hass, mqtt_mock_entry, lawn_mower.DOMAIN + ) + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_availability_without_topic( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability without defined availability topic.""" + await help_test_availability_without_topic( + hass, mqtt_mock_entry, lawn_mower.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_default_availability_payload( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability by default payload with defined topic.""" + await help_test_default_availability_payload( + hass, mqtt_mock_entry, lawn_mower.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_custom_availability_payload( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability by custom payload with defined topic.""" + await help_test_custom_availability_payload( + hass, mqtt_mock_entry, lawn_mower.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_setting_attribute_via_mqtt_json_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry, lawn_mower.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_setting_blocked_attribute_via_mqtt_json_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_blocked_attribute_via_mqtt_json_message( + hass, + mqtt_mock_entry, + lawn_mower.DOMAIN, + DEFAULT_CONFIG, + MQTT_LAWN_MOWER_ATTRIBUTES_BLOCKED, + ) + + +async def test_setting_attribute_with_template( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_with_template( + hass, mqtt_mock_entry, lawn_mower.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_update_with_json_attrs_not_dict( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_not_dict( + hass, + mqtt_mock_entry, + caplog, + lawn_mower.DOMAIN, + DEFAULT_CONFIG, + ) + + +async def test_update_with_json_attrs_bad_json( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_bad_json( + hass, + mqtt_mock_entry, + caplog, + lawn_mower.DOMAIN, + DEFAULT_CONFIG, + ) + + +async def test_discovery_update_attr( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered MQTTAttributes.""" + await help_test_discovery_update_attr( + hass, + mqtt_mock_entry, + caplog, + lawn_mower.DOMAIN, + DEFAULT_CONFIG, + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + lawn_mower.DOMAIN: [ + { + "name": "Test 1", + "activity_state_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "activity_state_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) +async def test_unique_id( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test unique id action only creates one lawn_mower per unique_id.""" + await help_test_unique_id(hass, mqtt_mock_entry, lawn_mower.DOMAIN) + + +async def test_discovery_removal_lawn_mower( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test removal of discovered lawn_mower.""" + data = json.dumps(DEFAULT_CONFIG[mqtt.DOMAIN][lawn_mower.DOMAIN]) + await help_test_discovery_removal( + hass, mqtt_mock_entry, caplog, lawn_mower.DOMAIN, data + ) + + +async def test_discovery_update_lawn_mower( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered lawn_mower.""" + config1 = { + "name": "Beer", + "activity_state_topic": "test-topic", + "command_topic": "test-topic", + "actions": ["milk", "beer"], + } + config2 = { + "name": "Milk", + "activity_state_topic": "test-topic", + "command_topic": "test-topic", + "actions": ["milk"], + } + + await help_test_discovery_update( + hass, mqtt_mock_entry, caplog, lawn_mower.DOMAIN, config1, config2 + ) + + +async def test_discovery_update_unchanged_lawn_mower( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered lawn_mower.""" + data1 = '{ "name": "Beer", "activity_state_topic": "test-topic", "command_topic": "test-topic", "actions": ["milk", "beer"]}' + with patch( + "homeassistant.components.mqtt.lawn_mower.MqttLawnMower.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, + mqtt_mock_entry, + caplog, + lawn_mower.DOMAIN, + data1, + discovery_update, + ) + + +@pytest.mark.no_fail_on_log_exception +async def test_discovery_broken( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test handling of bad discovery message.""" + data1 = '{ "invalid" }' + data2 = '{ "name": "Milk", "activity_state_topic": "test-topic", "pause_command_topic": "test-topic"}' + + await help_test_discovery_broken( + hass, mqtt_mock_entry, caplog, lawn_mower.DOMAIN, data1, data2 + ) + + +async def test_entity_device_info_with_connection( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT lawn_mower device registry integration.""" + await help_test_entity_device_info_with_connection( + hass, mqtt_mock_entry, lawn_mower.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_with_identifier( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT lawn_mower device registry integration.""" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock_entry, lawn_mower.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test device registry update.""" + await help_test_entity_device_info_update( + hass, mqtt_mock_entry, lawn_mower.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_remove( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test device registry remove.""" + await help_test_entity_device_info_remove( + hass, mqtt_mock_entry, lawn_mower.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_subscriptions( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT subscriptions are managed when entity_id is updated.""" + config = { + mqtt.DOMAIN: { + lawn_mower.DOMAIN: { + "name": "test", + "activity_state_topic": "test-topic", + "availability_topic": "avty-topic", + } + } + } + await help_test_entity_id_update_subscriptions( + hass, mqtt_mock_entry, lawn_mower.DOMAIN, config, ["avty-topic", "test-topic"] + ) + + +async def test_entity_id_update_discovery_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT discovery update when entity_id is updated.""" + await help_test_entity_id_update_discovery_update( + hass, mqtt_mock_entry, lawn_mower.DOMAIN, DEFAULT_CONFIG + ) + + +@pytest.mark.parametrize( + ("service", "command_payload", "state_payload", "state_topic", "command_topic"), + [ + ( + SERVICE_START_MOWING, + "start_mowing", + "mowing", + "test/lawn_mower_stat", + "start_mowing-test-topic", + ), + ( + SERVICE_PAUSE, + "pause", + "paused", + "test/lawn_mower_stat", + "pause-test-topic", + ), + ( + SERVICE_DOCK, + "dock", + "docked", + "test/lawn_mower_stat", + "dock-test-topic", + ), + ], +) +async def test_entity_debug_info_message( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + service: str, + command_payload: str, + state_payload: str, + state_topic: str, + command_topic: str, +) -> None: + """Test MQTT debug info.""" + config = { + mqtt.DOMAIN: { + lawn_mower.DOMAIN: { + "activity_state_topic": "test/lawn_mower_stat", + "dock_command_topic": "dock-test-topic", + "pause_command_topic": "pause-test-topic", + "start_mowing_command_topic": "start_mowing-test-topic", + "name": "test", + } + } + } + await help_test_entity_debug_info_message( + hass, + mqtt_mock_entry, + lawn_mower.DOMAIN, + config, + service=service, + command_payload=command_payload, + state_payload=state_payload, + state_topic=state_topic, + command_topic=command_topic, + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + lawn_mower.DOMAIN: { + "dock_command_topic": "dock-test-topic", + "pause_command_topic": "pause-test-topic", + "start_mowing_command_topic": "start_mowing-test-topic", + "activity_state_topic": "test/lawn_mower_stat", + "name": "Test Lawn Mower", + } + } + } + ], +) +async def test_mqtt_payload_not_a_valid_activity_warning( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test warning for MQTT payload which is not a valid activity.""" + await mqtt_mock_entry() + + async_fire_mqtt_message(hass, "test/lawn_mower_stat", "painting") + + await hass.async_block_till_done() + + assert ( + "Invalid activity for lawn_mower.test_lawn_mower: 'painting' (valid activies: ['error', 'paused', 'mowing', 'docked'])" + in caplog.text + ) + + +@pytest.mark.parametrize( + ("service", "topic", "parameters", "payload", "template"), + [ + ( + SERVICE_START_MOWING, + "start_mowing_command_topic", + {}, + "start_mowing", + "start_mowing_command_template", + ), + ( + SERVICE_PAUSE, + "pause_command_topic", + {}, + "pause", + "pause_command_template", + ), + ( + SERVICE_DOCK, + "dock_command_topic", + {}, + "dock", + "dock_command_template", + ), + ], +) +async def test_publishing_with_custom_encoding( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, +) -> None: + """Test publishing MQTT payload with different encoding.""" + domain = lawn_mower.DOMAIN + config = DEFAULT_CONFIG + + await help_test_publishing_with_custom_encoding( + hass, + mqtt_mock_entry, + caplog, + domain, + config, + service, + topic, + parameters, + payload, + template, + ) + + +async def test_reloadable( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, +) -> None: + """Test reloading the MQTT platform.""" + domain = lawn_mower.DOMAIN + config = DEFAULT_CONFIG + await help_test_reloadable(hass, mqtt_client_mock, domain, config) + + +@pytest.mark.parametrize( + ("topic", "value", "attribute", "attribute_value"), + [ + ("activity_state_topic", "paused", None, "paused"), + ("activity_state_topic", "docked", None, "docked"), + ("activity_state_topic", "mowing", None, "mowing"), + ], +) +async def test_encoding_subscribable_topics( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, +) -> None: + """Test handling of incoming encoded payload.""" + config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][lawn_mower.DOMAIN]) + config["actions"] = ["milk", "beer"] + await help_test_encoding_subscribable_topics( + hass, + mqtt_mock_entry, + lawn_mower.DOMAIN, + config, + topic, + value, + attribute, + attribute_value, + ) + + +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG, {"mqtt": [DEFAULT_CONFIG["mqtt"]]}], + ids=["platform_key", "listed"], +) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry() + platform = lawn_mower.DOMAIN + assert hass.states.get(f"{platform}.test") + + +async def test_unload_entry( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test unloading the config entry.""" + domain = lawn_mower.DOMAIN + config = DEFAULT_CONFIG + await help_test_unload_config_entry_with_platform( + hass, mqtt_mock_entry, domain, config + ) + + +async def test_persistent_state_after_reconfig( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test of the state is persistent after reconfiguring the lawn_mower activity.""" + await mqtt_mock_entry() + discovery_data = '{ "name": "Garden", "activity_state_topic": "test-topic", "command_topic": "test-topic"}' + await help_test_discovery_setup(hass, LAWN_MOWER_DOMAIN, discovery_data, "garden") + + # assign an initial state + async_fire_mqtt_message(hass, "test-topic", "docked") + state = hass.states.get("lawn_mower.garden") + assert state.state == "docked" + + # change the config + discovery_data = '{ "name": "Garden", "activity_state_topic": "test-topic2", "command_topic": "test-topic"}' + await help_test_discovery_setup(hass, LAWN_MOWER_DOMAIN, discovery_data, "garden") + + # assert the state persistent + state = hass.states.get("lawn_mower.garden") + assert state.state == "docked" From 8768c390213130d8d6ab1eea15e7e8fd6533fe6c Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 25 Aug 2023 12:28:48 -0500 Subject: [PATCH 112/124] Wake word cleanup (#98652) * Make arguments for async_pipeline_from_audio_stream keyword-only after hass * Use a bytearray ring buffer * Move generator outside * Move stt stream generator outside * Clean up execute * Refactor VAD to use bytearray * More tests * Refactor chunk_samples to be more correct and robust * Change AudioBuffer to use append instead of setitem * Cleanup --------- Co-authored-by: Paulus Schoutsen --- .../components/assist_pipeline/__init__.py | 1 + .../components/assist_pipeline/pipeline.py | 183 ++++++++++-------- .../components/assist_pipeline/ring_buffer.py | 57 ++++++ .../components/assist_pipeline/vad.py | 153 ++++++++++----- .../components/wake_word/__init__.py | 2 - .../assist_pipeline/snapshots/test_init.ambr | 6 + tests/components/assist_pipeline/test_init.py | 90 +++++---- .../assist_pipeline/test_ring_buffer.py | 38 ++++ tests/components/assist_pipeline/test_vad.py | 91 ++++++++- 9 files changed, 458 insertions(+), 163 deletions(-) create mode 100644 homeassistant/components/assist_pipeline/ring_buffer.py create mode 100644 tests/components/assist_pipeline/test_ring_buffer.py diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py index c2d25da2162..4c2fe01036f 100644 --- a/homeassistant/components/assist_pipeline/__init__.py +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -52,6 +52,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_pipeline_from_audio_stream( hass: HomeAssistant, + *, context: Context, event_callback: PipelineEventCallback, stt_metadata: stt.SpeechMetadata, diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 320812b2039..3759fc12c75 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -49,6 +49,7 @@ from .error import ( WakeWordDetectionError, WakeWordTimeoutError, ) +from .ring_buffer import RingBuffer from .vad import VoiceActivityTimeout, VoiceCommandSegmenter _LOGGER = logging.getLogger(__name__) @@ -425,7 +426,6 @@ class PipelineRun: async def prepare_wake_word_detection(self) -> None: """Prepare wake-word-detection.""" - # Need to add to pipeline store engine = wake_word.async_default_engine(self.hass) if engine is None: raise WakeWordDetectionError( @@ -448,7 +448,7 @@ class PipelineRun: async def wake_word_detection( self, stream: AsyncIterable[bytes], - audio_buffer: list[bytes], + audio_chunks_for_stt: list[bytes], ) -> wake_word.DetectionResult | None: """Run wake-word-detection portion of pipeline. Returns detection result.""" metadata_dict = asdict( @@ -484,46 +484,29 @@ class PipelineRun: # Use VAD to determine timeout wake_word_vad = VoiceActivityTimeout(wake_word_settings.timeout) - # Audio chunk buffer. - audio_bytes_to_buffer = int( - wake_word_settings.audio_seconds_to_buffer * 16000 * 2 + # Audio chunk buffer. This audio will be forwarded to speech-to-text + # after wake-word-detection. + num_audio_bytes_to_buffer = int( + wake_word_settings.audio_seconds_to_buffer * 16000 * 2 # 16-bit @ 16Khz ) - audio_ring_buffer = b"" - - async def timestamped_stream() -> AsyncIterable[tuple[bytes, int]]: - """Yield audio with timestamps (milliseconds since start of stream).""" - nonlocal audio_ring_buffer - - timestamp_ms = 0 - async for chunk in stream: - yield chunk, timestamp_ms - timestamp_ms += (len(chunk) // 2) // 16 # milliseconds @ 16Khz - - # Keeping audio right before wake word detection allows the - # voice command to be spoken immediately after the wake word. - if audio_bytes_to_buffer > 0: - audio_ring_buffer += chunk - if len(audio_ring_buffer) > audio_bytes_to_buffer: - # A proper ring buffer would be far more efficient - audio_ring_buffer = audio_ring_buffer[ - len(audio_ring_buffer) - audio_bytes_to_buffer : - ] - - if (wake_word_vad is not None) and (not wake_word_vad.process(chunk)): - raise WakeWordTimeoutError( - code="wake-word-timeout", message="Wake word was not detected" - ) + stt_audio_buffer: RingBuffer | None = None + if num_audio_bytes_to_buffer > 0: + stt_audio_buffer = RingBuffer(num_audio_bytes_to_buffer) try: # Detect wake word(s) result = await self.wake_word_provider.async_process_audio_stream( - timestamped_stream() + _wake_word_audio_stream( + audio_stream=stream, + stt_audio_buffer=stt_audio_buffer, + wake_word_vad=wake_word_vad, + ) ) - if audio_ring_buffer: + if stt_audio_buffer is not None: # All audio kept from right before the wake word was detected as # a single chunk. - audio_buffer.append(audio_ring_buffer) + audio_chunks_for_stt.append(stt_audio_buffer.getvalue()) except WakeWordTimeoutError: _LOGGER.debug("Timeout during wake word detection") raise @@ -540,9 +523,14 @@ class PipelineRun: wake_word_output: dict[str, Any] = {} else: if result.queued_audio: - # Add audio that was pending at detection + # Add audio that was pending at detection. + # + # Because detection occurs *after* the wake word was actually + # spoken, we need to make sure pending audio is forwarded to + # speech-to-text so the user does not have to pause before + # speaking the voice command. for chunk_ts in result.queued_audio: - audio_buffer.append(chunk_ts[0]) + audio_chunks_for_stt.append(chunk_ts[0]) wake_word_output = asdict(result) @@ -608,41 +596,12 @@ class PipelineRun: ) try: - segmenter = VoiceCommandSegmenter() - - async def segment_stream( - stream: AsyncIterable[bytes], - ) -> AsyncGenerator[bytes, None]: - """Stop stream when voice command is finished.""" - sent_vad_start = False - timestamp_ms = 0 - async for chunk in stream: - if not segmenter.process(chunk): - # Silence detected at the end of voice command - self.process_event( - PipelineEvent( - PipelineEventType.STT_VAD_END, - {"timestamp": timestamp_ms}, - ) - ) - break - - if segmenter.in_command and (not sent_vad_start): - # Speech detected at start of voice command - self.process_event( - PipelineEvent( - PipelineEventType.STT_VAD_START, - {"timestamp": timestamp_ms}, - ) - ) - sent_vad_start = True - - yield chunk - timestamp_ms += (len(chunk) // 2) // 16 # milliseconds @ 16Khz - # Transcribe audio stream result = await self.stt_provider.async_process_audio_stream( - metadata, segment_stream(stream) + metadata, + self._speech_to_text_stream( + audio_stream=stream, stt_vad=VoiceCommandSegmenter() + ), ) except Exception as src_error: _LOGGER.exception("Unexpected error during speech-to-text") @@ -677,6 +636,42 @@ class PipelineRun: return result.text + async def _speech_to_text_stream( + self, + audio_stream: AsyncIterable[bytes], + stt_vad: VoiceCommandSegmenter | None, + sample_rate: int = 16000, + sample_width: int = 2, + ) -> AsyncGenerator[bytes, None]: + """Yield audio chunks until VAD detects silence or speech-to-text completes.""" + ms_per_sample = sample_rate // 1000 + sent_vad_start = False + timestamp_ms = 0 + async for chunk in audio_stream: + if stt_vad is not None: + if not stt_vad.process(chunk): + # Silence detected at the end of voice command + self.process_event( + PipelineEvent( + PipelineEventType.STT_VAD_END, + {"timestamp": timestamp_ms}, + ) + ) + break + + if stt_vad.in_command and (not sent_vad_start): + # Speech detected at start of voice command + self.process_event( + PipelineEvent( + PipelineEventType.STT_VAD_START, + {"timestamp": timestamp_ms}, + ) + ) + sent_vad_start = True + + yield chunk + timestamp_ms += (len(chunk) // sample_width) // ms_per_sample + async def prepare_recognize_intent(self) -> None: """Prepare recognizing an intent.""" agent_info = conversation.async_get_agent_info( @@ -861,13 +856,14 @@ class PipelineInput: """Run pipeline.""" self.run.start() current_stage: PipelineStage | None = self.run.start_stage - audio_buffer: list[bytes] = [] + stt_audio_buffer: list[bytes] = [] try: if current_stage == PipelineStage.WAKE_WORD: + # wake-word-detection assert self.stt_stream is not None detect_result = await self.run.wake_word_detection( - self.stt_stream, audio_buffer + self.stt_stream, stt_audio_buffer ) if detect_result is None: # No wake word. Abort the rest of the pipeline. @@ -882,19 +878,22 @@ class PipelineInput: assert self.stt_metadata is not None assert self.stt_stream is not None - if audio_buffer: + stt_stream = self.stt_stream - async def buffered_stream() -> AsyncGenerator[bytes, None]: - for chunk in audio_buffer: + if stt_audio_buffer: + # Send audio in the buffer first to speech-to-text, then move on to stt_stream. + # This is basically an async itertools.chain. + async def buffer_then_audio_stream() -> AsyncGenerator[bytes, None]: + # Buffered audio + for chunk in stt_audio_buffer: yield chunk + # Streamed audio assert self.stt_stream is not None async for chunk in self.stt_stream: yield chunk - stt_stream = cast(AsyncIterable[bytes], buffered_stream()) - else: - stt_stream = self.stt_stream + stt_stream = buffer_then_audio_stream() intent_input = await self.run.speech_to_text( self.stt_metadata, @@ -906,6 +905,7 @@ class PipelineInput: tts_input = self.tts_input if current_stage == PipelineStage.INTENT: + # intent-recognition assert intent_input is not None tts_input = await self.run.recognize_intent( intent_input, @@ -915,6 +915,7 @@ class PipelineInput: current_stage = PipelineStage.TTS if self.run.end_stage != PipelineStage.INTENT: + # text-to-speech if current_stage == PipelineStage.TTS: assert tts_input is not None await self.run.text_to_speech(tts_input) @@ -999,6 +1000,36 @@ class PipelineInput: await asyncio.gather(*prepare_tasks) +async def _wake_word_audio_stream( + audio_stream: AsyncIterable[bytes], + stt_audio_buffer: RingBuffer | None, + wake_word_vad: VoiceActivityTimeout | None, + sample_rate: int = 16000, + sample_width: int = 2, +) -> AsyncIterable[tuple[bytes, int]]: + """Yield audio chunks with timestamps (milliseconds since start of stream). + + Adds audio to a ring buffer that will be forwarded to speech-to-text after + detection. Times out if VAD detects enough silence. + """ + ms_per_sample = sample_rate // 1000 + timestamp_ms = 0 + async for chunk in audio_stream: + yield chunk, timestamp_ms + timestamp_ms += (len(chunk) // sample_width) // ms_per_sample + + # Wake-word-detection occurs *after* the wake word was actually + # spoken. Keeping audio right before detection allows the voice + # command to be spoken immediately after the wake word. + if stt_audio_buffer is not None: + stt_audio_buffer.put(chunk) + + if (wake_word_vad is not None) and (not wake_word_vad.process(chunk)): + raise WakeWordTimeoutError( + code="wake-word-timeout", message="Wake word was not detected" + ) + + class PipelinePreferred(CollectionError): """Raised when attempting to delete the preferred pipelen.""" diff --git a/homeassistant/components/assist_pipeline/ring_buffer.py b/homeassistant/components/assist_pipeline/ring_buffer.py new file mode 100644 index 00000000000..d134389216c --- /dev/null +++ b/homeassistant/components/assist_pipeline/ring_buffer.py @@ -0,0 +1,57 @@ +"""Implementation of a ring buffer using bytearray.""" + + +class RingBuffer: + """Basic ring buffer using a bytearray. + + Not threadsafe. + """ + + def __init__(self, maxlen: int) -> None: + """Initialize empty buffer.""" + self._buffer = bytearray(maxlen) + self._pos = 0 + self._length = 0 + self._maxlen = maxlen + + @property + def maxlen(self) -> int: + """Return the maximum size of the buffer.""" + return self._maxlen + + @property + def pos(self) -> int: + """Return the current put position.""" + return self._pos + + def __len__(self) -> int: + """Return the length of data stored in the buffer.""" + return self._length + + def put(self, data: bytes) -> None: + """Put a chunk of data into the buffer, possibly wrapping around.""" + data_len = len(data) + new_pos = self._pos + data_len + if new_pos >= self._maxlen: + # Split into two chunks + num_bytes_1 = self._maxlen - self._pos + num_bytes_2 = new_pos - self._maxlen + + self._buffer[self._pos : self._maxlen] = data[:num_bytes_1] + self._buffer[:num_bytes_2] = data[num_bytes_1:] + new_pos = new_pos - self._maxlen + else: + # Entire chunk fits at current position + self._buffer[self._pos : self._pos + data_len] = data + + self._pos = new_pos + self._length = min(self._maxlen, self._length + data_len) + + def getvalue(self) -> bytes: + """Get bytes written to the buffer.""" + if (self._pos + self._length) <= self._maxlen: + # Single chunk + return bytes(self._buffer[: self._length]) + + # Two chunks + return bytes(self._buffer[self._pos :] + self._buffer[: self._pos]) diff --git a/homeassistant/components/assist_pipeline/vad.py b/homeassistant/components/assist_pipeline/vad.py index cae31671a3c..20a048d5621 100644 --- a/homeassistant/components/assist_pipeline/vad.py +++ b/homeassistant/components/assist_pipeline/vad.py @@ -1,12 +1,15 @@ """Voice activity detection.""" from __future__ import annotations +from collections.abc import Iterable from dataclasses import dataclass, field from enum import StrEnum +from typing import Final import webrtcvad -_SAMPLE_RATE = 16000 +_SAMPLE_RATE: Final = 16000 # Hz +_SAMPLE_WIDTH: Final = 2 # bytes class VadSensitivity(StrEnum): @@ -29,6 +32,45 @@ class VadSensitivity(StrEnum): return 1.0 +class AudioBuffer: + """Fixed-sized audio buffer with variable internal length.""" + + def __init__(self, maxlen: int) -> None: + """Initialize buffer.""" + self._buffer = bytearray(maxlen) + self._length = 0 + + @property + def length(self) -> int: + """Get number of bytes currently in the buffer.""" + return self._length + + def clear(self) -> None: + """Clear the buffer.""" + self._length = 0 + + def append(self, data: bytes) -> None: + """Append bytes to the buffer, increasing the internal length.""" + data_len = len(data) + if (self._length + data_len) > len(self._buffer): + raise ValueError("Length cannot be greater than buffer size") + + self._buffer[self._length : self._length + data_len] = data + self._length += data_len + + def bytes(self) -> bytes: + """Convert written portion of buffer to bytes.""" + return bytes(self._buffer[: self._length]) + + def __len__(self) -> int: + """Get the number of bytes currently in the buffer.""" + return self._length + + def __bool__(self) -> bool: + """Return True if there are bytes in the buffer.""" + return self._length > 0 + + @dataclass class VoiceCommandSegmenter: """Segments an audio stream into voice commands using webrtcvad.""" @@ -36,7 +78,7 @@ class VoiceCommandSegmenter: vad_mode: int = 3 """Aggressiveness in filtering out non-speech. 3 is the most aggressive.""" - vad_frames: int = 480 # 30 ms + vad_samples_per_chunk: int = 480 # 30 ms """Must be 10, 20, or 30 ms at 16Khz.""" speech_seconds: float = 0.3 @@ -67,20 +109,23 @@ class VoiceCommandSegmenter: """Seconds left before resetting start/stop time counters.""" _vad: webrtcvad.Vad = None - _audio_buffer: bytes = field(default_factory=bytes) - _bytes_per_chunk: int = 480 * 2 # 16-bit samples - _seconds_per_chunk: float = 0.03 # 30 ms + _leftover_chunk_buffer: AudioBuffer = field(init=False) + _bytes_per_chunk: int = field(init=False) + _seconds_per_chunk: float = field(init=False) def __post_init__(self) -> None: """Initialize VAD.""" self._vad = webrtcvad.Vad(self.vad_mode) - self._bytes_per_chunk = self.vad_frames * 2 - self._seconds_per_chunk = self.vad_frames / _SAMPLE_RATE + self._bytes_per_chunk = self.vad_samples_per_chunk * _SAMPLE_WIDTH + self._seconds_per_chunk = self.vad_samples_per_chunk / _SAMPLE_RATE + self._leftover_chunk_buffer = AudioBuffer( + self.vad_samples_per_chunk * _SAMPLE_WIDTH + ) self.reset() def reset(self) -> None: """Reset all counters and state.""" - self._audio_buffer = b"" + self._leftover_chunk_buffer.clear() self._speech_seconds_left = self.speech_seconds self._silence_seconds_left = self.silence_seconds self._timeout_seconds_left = self.timeout_seconds @@ -92,27 +137,20 @@ class VoiceCommandSegmenter: Returns False when command is done. """ - self._audio_buffer += samples - - # Process in 10, 20, or 30 ms chunks. - num_chunks = len(self._audio_buffer) // self._bytes_per_chunk - for chunk_idx in range(num_chunks): - chunk_offset = chunk_idx * self._bytes_per_chunk - chunk = self._audio_buffer[ - chunk_offset : chunk_offset + self._bytes_per_chunk - ] + for chunk in chunk_samples( + samples, self._bytes_per_chunk, self._leftover_chunk_buffer + ): if not self._process_chunk(chunk): self.reset() return False - if num_chunks > 0: - # Remove from buffer - self._audio_buffer = self._audio_buffer[ - num_chunks * self._bytes_per_chunk : - ] - return True + @property + def audio_buffer(self) -> bytes: + """Get partial chunk in the audio buffer.""" + return self._leftover_chunk_buffer.bytes() + def _process_chunk(self, chunk: bytes) -> bool: """Process a single chunk of 16-bit 16Khz mono audio. @@ -163,7 +201,7 @@ class VoiceActivityTimeout: vad_mode: int = 3 """Aggressiveness in filtering out non-speech. 3 is the most aggressive.""" - vad_frames: int = 480 # 30 ms + vad_samples_per_chunk: int = 480 # 30 ms """Must be 10, 20, or 30 ms at 16Khz.""" _silence_seconds_left: float = 0.0 @@ -173,20 +211,23 @@ class VoiceActivityTimeout: """Seconds left before resetting start/stop time counters.""" _vad: webrtcvad.Vad = None - _audio_buffer: bytes = field(default_factory=bytes) - _bytes_per_chunk: int = 480 * 2 # 16-bit samples - _seconds_per_chunk: float = 0.03 # 30 ms + _leftover_chunk_buffer: AudioBuffer = field(init=False) + _bytes_per_chunk: int = field(init=False) + _seconds_per_chunk: float = field(init=False) def __post_init__(self) -> None: """Initialize VAD.""" self._vad = webrtcvad.Vad(self.vad_mode) - self._bytes_per_chunk = self.vad_frames * 2 - self._seconds_per_chunk = self.vad_frames / _SAMPLE_RATE + self._bytes_per_chunk = self.vad_samples_per_chunk * _SAMPLE_WIDTH + self._seconds_per_chunk = self.vad_samples_per_chunk / _SAMPLE_RATE + self._leftover_chunk_buffer = AudioBuffer( + self.vad_samples_per_chunk * _SAMPLE_WIDTH + ) self.reset() def reset(self) -> None: """Reset all counters and state.""" - self._audio_buffer = b"" + self._leftover_chunk_buffer.clear() self._silence_seconds_left = self.silence_seconds self._reset_seconds_left = self.reset_seconds @@ -195,24 +236,12 @@ class VoiceActivityTimeout: Returns False when timeout is reached. """ - self._audio_buffer += samples - - # Process in 10, 20, or 30 ms chunks. - num_chunks = len(self._audio_buffer) // self._bytes_per_chunk - for chunk_idx in range(num_chunks): - chunk_offset = chunk_idx * self._bytes_per_chunk - chunk = self._audio_buffer[ - chunk_offset : chunk_offset + self._bytes_per_chunk - ] + for chunk in chunk_samples( + samples, self._bytes_per_chunk, self._leftover_chunk_buffer + ): if not self._process_chunk(chunk): return False - if num_chunks > 0: - # Remove from buffer - self._audio_buffer = self._audio_buffer[ - num_chunks * self._bytes_per_chunk : - ] - return True def _process_chunk(self, chunk: bytes) -> bool: @@ -239,3 +268,37 @@ class VoiceActivityTimeout: ) return True + + +def chunk_samples( + samples: bytes, + bytes_per_chunk: int, + leftover_chunk_buffer: AudioBuffer, +) -> Iterable[bytes]: + """Yield fixed-sized chunks from samples, keeping leftover bytes from previous call(s).""" + + if (len(leftover_chunk_buffer) + len(samples)) < bytes_per_chunk: + # Extend leftover chunk, but not enough samples to complete it + leftover_chunk_buffer.append(samples) + return + + next_chunk_idx = 0 + + if leftover_chunk_buffer: + # Add to leftover chunk from previous call(s). + bytes_to_copy = bytes_per_chunk - len(leftover_chunk_buffer) + leftover_chunk_buffer.append(samples[:bytes_to_copy]) + next_chunk_idx = bytes_to_copy + + # Process full chunk in buffer + yield leftover_chunk_buffer.bytes() + leftover_chunk_buffer.clear() + + while next_chunk_idx < len(samples) - bytes_per_chunk + 1: + # Process full chunk + yield samples[next_chunk_idx : next_chunk_idx + bytes_per_chunk] + next_chunk_idx += bytes_per_chunk + + # Capture leftover chunks + if rest_samples := samples[next_chunk_idx:]: + leftover_chunk_buffer.append(rest_samples) diff --git a/homeassistant/components/wake_word/__init__.py b/homeassistant/components/wake_word/__init__.py index 0a751b7eea2..b308cf98912 100644 --- a/homeassistant/components/wake_word/__init__.py +++ b/homeassistant/components/wake_word/__init__.py @@ -79,8 +79,6 @@ class WakeWordDetectionEntity(RestoreEntity): @final def state(self) -> str | None: """Return the state of the entity.""" - if self.__last_detected is None: - return None return self.__last_detected @property diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index 58835e37973..7c1cf0e2b2d 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -317,6 +317,12 @@ }), 'type': , }), + dict({ + 'data': dict({ + 'timestamp': 1500, + }), + 'type': , + }), dict({ 'data': dict({ 'stt_output': dict({ diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index 184f479f830..aba9862614b 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -1,7 +1,7 @@ """Test Voice Assistant init.""" from dataclasses import asdict import itertools as it -from unittest.mock import ANY +from unittest.mock import ANY, patch import pytest from syrupy.assertion import SnapshotAssertion @@ -49,9 +49,9 @@ async def test_pipeline_from_audio_stream_auto( await assist_pipeline.async_pipeline_from_audio_stream( hass, - Context(), - events.append, - stt.SpeechMetadata( + context=Context(), + event_callback=events.append, + stt_metadata=stt.SpeechMetadata( language="", format=stt.AudioFormats.WAV, codec=stt.AudioCodecs.PCM, @@ -59,7 +59,7 @@ async def test_pipeline_from_audio_stream_auto( sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, channel=stt.AudioChannels.CHANNEL_MONO, ), - audio_data(), + stt_stream=audio_data(), ) assert process_events(events) == snapshot @@ -108,9 +108,9 @@ async def test_pipeline_from_audio_stream_legacy( # Use the created pipeline await assist_pipeline.async_pipeline_from_audio_stream( hass, - Context(), - events.append, - stt.SpeechMetadata( + context=Context(), + event_callback=events.append, + stt_metadata=stt.SpeechMetadata( language="en-UK", format=stt.AudioFormats.WAV, codec=stt.AudioCodecs.PCM, @@ -118,7 +118,7 @@ async def test_pipeline_from_audio_stream_legacy( sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, channel=stt.AudioChannels.CHANNEL_MONO, ), - audio_data(), + stt_stream=audio_data(), pipeline_id=pipeline_id, ) @@ -168,9 +168,9 @@ async def test_pipeline_from_audio_stream_entity( # Use the created pipeline await assist_pipeline.async_pipeline_from_audio_stream( hass, - Context(), - events.append, - stt.SpeechMetadata( + context=Context(), + event_callback=events.append, + stt_metadata=stt.SpeechMetadata( language="en-UK", format=stt.AudioFormats.WAV, codec=stt.AudioCodecs.PCM, @@ -178,7 +178,7 @@ async def test_pipeline_from_audio_stream_entity( sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, channel=stt.AudioChannels.CHANNEL_MONO, ), - audio_data(), + stt_stream=audio_data(), pipeline_id=pipeline_id, ) @@ -229,9 +229,9 @@ async def test_pipeline_from_audio_stream_no_stt( with pytest.raises(assist_pipeline.pipeline.PipelineRunValidationError): await assist_pipeline.async_pipeline_from_audio_stream( hass, - Context(), - events.append, - stt.SpeechMetadata( + context=Context(), + event_callback=events.append, + stt_metadata=stt.SpeechMetadata( language="en-UK", format=stt.AudioFormats.WAV, codec=stt.AudioCodecs.PCM, @@ -239,7 +239,7 @@ async def test_pipeline_from_audio_stream_no_stt( sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, channel=stt.AudioChannels.CHANNEL_MONO, ), - audio_data(), + stt_stream=audio_data(), pipeline_id=pipeline_id, ) @@ -268,9 +268,9 @@ async def test_pipeline_from_audio_stream_unknown_pipeline( with pytest.raises(assist_pipeline.PipelineNotFound): await assist_pipeline.async_pipeline_from_audio_stream( hass, - Context(), - events.append, - stt.SpeechMetadata( + context=Context(), + event_callback=events.append, + stt_metadata=stt.SpeechMetadata( language="en-UK", format=stt.AudioFormats.WAV, codec=stt.AudioCodecs.PCM, @@ -278,7 +278,7 @@ async def test_pipeline_from_audio_stream_unknown_pipeline( sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, channel=stt.AudioChannels.CHANNEL_MONO, ), - audio_data(), + stt_stream=audio_data(), pipeline_id="blah", ) @@ -308,26 +308,38 @@ async def test_pipeline_from_audio_stream_wake_word( yield b"wake word" yield b"part1" yield b"part2" + yield b"end" yield b"" - await assist_pipeline.async_pipeline_from_audio_stream( - hass, - Context(), - events.append, - stt.SpeechMetadata( - language="", - format=stt.AudioFormats.WAV, - codec=stt.AudioCodecs.PCM, - bit_rate=stt.AudioBitRates.BITRATE_16, - sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, - channel=stt.AudioChannels.CHANNEL_MONO, - ), - audio_data(), - start_stage=assist_pipeline.PipelineStage.WAKE_WORD, - wake_word_settings=assist_pipeline.WakeWordSettings( - audio_seconds_to_buffer=1.5 - ), - ) + def continue_stt(self, chunk): + # Ensure stt_vad_start event is triggered + self.in_command = True + + # Stop on fake end chunk to trigger stt_vad_end + return chunk != b"end" + + with patch( + "homeassistant.components.assist_pipeline.pipeline.VoiceCommandSegmenter.process", + continue_stt, + ): + await assist_pipeline.async_pipeline_from_audio_stream( + hass, + context=Context(), + event_callback=events.append, + stt_metadata=stt.SpeechMetadata( + language="", + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=audio_data(), + start_stage=assist_pipeline.PipelineStage.WAKE_WORD, + wake_word_settings=assist_pipeline.WakeWordSettings( + audio_seconds_to_buffer=1.5 + ), + ) assert process_events(events) == snapshot diff --git a/tests/components/assist_pipeline/test_ring_buffer.py b/tests/components/assist_pipeline/test_ring_buffer.py new file mode 100644 index 00000000000..22185c3ad5b --- /dev/null +++ b/tests/components/assist_pipeline/test_ring_buffer.py @@ -0,0 +1,38 @@ +"""Tests for audio ring buffer.""" +from homeassistant.components.assist_pipeline.ring_buffer import RingBuffer + + +def test_ring_buffer_empty() -> None: + """Test empty ring buffer.""" + rb = RingBuffer(10) + assert rb.maxlen == 10 + assert rb.pos == 0 + assert rb.getvalue() == b"" + + +def test_ring_buffer_put_1() -> None: + """Test putting some data smaller than the maximum length.""" + rb = RingBuffer(10) + rb.put(bytes([1, 2, 3, 4, 5])) + assert len(rb) == 5 + assert rb.pos == 5 + assert rb.getvalue() == bytes([1, 2, 3, 4, 5]) + + +def test_ring_buffer_put_2() -> None: + """Test putting some data past the end of the buffer.""" + rb = RingBuffer(10) + rb.put(bytes([1, 2, 3, 4, 5])) + rb.put(bytes([6, 7, 8, 9, 10, 11, 12])) + assert len(rb) == 10 + assert rb.pos == 2 + assert rb.getvalue() == bytes([3, 4, 5, 6, 7, 8, 9, 10, 11, 12]) + + +def test_ring_buffer_put_too_large() -> None: + """Test putting data too large for the buffer.""" + rb = RingBuffer(10) + rb.put(bytes([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])) + assert len(rb) == 10 + assert rb.pos == 2 + assert rb.getvalue() == bytes([3, 4, 5, 6, 7, 8, 9, 10, 11, 12]) diff --git a/tests/components/assist_pipeline/test_vad.py b/tests/components/assist_pipeline/test_vad.py index 3a5c763ee5c..4dc8c8f6197 100644 --- a/tests/components/assist_pipeline/test_vad.py +++ b/tests/components/assist_pipeline/test_vad.py @@ -1,7 +1,12 @@ """Tests for webrtcvad voice command segmenter.""" +import itertools as it from unittest.mock import patch -from homeassistant.components.assist_pipeline.vad import VoiceCommandSegmenter +from homeassistant.components.assist_pipeline.vad import ( + AudioBuffer, + VoiceCommandSegmenter, + chunk_samples, +) _ONE_SECOND = 16000 * 2 # 16Khz 16-bit @@ -36,3 +41,87 @@ def test_speech() -> None: # silence # False return value indicates voice command is finished assert not segmenter.process(bytes(_ONE_SECOND)) + + +def test_audio_buffer() -> None: + """Test audio buffer wrapping.""" + + def is_speech(self, chunk, sample_rate): + """Disable VAD.""" + return False + + with patch( + "webrtcvad.Vad.is_speech", + new=is_speech, + ): + segmenter = VoiceCommandSegmenter() + bytes_per_chunk = segmenter.vad_samples_per_chunk * 2 + + with patch.object( + segmenter, "_process_chunk", return_value=True + ) as mock_process: + # Partially fill audio buffer + half_chunk = bytes(it.islice(it.cycle(range(256)), bytes_per_chunk // 2)) + segmenter.process(half_chunk) + + assert not mock_process.called + assert segmenter.audio_buffer == half_chunk + + # Fill and wrap with 1/4 chunk left over + three_quarters_chunk = bytes( + it.islice(it.cycle(range(256)), int(0.75 * bytes_per_chunk)) + ) + segmenter.process(three_quarters_chunk) + + assert mock_process.call_count == 1 + assert ( + segmenter.audio_buffer + == three_quarters_chunk[ + len(three_quarters_chunk) - (bytes_per_chunk // 4) : + ] + ) + assert ( + mock_process.call_args[0][0] + == half_chunk + three_quarters_chunk[: bytes_per_chunk // 2] + ) + + # Run 2 chunks through + segmenter.reset() + assert len(segmenter.audio_buffer) == 0 + + mock_process.reset_mock() + two_chunks = bytes(it.islice(it.cycle(range(256)), bytes_per_chunk * 2)) + segmenter.process(two_chunks) + + assert mock_process.call_count == 2 + assert len(segmenter.audio_buffer) == 0 + assert mock_process.call_args_list[0][0][0] == two_chunks[:bytes_per_chunk] + assert mock_process.call_args_list[1][0][0] == two_chunks[bytes_per_chunk:] + + +def test_partial_chunk() -> None: + """Test that chunk_samples returns when given a partial chunk.""" + bytes_per_chunk = 5 + samples = bytes([1, 2, 3]) + leftover_chunk_buffer = AudioBuffer(bytes_per_chunk) + chunks = list(chunk_samples(samples, bytes_per_chunk, leftover_chunk_buffer)) + + assert len(chunks) == 0 + assert leftover_chunk_buffer.bytes() == samples + + +def test_chunk_samples_leftover() -> None: + """Test that chunk_samples property keeps left over bytes across calls.""" + bytes_per_chunk = 5 + samples = bytes([1, 2, 3, 4, 5, 6]) + leftover_chunk_buffer = AudioBuffer(bytes_per_chunk) + chunks = list(chunk_samples(samples, bytes_per_chunk, leftover_chunk_buffer)) + + assert len(chunks) == 1 + assert leftover_chunk_buffer.bytes() == bytes([6]) + + # Add some more to the chunk + chunks = list(chunk_samples(samples, bytes_per_chunk, leftover_chunk_buffer)) + + assert len(chunks) == 1 + assert leftover_chunk_buffer.bytes() == bytes([5, 6]) From 3a71e21d6a6551877eb529b0d8205242216146dd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 25 Aug 2023 19:47:13 +0200 Subject: [PATCH 113/124] Add and improve comments about staggering of event listeners (#99058) --- homeassistant/helpers/event.py | 6 +++++- tests/common.py | 12 +++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index e615a6422f0..daad994bbd4 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -68,6 +68,10 @@ _ENTITIES_LISTENER = "entities" _LOGGER = logging.getLogger(__name__) +# Used to spread async_track_utc_time_change listeners and DataUpdateCoordinator +# refresh cycles between RANDOM_MICROSECOND_MIN..RANDOM_MICROSECOND_MAX. +# The values have been determined experimentally in production testing, background +# in PR https://github.com/home-assistant/core/pull/82233 RANDOM_MICROSECOND_MIN = 50000 RANDOM_MICROSECOND_MAX = 500000 @@ -1640,7 +1644,7 @@ def async_track_utc_time_change( matching_seconds = dt_util.parse_time_expression(second, 0, 59) matching_minutes = dt_util.parse_time_expression(minute, 0, 59) matching_hours = dt_util.parse_time_expression(hour, 0, 23) - # Avoid aligning all time trackers to the same second + # Avoid aligning all time trackers to the same fraction of a second # since it can create a thundering herd problem # https://github.com/home-assistant/core/issues/82231 microsecond = randint(RANDOM_MICROSECOND_MIN, RANDOM_MICROSECOND_MAX) diff --git a/tests/common.py b/tests/common.py index 0b63a9a2ef6..6ee38b72532 100644 --- a/tests/common.py +++ b/tests/common.py @@ -59,6 +59,7 @@ from homeassistant.helpers import ( entity, entity_platform, entity_registry as er, + event, intent, issue_registry as ir, recorder as recorder_helper, @@ -397,9 +398,10 @@ def async_fire_time_changed( ) -> None: """Fire a time changed event. - This function will add up to 0.5 seconds to the time to ensure that - it accounts for the accidental synchronization avoidance code in repeating - listeners. + If called within the first 500 ms of a second, time will be bumped to exactly + 500 ms to match the async_track_utc_time_change event listeners and + DataUpdateCoordinator which spreads all updates between 0.05..0.50. + Background in PR https://github.com/home-assistant/core/pull/82233 As asyncio is cooperative, we can't guarantee that the event loop will run an event at the exact time we want. If you need to fire time changed @@ -410,12 +412,12 @@ def async_fire_time_changed( else: utc_datetime = dt_util.as_utc(datetime_) - if utc_datetime.microsecond < 500000: + if utc_datetime.microsecond < event.RANDOM_MICROSECOND_MAX: # Allow up to 500000 microseconds to be added to the time # to handle update_coordinator's and # async_track_time_interval's # staggering to avoid thundering herd. - utc_datetime = utc_datetime.replace(microsecond=500000) + utc_datetime = utc_datetime.replace(microsecond=event.RANDOM_MICROSECOND_MAX) _async_fire_time_changed(hass, utc_datetime, fire_all) From 57144a6064db0ff97cdc536580a329381bc3b1d3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 25 Aug 2023 20:12:21 +0200 Subject: [PATCH 114/124] Use entity descriptions in Switcher (#98958) --- .../components/switcher_kis/sensor.py | 75 +++++++------------ 1 file changed, 29 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/switcher_kis/sensor.py b/homeassistant/components/switcher_kis/sensor.py index 2c74f14cb5c..0b6263a6b2e 100644 --- a/homeassistant/components/switcher_kis/sensor.py +++ b/homeassistant/components/switcher_kis/sensor.py @@ -1,19 +1,19 @@ """Switcher integration Sensor platform.""" from __future__ import annotations -from dataclasses import dataclass - from aioswitcher.device import DeviceCategory from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfElectricCurrent, UnitOfPower from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -22,48 +22,36 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import SwitcherDataUpdateCoordinator from .const import SIGNAL_DEVICE_ADD - -@dataclass -class AttributeDescription: - """Class to describe a sensor.""" - - name: str - icon: str | None = None - unit: str | None = None - device_class: SensorDeviceClass | None = None - state_class: SensorStateClass | None = None - default_enabled: bool = True - - -POWER_SENSORS = { - "power_consumption": AttributeDescription( +POWER_SENSORS: list[SensorEntityDescription] = [ + SensorEntityDescription( + key="power_consumption", name="Power Consumption", - unit=UnitOfPower.WATT, + native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - "electric_current": AttributeDescription( + SensorEntityDescription( + key="electric_current", name="Electric Current", - unit=UnitOfElectricCurrent.AMPERE, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, ), -} - -TIME_SENSORS = { - "remaining_time": AttributeDescription( - name="Remaining Time", - icon="mdi:av-timer", +] +TIME_SENSORS: list[SensorEntityDescription] = [ + SensorEntityDescription( + key="remaining_time", name="Remaining Time", icon="mdi:av-timer" ), - "auto_off_set": AttributeDescription( + SensorEntityDescription( + key="auto_off_set", name="Auto Shutdown", icon="mdi:progress-clock", - default_enabled=False, + entity_registry_enabled_default=False, ), -} +] POWER_PLUG_SENSORS = POWER_SENSORS -WATER_HEATER_SENSORS = {**POWER_SENSORS, **TIME_SENSORS} +WATER_HEATER_SENSORS = [*POWER_SENSORS, *TIME_SENSORS] async def async_setup_entry( @@ -78,13 +66,13 @@ async def async_setup_entry( """Add sensors from Switcher device.""" if coordinator.data.device_type.category == DeviceCategory.POWER_PLUG: async_add_entities( - SwitcherSensorEntity(coordinator, attribute, info) - for attribute, info in POWER_PLUG_SENSORS.items() + SwitcherSensorEntity(coordinator, description) + for description in POWER_PLUG_SENSORS ) elif coordinator.data.device_type.category == DeviceCategory.WATER_HEATER: async_add_entities( - SwitcherSensorEntity(coordinator, attribute, info) - for attribute, info in WATER_HEATER_SENSORS.items() + SwitcherSensorEntity(coordinator, description) + for description in WATER_HEATER_SENSORS ) config_entry.async_on_unload( @@ -100,28 +88,23 @@ class SwitcherSensorEntity( def __init__( self, coordinator: SwitcherDataUpdateCoordinator, - attribute: str, - description: AttributeDescription, + description: SensorEntityDescription, ) -> None: """Initialize the entity.""" super().__init__(coordinator) - self.attribute = attribute + self.entity_description = description # Entity class attributes self._attr_name = f"{coordinator.name} {description.name}" - self._attr_icon = description.icon - self._attr_native_unit_of_measurement = description.unit - self._attr_device_class = description.device_class - self._attr_entity_registry_enabled_default = description.default_enabled self._attr_unique_id = ( - f"{coordinator.device_id}-{coordinator.mac_address}-{attribute}" + f"{coordinator.device_id}-{coordinator.mac_address}-{description.key}" + ) + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, coordinator.mac_address)} ) - self._attr_device_info = { - "connections": {(dr.CONNECTION_NETWORK_MAC, coordinator.mac_address)} - } @property def native_value(self) -> StateType: """Return value of sensor.""" - return getattr(self.coordinator.data, self.attribute) # type: ignore[no-any-return] + return getattr(self.coordinator.data, self.entity_description.key) # type: ignore[no-any-return] From 544d6b05a5fe289c099840efd9f0f5d2fcc42c0a Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 25 Aug 2023 22:54:55 +0200 Subject: [PATCH 115/124] Replace mock_coro with AsyncMock (#99014) * Replace mock_coro with AsyncMock * Remove mock_coro test helper function * Remove redundant AsyncMock --- tests/common.py | 10 ---------- tests/components/ecobee/test_config_flow.py | 8 +++----- tests/components/ios/test_init.py | 10 ++++++---- tests/components/logi_circle/test_config_flow.py | 6 ++++-- tests/components/mill/test_init.py | 6 ++++-- tests/components/mobile_app/test_http_api.py | 3 +-- tests/components/onboarding/test_init.py | 3 +-- tests/components/owntracks/test_device_tracker.py | 8 ++++---- tests/components/spaceapi/test_init.py | 4 +--- tests/components/spc/test_init.py | 8 +++----- tests/components/syncthru/test_config_flow.py | 4 ++-- tests/components/websocket_api/test_auth.py | 2 -- tests/components/zha/test_button.py | 6 ++---- tests/components/zha/test_device_action.py | 4 ++-- tests/components/zha/test_number.py | 4 +--- tests/components/zha/test_siren.py | 8 ++++---- tests/test_bootstrap.py | 3 +-- tests/test_config_entries.py | 6 ++++-- 18 files changed, 43 insertions(+), 60 deletions(-) diff --git a/tests/common.py b/tests/common.py index 6ee38b72532..6ccb804ee73 100644 --- a/tests/common.py +++ b/tests/common.py @@ -965,16 +965,6 @@ def patch_yaml_files(files_dict, endswith=True): return patch.object(yaml_loader, "open", mock_open_f, create=True) -def mock_coro(return_value=None, exception=None): - """Return a coro that returns a value or raise an exception.""" - fut = asyncio.Future() - if exception is not None: - fut.set_exception(exception) - else: - fut.set_result(return_value) - return fut - - @contextmanager def assert_setup_component(count, domain=None): """Collect valid configuration from setup_component. diff --git a/tests/components/ecobee/test_config_flow.py b/tests/components/ecobee/test_config_flow.py index a4185313f5f..7d79a10e912 100644 --- a/tests/components/ecobee/test_config_flow.py +++ b/tests/components/ecobee/test_config_flow.py @@ -14,7 +14,7 @@ from homeassistant.components.ecobee.const import ( from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, mock_coro +from tests.common import MockConfigEntry async def test_abort_if_already_setup(hass: HomeAssistant) -> None: @@ -175,9 +175,7 @@ async def test_import_flow_triggered_with_ecobee_conf_and_invalid_data( with patch( "homeassistant.components.ecobee.config_flow.load_json_object", return_value=MOCK_ECOBEE_CONF, - ), patch.object( - flow, "async_step_user", return_value=mock_coro() - ) as mock_async_step_user: + ), patch.object(flow, "async_step_user") as mock_async_step_user: await flow.async_step_import(import_data=None) mock_async_step_user.assert_called_once_with( @@ -201,7 +199,7 @@ async def test_import_flow_triggered_with_ecobee_conf_and_valid_data_and_stale_t ), patch( "homeassistant.components.ecobee.config_flow.Ecobee" ) as mock_ecobee, patch.object( - flow, "async_step_user", return_value=mock_coro() + flow, "async_step_user" ) as mock_async_step_user: mock_ecobee = mock_ecobee.return_value mock_ecobee.refresh_tokens.return_value = False diff --git a/tests/components/ios/test_init.py b/tests/components/ios/test_init.py index 67c8bbde2cc..9586bd3c011 100644 --- a/tests/components/ios/test_init.py +++ b/tests/components/ios/test_init.py @@ -7,7 +7,7 @@ from homeassistant.components import ios from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import mock_component, mock_coro +from tests.common import mock_component @pytest.fixture(autouse=True) @@ -28,7 +28,7 @@ async def test_creating_entry_sets_up_sensor(hass: HomeAssistant) -> None: """Test setting up iOS loads the sensor component.""" with patch( "homeassistant.components.ios.sensor.async_setup_entry", - return_value=mock_coro(True), + return_value=True, ) as mock_setup: assert await async_setup_component(hass, ios.DOMAIN, {ios.DOMAIN: {}}) await hass.async_block_till_done() @@ -39,7 +39,8 @@ async def test_creating_entry_sets_up_sensor(hass: HomeAssistant) -> None: async def test_configuring_ios_creates_entry(hass: HomeAssistant) -> None: """Test that specifying config will create an entry.""" with patch( - "homeassistant.components.ios.async_setup_entry", return_value=mock_coro(True) + "homeassistant.components.ios.async_setup_entry", + return_value=True, ) as mock_setup: await async_setup_component(hass, ios.DOMAIN, {"ios": {"push": {}}}) await hass.async_block_till_done() @@ -50,7 +51,8 @@ async def test_configuring_ios_creates_entry(hass: HomeAssistant) -> None: async def test_not_configuring_ios_not_creates_entry(hass: HomeAssistant) -> None: """Test that no config will not create an entry.""" with patch( - "homeassistant.components.ios.async_setup_entry", return_value=mock_coro(True) + "homeassistant.components.ios.async_setup_entry", + return_value=True, ) as mock_setup: await async_setup_component(hass, ios.DOMAIN, {"foo": "bar"}) await hass.async_block_till_done() diff --git a/tests/components/logi_circle/test_config_flow.py b/tests/components/logi_circle/test_config_flow.py index 885459a5df2..de4a9bd4da4 100644 --- a/tests/components/logi_circle/test_config_flow.py +++ b/tests/components/logi_circle/test_config_flow.py @@ -15,7 +15,7 @@ from homeassistant.components.logi_circle.config_flow import ( from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, mock_coro +from tests.common import MockConfigEntry class MockRequest: @@ -50,10 +50,12 @@ def mock_logi_circle(): with patch( "homeassistant.components.logi_circle.config_flow.LogiCircle" ) as logi_circle: + future = asyncio.Future() + future.set_result({"accountId": "testId"}) LogiCircle = logi_circle() LogiCircle.authorize = AsyncMock(return_value=True) LogiCircle.close = AsyncMock(return_value=True) - LogiCircle.account = mock_coro(return_value={"accountId": "testId"}) + LogiCircle.account = future LogiCircle.authorize_url = "http://authorize.url" yield LogiCircle diff --git a/tests/components/mill/test_init.py b/tests/components/mill/test_init.py index 2c17a2d7550..694e9537a8c 100644 --- a/tests/components/mill/test_init.py +++ b/tests/components/mill/test_init.py @@ -6,7 +6,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, mock_coro +from tests.common import MockConfigEntry async def test_setup_with_cloud_config(hass: HomeAssistant) -> None: @@ -109,7 +109,9 @@ async def test_unload_entry(hass: HomeAssistant) -> None: entry.add_to_hass(hass) with patch.object( - hass.config_entries, "async_forward_entry_unload", return_value=mock_coro(True) + hass.config_entries, + "async_forward_entry_unload", + return_value=True, ) as unload_entry, patch( "mill.Mill.fetch_heater_and_sensor_data", return_value={} ), patch( diff --git a/tests/components/mobile_app/test_http_api.py b/tests/components/mobile_app/test_http_api.py index 4b9169b48db..28a8a26657a 100644 --- a/tests/components/mobile_app/test_http_api.py +++ b/tests/components/mobile_app/test_http_api.py @@ -13,7 +13,7 @@ from homeassistant.setup import async_setup_component from .const import REGISTER, REGISTER_CLEARTEXT, RENDER_TEMPLATE -from tests.common import MockUser, mock_coro +from tests.common import MockUser from tests.typing import ClientSessionGenerator @@ -28,7 +28,6 @@ async def test_registration( with patch( "homeassistant.components.person.async_add_user_device_tracker", spec=True, - return_value=mock_coro(), ) as add_user_dev_track: resp = await api_client.post( "/api/mobile_app/registrations", json=REGISTER_CLEARTEXT diff --git a/tests/components/onboarding/test_init.py b/tests/components/onboarding/test_init.py index 0f7dc8d242b..bcaa9ad611f 100644 --- a/tests/components/onboarding/test_init.py +++ b/tests/components/onboarding/test_init.py @@ -8,7 +8,7 @@ from homeassistant.setup import async_setup_component from . import mock_storage -from tests.common import MockUser, mock_coro +from tests.common import MockUser # Temporarily: if auth not active, always set onboarded=True @@ -31,7 +31,6 @@ async def test_setup_views_if_not_onboarded(hass: HomeAssistant) -> None: """Test if onboarding is not done, we setup views.""" with patch( "homeassistant.components.onboarding.views.async_setup", - return_value=mock_coro(), ) as mock_setup: assert await async_setup_component(hass, "onboarding", {}) diff --git a/tests/components/owntracks/test_device_tracker.py b/tests/components/owntracks/test_device_tracker.py index 41c3b7f058d..1be21e8b1b2 100644 --- a/tests/components/owntracks/test_device_tracker.py +++ b/tests/components/owntracks/test_device_tracker.py @@ -9,7 +9,7 @@ from homeassistant.const import STATE_NOT_HOME from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, async_fire_mqtt_message, mock_coro +from tests.common import MockConfigEntry, async_fire_mqtt_message from tests.typing import ClientSessionGenerator USER = "greg" @@ -1303,7 +1303,7 @@ async def test_not_implemented_message(hass: HomeAssistant, context) -> None: """Handle not implemented message type.""" patch_handler = patch( "homeassistant.components.owntracks.messages.async_handle_not_impl_msg", - return_value=mock_coro(False), + return_value=False, ) patch_handler.start() assert not await send_message(hass, LWT_TOPIC, LWT_MESSAGE) @@ -1314,7 +1314,7 @@ async def test_unsupported_message(hass: HomeAssistant, context) -> None: """Handle not implemented message type.""" patch_handler = patch( "homeassistant.components.owntracks.messages.async_handle_unsupported_msg", - return_value=mock_coro(False), + return_value=False, ) patch_handler.start() assert not await send_message(hass, BAD_TOPIC, BAD_MESSAGE) @@ -1393,7 +1393,7 @@ def config_context(hass, setup_comp): """Set up the mocked context.""" patch_load = patch( "homeassistant.components.device_tracker.async_load_config", - return_value=mock_coro([]), + return_value=[], ) patch_load.start() diff --git a/tests/components/spaceapi/test_init.py b/tests/components/spaceapi/test_init.py index d2f81ac18dc..ac892eeb2d8 100644 --- a/tests/components/spaceapi/test_init.py +++ b/tests/components/spaceapi/test_init.py @@ -9,8 +9,6 @@ from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, UnitOfTemp from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import mock_coro - CONFIG = { DOMAIN: { "space": "Home", @@ -83,7 +81,7 @@ SENSOR_OUTPUT = { @pytest.fixture def mock_client(hass, hass_client): """Start the Home Assistant HTTP component.""" - with patch("homeassistant.components.spaceapi", return_value=mock_coro(True)): + with patch("homeassistant.components.spaceapi", return_value=True): hass.loop.run_until_complete(async_setup_component(hass, "spaceapi", CONFIG)) hass.states.async_set( diff --git a/tests/components/spc/test_init.py b/tests/components/spc/test_init.py index 7e4faa68e00..1972b7af5c8 100644 --- a/tests/components/spc/test_init.py +++ b/tests/components/spc/test_init.py @@ -6,8 +6,6 @@ from homeassistant.components.spc import DATA_API from homeassistant.const import STATE_ALARM_ARMED_AWAY, STATE_ALARM_DISARMED from homeassistant.core import HomeAssistant -from tests.common import mock_coro - async def test_valid_device_config(hass: HomeAssistant, monkeypatch) -> None: """Test valid device config.""" @@ -15,7 +13,7 @@ async def test_valid_device_config(hass: HomeAssistant, monkeypatch) -> None: with patch( "homeassistant.components.spc.SpcWebGateway.async_load_parameters", - return_value=mock_coro(True), + return_value=True, ): assert await async_setup_component(hass, "spc", config) is True @@ -26,7 +24,7 @@ async def test_invalid_device_config(hass: HomeAssistant, monkeypatch) -> None: with patch( "homeassistant.components.spc.SpcWebGateway.async_load_parameters", - return_value=mock_coro(True), + return_value=True, ): assert await async_setup_component(hass, "spc", config) is False @@ -53,7 +51,7 @@ async def test_update_alarm_device(hass: HomeAssistant) -> None: mock_areas.return_value = {"1": area_mock} with patch( "homeassistant.components.spc.SpcWebGateway.async_load_parameters", - return_value=mock_coro(True), + return_value=True, ): assert await async_setup_component(hass, "spc", config) is True diff --git a/tests/components/syncthru/test_config_flow.py b/tests/components/syncthru/test_config_flow.py index ae6172af6d8..948e55649fc 100644 --- a/tests/components/syncthru/test_config_flow.py +++ b/tests/components/syncthru/test_config_flow.py @@ -11,7 +11,7 @@ from homeassistant.components.syncthru.const import DOMAIN from homeassistant.const import CONF_NAME, CONF_URL from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, mock_coro +from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker FIXTURE_USER_INPUT = { @@ -90,7 +90,7 @@ async def test_syncthru_not_supported(hass: HomeAssistant) -> None: async def test_unknown_state(hass: HomeAssistant) -> None: """Test we show user form on unsupported device.""" - with patch.object(SyncThru, "update", return_value=mock_coro()), patch.object( + with patch.object(SyncThru, "update"), patch.object( SyncThru, "is_unknown_state", return_value=True ): result = await hass.config_entries.flow.async_init( diff --git a/tests/components/websocket_api/test_auth.py b/tests/components/websocket_api/test_auth.py index aba34aeb44b..d5ff879de78 100644 --- a/tests/components/websocket_api/test_auth.py +++ b/tests/components/websocket_api/test_auth.py @@ -23,7 +23,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.setup import async_setup_component -from tests.common import mock_coro from tests.typing import ClientSessionGenerator @@ -72,7 +71,6 @@ async def test_auth_via_msg_incorrect_pass(no_auth_websocket_client) -> None: """Test authenticating.""" with patch( "homeassistant.components.websocket_api.auth.process_wrong_login", - return_value=mock_coro(), ) as mock_process_wrong_login: await no_auth_websocket_client.send_json( {"type": TYPE_AUTH, "api_password": "wrong"} diff --git a/tests/components/zha/test_button.py b/tests/components/zha/test_button.py index 2a2fbc92ace..461e592ef85 100644 --- a/tests/components/zha/test_button.py +++ b/tests/components/zha/test_button.py @@ -35,8 +35,6 @@ from homeassistant.helpers import entity_registry as er from .common import find_entity_id from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE -from tests.common import mock_coro - @pytest.fixture(autouse=True) def button_platform_only(): @@ -151,7 +149,7 @@ async def test_button(hass: HomeAssistant, contact_sensor) -> None: with patch( "zigpy.zcl.Cluster.request", - return_value=mock_coro([0x00, zcl_f.Status.SUCCESS]), + return_value=[0x00, zcl_f.Status.SUCCESS], ): await hass.services.async_call( DOMAIN, @@ -191,7 +189,7 @@ async def test_frost_unlock(hass: HomeAssistant, tuya_water_valve) -> None: with patch( "zigpy.zcl.Cluster.request", - return_value=mock_coro([0x00, zcl_f.Status.SUCCESS]), + return_value=[0x00, zcl_f.Status.SUCCESS], ): await hass.services.async_call( DOMAIN, diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py index 9c44a0d08b5..31ffe9449e2 100644 --- a/tests/components/zha/test_device_action.py +++ b/tests/components/zha/test_device_action.py @@ -19,7 +19,7 @@ from homeassistant.setup import async_setup_component from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE -from tests.common import async_get_device_automations, async_mock_service, mock_coro +from tests.common import async_get_device_automations, async_mock_service @pytest.fixture(autouse=True, name="stub_blueprint_populate") @@ -274,7 +274,7 @@ async def test_action(hass: HomeAssistant, device_ias, device_inovelli) -> None: with patch( "zigpy.zcl.Cluster.request", - return_value=mock_coro([0x00, zcl_f.Status.SUCCESS]), + return_value=[0x00, zcl_f.Status.SUCCESS], ): assert await async_setup_component( hass, diff --git a/tests/components/zha/test_number.py b/tests/components/zha/test_number.py index 60aa355af5f..67770efd591 100644 --- a/tests/components/zha/test_number.py +++ b/tests/components/zha/test_number.py @@ -23,8 +23,6 @@ from .common import ( ) from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE -from tests.common import mock_coro - @pytest.fixture(autouse=True) def number_platform_only(): @@ -153,7 +151,7 @@ async def test_number( # change value from HA with patch( "zigpy.zcl.Cluster.write_attributes", - return_value=mock_coro([zcl_f.Status.SUCCESS, zcl_f.Status.SUCCESS]), + return_value=[zcl_f.Status.SUCCESS, zcl_f.Status.SUCCESS], ): # set value via UI await hass.services.async_call( diff --git a/tests/components/zha/test_siren.py b/tests/components/zha/test_siren.py index 2df6c2be5db..b953d833330 100644 --- a/tests/components/zha/test_siren.py +++ b/tests/components/zha/test_siren.py @@ -27,7 +27,7 @@ import homeassistant.util.dt as dt_util from .common import async_enable_traffic, find_entity_id from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE -from tests.common import async_fire_time_changed, mock_coro +from tests.common import async_fire_time_changed @pytest.fixture(autouse=True) @@ -87,7 +87,7 @@ async def test_siren(hass: HomeAssistant, siren) -> None: # turn on from HA with patch( "zigpy.device.Device.request", - return_value=mock_coro([0x00, zcl_f.Status.SUCCESS]), + return_value=[0x00, zcl_f.Status.SUCCESS], ), patch( "zigpy.zcl.Cluster.request", side_effect=zigpy.zcl.Cluster.request, @@ -119,7 +119,7 @@ async def test_siren(hass: HomeAssistant, siren) -> None: # turn off from HA with patch( "zigpy.device.Device.request", - return_value=mock_coro([0x01, zcl_f.Status.SUCCESS]), + return_value=[0x01, zcl_f.Status.SUCCESS], ), patch( "zigpy.zcl.Cluster.request", side_effect=zigpy.zcl.Cluster.request, @@ -151,7 +151,7 @@ async def test_siren(hass: HomeAssistant, siren) -> None: # turn on from HA with patch( "zigpy.device.Device.request", - return_value=mock_coro([0x00, zcl_f.Status.SUCCESS]), + return_value=[0x00, zcl_f.Status.SUCCESS], ), patch( "zigpy.zcl.Cluster.request", side_effect=zigpy.zcl.Cluster.request, diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 26eef47273f..ea9e04ac993 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -23,7 +23,6 @@ from .common import ( MockModule, MockPlatform, get_test_config_dir, - mock_coro, mock_entity_platform, mock_integration, ) @@ -110,7 +109,7 @@ async def test_core_failure_loads_safe_mode( """Test failing core setup aborts further setup.""" with patch( "homeassistant.components.homeassistant.async_setup", - return_value=mock_coro(False), + return_value=False, ): await bootstrap.async_from_config_dict({"group": {}}, hass) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 75b6377973b..760c7138c88 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -40,7 +40,6 @@ from .common import ( MockPlatform, async_fire_time_changed, mock_config_flow, - mock_coro, mock_entity_platform, mock_integration, ) @@ -605,7 +604,10 @@ async def test_domains_gets_domains_excludes_ignore_and_disabled( async def test_saving_and_loading(hass: HomeAssistant) -> None: """Test that we're saving and loading correctly.""" mock_integration( - hass, MockModule("test", async_setup_entry=lambda *args: mock_coro(True)) + hass, + MockModule( + "test", async_setup_entry=lambda *args: AsyncMock(return_value=True) + ), ) mock_entity_platform(hass, "config_flow.test", None) From 8d9c5a61ec09a458bdccc8dc37a4648ad918f1d8 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 25 Aug 2023 18:32:20 -0700 Subject: [PATCH 116/124] Update calendar handle state updates at start/end of active/upcoming event (#98037) * Update calendar handle state updates at start/end of active/upcoming event * Use async_write_ha_state intercept state updates Remove unrelated changes and whitespace. * Revert unnecessary changes * Move demo calendar to config entries to cleanup event timers * Fix docs on calendars * Move method inside from PR feedback --- homeassistant/components/calendar/__init__.py | 47 +++++++++++++ homeassistant/components/demo/__init__.py | 2 +- homeassistant/components/demo/calendar.py | 15 ++--- homeassistant/components/google/calendar.py | 66 ++++++------------- 4 files changed, 75 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index c85f0d2bff1..e487569453f 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -20,10 +20,12 @@ from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import ( + CALLBACK_TYPE, HomeAssistant, ServiceCall, ServiceResponse, SupportsResponse, + callback, ) from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv @@ -34,6 +36,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 ) from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.event import async_track_point_in_time from homeassistant.helpers.template import DATE_STR_FORMAT from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util @@ -478,6 +481,8 @@ def is_offset_reached( class CalendarEntity(Entity): """Base class for calendar event entities.""" + _alarm_unsubs: list[CALLBACK_TYPE] = [] + @property def event(self) -> CalendarEvent | None: """Return the next upcoming event.""" @@ -513,6 +518,48 @@ class CalendarEntity(Entity): return STATE_OFF + @callback + def async_write_ha_state(self) -> None: + """Write the state to the state machine. + + This sets up listeners to handle state transitions for start or end of + the current or upcoming event. + """ + super().async_write_ha_state() + + for unsub in self._alarm_unsubs: + unsub() + + now = dt_util.now() + event = self.event + if event is None or now >= event.end_datetime_local: + return + + @callback + def update(_: datetime.datetime) -> None: + """Run when the active or upcoming event starts or ends.""" + self._async_write_ha_state() + + if now < event.start_datetime_local: + self._alarm_unsubs.append( + async_track_point_in_time( + self.hass, + update, + event.start_datetime_local, + ) + ) + self._alarm_unsubs.append( + async_track_point_in_time(self.hass, update, event.end_datetime_local) + ) + + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass. + + To be extended by integrations. + """ + for unsub in self._alarm_unsubs: + unsub() + async def async_get_events( self, hass: HomeAssistant, diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index 04eba5f0586..b40e1ede232 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -26,6 +26,7 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CAMERA, + Platform.CALENDAR, Platform.CLIMATE, Platform.COVER, Platform.DATE, @@ -54,7 +55,6 @@ COMPONENTS_WITH_DEMO_PLATFORM = [ Platform.MAILBOX, Platform.NOTIFY, Platform.IMAGE_PROCESSING, - Platform.CALENDAR, Platform.DEVICE_TRACKER, Platform.WEATHER, ] diff --git a/homeassistant/components/demo/calendar.py b/homeassistant/components/demo/calendar.py index 73b45a55640..b4200f1be89 100644 --- a/homeassistant/components/demo/calendar.py +++ b/homeassistant/components/demo/calendar.py @@ -1,23 +1,22 @@ -"""Demo platform that has two fake binary sensors.""" +"""Demo platform that has two fake calendars.""" from __future__ import annotations import datetime from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the Demo Calendar platform.""" - add_entities( + """Set up the Demo Calendar config entry.""" + async_add_entities( [ DemoCalendar(calendar_data_future(), "Calendar 1"), DemoCalendar(calendar_data_current(), "Calendar 2"), diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 347e8444946..9559a06d49c 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -36,7 +36,7 @@ from homeassistant.components.calendar import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITIES, CONF_NAME, CONF_OFFSET -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.helpers import entity_platform, entity_registry as er from homeassistant.helpers.entity import generate_entity_id @@ -383,7 +383,6 @@ class GoogleCalendarEntity( self._event: CalendarEvent | None = None self._attr_name = data[CONF_NAME].capitalize() self._offset = data.get(CONF_OFFSET, DEFAULT_CONF_OFFSET) - self._offset_value: timedelta | None = None self.entity_id = entity_id self._attr_unique_id = unique_id self._attr_entity_registry_enabled_default = entity_enabled @@ -392,17 +391,6 @@ class GoogleCalendarEntity( CalendarEntityFeature.CREATE_EVENT | CalendarEntityFeature.DELETE_EVENT ) - @property - def should_poll(self) -> bool: - """Enable polling for the entity. - - The coordinator is not used by multiple entities, but instead - is used to poll the calendar API at a separate interval from the - entity state updates itself which happen more frequently (e.g. to - fire an alarm when the next event starts). - """ - return True - @property def extra_state_attributes(self) -> dict[str, bool]: """Return the device state attributes.""" @@ -411,16 +399,16 @@ class GoogleCalendarEntity( @property def offset_reached(self) -> bool: """Return whether or not the event offset was reached.""" - if self._event and self._offset_value: - return is_offset_reached( - self._event.start_datetime_local, self._offset_value - ) + (event, offset_value) = self._event_with_offset() + if event is not None and offset_value is not None: + return is_offset_reached(event.start_datetime_local, offset_value) return False @property def event(self) -> CalendarEvent | None: """Return the next upcoming event.""" - return self._event + (event, _) = self._event_with_offset() + return event def _event_filter(self, event: Event) -> bool: """Return True if the event is visible.""" @@ -435,12 +423,10 @@ class GoogleCalendarEntity( # We do not ask for an update with async_add_entities() # because it will update disabled entities. This is started as a # task to let if sync in the background without blocking startup - async def refresh() -> None: - await self.coordinator.async_request_refresh() - self._apply_coordinator_update() - self.coordinator.config_entry.async_create_background_task( - self.hass, refresh(), "google.calendar-refresh" + self.hass, + self.coordinator.async_request_refresh(), + "google.calendar-refresh", ) async def async_get_events( @@ -453,8 +439,10 @@ class GoogleCalendarEntity( for event in filter(self._event_filter, result_items) ] - def _apply_coordinator_update(self) -> None: - """Copy state from the coordinator to this entity.""" + def _event_with_offset( + self, + ) -> tuple[CalendarEvent | None, timedelta | None]: + """Get the calendar event and offset if any.""" if api_event := next( filter( self._event_filter, @@ -462,27 +450,13 @@ class GoogleCalendarEntity( ), None, ): - self._event = _get_calendar_event(api_event) - (self._event.summary, self._offset_value) = extract_offset( - self._event.summary, self._offset - ) - else: - self._event = None - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - self._apply_coordinator_update() - super()._handle_coordinator_update() - - async def async_update(self) -> None: - """Disable update behavior. - - This relies on the coordinator callback update to write home assistant - state with the next calendar event. This update is a no-op as no new data - fetch is needed to evaluate the state to determine if the next event has - started, handled by CalendarEntity parent class. - """ + event = _get_calendar_event(api_event) + if self._offset: + (event.summary, offset_value) = extract_offset( + event.summary, self._offset + ) + return event, offset_value + return None, None async def async_create_event(self, **kwargs: Any) -> None: """Add a new event to calendar.""" From 6f43dd1c140682915ce9ada107d129d650e702bc Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 26 Aug 2023 07:35:10 +0200 Subject: [PATCH 117/124] Adjust netatmo test (#99071) --- tests/components/netatmo/fixtures/getpublicdata.json | 2 +- tests/components/netatmo/test_sensor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/netatmo/fixtures/getpublicdata.json b/tests/components/netatmo/fixtures/getpublicdata.json index cf2ec3c66cb..622e7f962f1 100644 --- a/tests/components/netatmo/fixtures/getpublicdata.json +++ b/tests/components/netatmo/fixtures/getpublicdata.json @@ -91,7 +91,7 @@ }, "70:ee:50:27:25:b0": { "res": { - "1560247907": [1012.8] + "1560247907": [1012.9] }, "type": ["pressure"] }, diff --git a/tests/components/netatmo/test_sensor.py b/tests/components/netatmo/test_sensor.py index 5c04f0d2fc7..00cec6f8aa0 100644 --- a/tests/components/netatmo/test_sensor.py +++ b/tests/components/netatmo/test_sensor.py @@ -46,7 +46,7 @@ async def test_public_weather_sensor( assert hass.states.get(f"{prefix}temperature").state == "22.7" assert hass.states.get(f"{prefix}humidity").state == "63.2" - assert hass.states.get(f"{prefix}pressure").state == "1010.3" + assert hass.states.get(f"{prefix}pressure").state == "1010.4" entities_before_change = len(hass.states.async_all()) From d74a0fd6dd2eac147e35da28aa561fc6eeebee4a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 26 Aug 2023 09:11:42 +0200 Subject: [PATCH 118/124] Use freezegun in additional fronius tests (#99066) --- tests/components/fronius/__init__.py | 6 +- tests/components/fronius/test_sensor.py | 87 +++++++++++++++++-------- 2 files changed, 63 insertions(+), 30 deletions(-) diff --git a/tests/components/fronius/__init__.py b/tests/components/fronius/__init__.py index 4d11291508b..5a757da1e9c 100644 --- a/tests/components/fronius/__init__.py +++ b/tests/components/fronius/__init__.py @@ -6,7 +6,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker @@ -85,7 +84,7 @@ def mock_responses( ) -async def enable_all_entities(hass, config_entry_id, time_till_next_update): +async def enable_all_entities(hass, freezer, config_entry_id, time_till_next_update): """Enable all entities for a config entry and fast forward time to receive data.""" registry = er.async_get(hass) entities = er.async_entries_for_config_entry(registry, config_entry_id) @@ -96,5 +95,6 @@ async def enable_all_entities(hass, config_entry_id, time_till_next_update): ]: registry.async_update_entity(entry.entity_id, **{"disabled_by": None}) await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + time_till_next_update) + freezer.tick(time_till_next_update) + async_fire_time_changed(hass) await hass.async_block_till_done() diff --git a/tests/components/fronius/test_sensor.py b/tests/components/fronius/test_sensor.py index 47b6410a146..c2e0c4ad969 100644 --- a/tests/components/fronius/test_sensor.py +++ b/tests/components/fronius/test_sensor.py @@ -1,4 +1,7 @@ """Tests for the Fronius sensor platform.""" + +from freezegun.api import FrozenDateTimeFactory + from homeassistant.components.fronius.const import DOMAIN from homeassistant.components.fronius.coordinator import ( FroniusInverterUpdateCoordinator, @@ -8,7 +11,6 @@ from homeassistant.components.fronius.coordinator import ( from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from homeassistant.util import dt as dt_util from . import enable_all_entities, mock_responses, setup_fronius_integration @@ -17,7 +19,9 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_symo_inverter( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, ) -> None: """Test Fronius Symo inverter entities.""" @@ -31,7 +35,10 @@ async def test_symo_inverter( assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 20 await enable_all_entities( - hass, config_entry.entry_id, FroniusInverterUpdateCoordinator.default_interval + hass, + freezer, + config_entry.entry_id, + FroniusInverterUpdateCoordinator.default_interval, ) assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 52 assert_state("sensor.symo_20_dc_current", 0) @@ -42,13 +49,15 @@ async def test_symo_inverter( # Second test at daytime when inverter is producing mock_responses(aioclient_mock, night=False) - async_fire_time_changed( - hass, dt_util.utcnow() + FroniusInverterUpdateCoordinator.default_interval - ) + freezer.tick(FroniusInverterUpdateCoordinator.default_interval) + async_fire_time_changed(hass) await hass.async_block_till_done() assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 56 await enable_all_entities( - hass, config_entry.entry_id, FroniusInverterUpdateCoordinator.default_interval + hass, + freezer, + config_entry.entry_id, + FroniusInverterUpdateCoordinator.default_interval, ) assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 58 # 4 additional AC entities @@ -64,9 +73,8 @@ async def test_symo_inverter( # Third test at nighttime - additional AC entities default to 0 mock_responses(aioclient_mock, night=True) - async_fire_time_changed( - hass, dt_util.utcnow() + FroniusInverterUpdateCoordinator.default_interval - ) + freezer.tick(FroniusInverterUpdateCoordinator.default_interval) + async_fire_time_changed(hass) await hass.async_block_till_done() assert_state("sensor.symo_20_ac_current", 0) assert_state("sensor.symo_20_frequency", 0) @@ -94,7 +102,9 @@ async def test_symo_logger( async def test_symo_meter( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, ) -> None: """Test Fronius Symo meter entities.""" @@ -108,7 +118,10 @@ async def test_symo_meter( assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 24 await enable_all_entities( - hass, config_entry.entry_id, FroniusMeterUpdateCoordinator.default_interval + hass, + freezer, + config_entry.entry_id, + FroniusMeterUpdateCoordinator.default_interval, ) assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 58 # states are rounded to 4 decimals @@ -147,10 +160,12 @@ async def test_symo_meter( async def test_symo_power_flow( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, ) -> None: """Test Fronius Symo power flow entities.""" - async_fire_time_changed(hass, dt_util.utcnow()) + async_fire_time_changed(hass) def assert_state(entity_id, expected_state): state = hass.states.get(entity_id) @@ -162,7 +177,10 @@ async def test_symo_power_flow( assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 20 await enable_all_entities( - hass, config_entry.entry_id, FroniusInverterUpdateCoordinator.default_interval + hass, + freezer, + config_entry.entry_id, + FroniusInverterUpdateCoordinator.default_interval, ) assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 52 # states are rounded to 4 decimals @@ -175,9 +193,8 @@ async def test_symo_power_flow( # Second test at daytime when inverter is producing mock_responses(aioclient_mock, night=False) - async_fire_time_changed( - hass, dt_util.utcnow() + FroniusPowerFlowUpdateCoordinator.default_interval - ) + freezer.tick(FroniusPowerFlowUpdateCoordinator.default_interval) + async_fire_time_changed(hass) await hass.async_block_till_done() # 54 because power_flow `rel_SelfConsumption` and `P_PV` is not `null` anymore assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 54 @@ -192,9 +209,8 @@ async def test_symo_power_flow( # Third test at nighttime - default values are used mock_responses(aioclient_mock, night=True) - async_fire_time_changed( - hass, dt_util.utcnow() + FroniusPowerFlowUpdateCoordinator.default_interval - ) + freezer.tick(FroniusPowerFlowUpdateCoordinator.default_interval) + async_fire_time_changed(hass) await hass.async_block_till_done() assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 54 assert_state("sensor.solarnet_energy_day", 10828) @@ -207,7 +223,11 @@ async def test_symo_power_flow( assert_state("sensor.solarnet_relative_self_consumption", 0) -async def test_gen24(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +async def test_gen24( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, +) -> None: """Test Fronius Gen24 inverter entities.""" def assert_state(entity_id, expected_state): @@ -220,7 +240,10 @@ async def test_gen24(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 22 await enable_all_entities( - hass, config_entry.entry_id, FroniusMeterUpdateCoordinator.default_interval + hass, + freezer, + config_entry.entry_id, + FroniusMeterUpdateCoordinator.default_interval, ) assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 52 # inverter 1 @@ -281,7 +304,9 @@ async def test_gen24(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) - async def test_gen24_storage( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, ) -> None: """Test Fronius Gen24 inverter with BYD battery and Ohmpilot entities.""" @@ -297,7 +322,10 @@ async def test_gen24_storage( assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 34 await enable_all_entities( - hass, config_entry.entry_id, FroniusMeterUpdateCoordinator.default_interval + hass, + freezer, + config_entry.entry_id, + FroniusMeterUpdateCoordinator.default_interval, ) assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 64 # inverter 1 @@ -405,7 +433,9 @@ async def test_gen24_storage( async def test_primo_s0( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, ) -> None: """Test Fronius Primo dual inverter with S0 meter entities.""" @@ -419,7 +449,10 @@ async def test_primo_s0( assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 29 await enable_all_entities( - hass, config_entry.entry_id, FroniusMeterUpdateCoordinator.default_interval + hass, + freezer, + config_entry.entry_id, + FroniusMeterUpdateCoordinator.default_interval, ) assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 40 # logger From a25a7ebbeb85ba6a9675a3d5d64fc6e57025adcd Mon Sep 17 00:00:00 2001 From: tronikos Date: Sat, 26 Aug 2023 06:39:48 -0700 Subject: [PATCH 119/124] Bump opower to 0.0.32 (#99079) --- homeassistant/components/opower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index aff1ad2f599..fb4ff5153ec 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.0.31"] + "requirements": ["opower==0.0.32"] } diff --git a/requirements_all.txt b/requirements_all.txt index b915a38368f..29f5527746c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1368,7 +1368,7 @@ openwrt-luci-rpc==1.1.16 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.0.31 +opower==0.0.32 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a292cfb78ac..dcc4abbce29 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1034,7 +1034,7 @@ openerz-api==0.2.0 openhomedevice==2.2.0 # homeassistant.components.opower -opower==0.0.31 +opower==0.0.32 # homeassistant.components.oralb oralb-ble==0.17.6 From c287bd1a3be85678dddd14dcded5b97a9e212108 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Sat, 26 Aug 2023 17:46:03 +0300 Subject: [PATCH 120/124] Remove pylint configs flagged by useless-suppression (#99081) --- homeassistant/components/azure_service_bus/notify.py | 5 ----- homeassistant/components/discovergy/__init__.py | 2 +- homeassistant/components/http/__init__.py | 2 +- homeassistant/components/huawei_lte/utils.py | 2 +- homeassistant/components/integration/sensor.py | 1 - homeassistant/components/mqtt/models.py | 4 ++-- homeassistant/components/recorder/filters.py | 2 -- homeassistant/components/samsungtv/bridge.py | 1 - homeassistant/components/supla/__init__.py | 1 - 9 files changed, 5 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/azure_service_bus/notify.py b/homeassistant/components/azure_service_bus/notify.py index 23235a23dff..4005460ecae 100644 --- a/homeassistant/components/azure_service_bus/notify.py +++ b/homeassistant/components/azure_service_bus/notify.py @@ -4,13 +4,8 @@ from __future__ import annotations import json import logging -# pylint: disable-next=no-name-in-module from azure.servicebus import ServiceBusMessage - -# pylint: disable-next=no-name-in-module from azure.servicebus.aio import ServiceBusClient, ServiceBusSender - -# pylint: disable-next=no-name-in-module from azure.servicebus.exceptions import ( MessagingEntityNotFoundError, ServiceBusConnectionError, diff --git a/homeassistant/components/discovergy/__init__.py b/homeassistant/components/discovergy/__init__.py index fe1045203d8..ab892cd9324 100644 --- a/homeassistant/components/discovergy/__init__.py +++ b/homeassistant/components/discovergy/__init__.py @@ -51,7 +51,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: discovergy_data.meters = await discovergy_data.api_client.meters() except discovergyError.InvalidLogin as err: raise ConfigEntryAuthFailed("Invalid email or password") from err - except Exception as err: # pylint: disable=broad-except + except Exception as err: raise ConfigEntryNotReady( "Unexpected error while while getting meters" ) from err diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 68f68d7f558..409b78fb16a 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -318,7 +318,7 @@ class HomeAssistantHTTP: # By default aiohttp does a linear search for routing rules, # we have a lot of routes, so use a dict lookup with a fallback # to the linear search. - self.app._router = FastUrlDispatcher() # pylint: disable=protected-access + self.app._router = FastUrlDispatcher() self.hass = hass self.ssl_certificate = ssl_certificate self.ssl_peer_certificate = ssl_peer_certificate diff --git a/homeassistant/components/huawei_lte/utils.py b/homeassistant/components/huawei_lte/utils.py index ab787a97ea9..172e8658928 100644 --- a/homeassistant/components/huawei_lte/utils.py +++ b/homeassistant/components/huawei_lte/utils.py @@ -21,7 +21,7 @@ def get_device_macs( for x in ("MacAddress1", "MacAddress2", "WifiMacAddrWl0", "WifiMacAddrWl1") ] # Assume not supported when exception is thrown - with suppress(Exception): # pylint: disable=broad-except + with suppress(Exception): macs.extend(x.get("WifiMac") for x in wlan_settings["Ssids"]["Ssid"]) return sorted({format_mac(str(x)) for x in macs if x}) diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index ba17a448477..66a99b63681 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -207,7 +207,6 @@ async def async_setup_platform( async_add_entities([integral]) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class IntegrationSensor(RestoreSensor): """Representation of an integration sensor.""" diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index d553274ab3e..8c599469ff2 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -247,7 +247,7 @@ class MqttValueTemplate: payload, variables=values ) ) - except Exception as ex: # pylint: disable=broad-except + except Exception as ex: _LOGGER.error( "%s: %s rendering template for entity '%s', template: '%s'", type(ex).__name__, @@ -274,7 +274,7 @@ class MqttValueTemplate: payload, default, variables=values ) ) - except Exception as ex: # pylint: disable=broad-except + except Exception as ex: _LOGGER.error( "%s: %s rendering template for entity '%s', template: " "'%s', default value: %s and payload: %s", diff --git a/homeassistant/components/recorder/filters.py b/homeassistant/components/recorder/filters.py index 24d22704a89..bf76c7264d5 100644 --- a/homeassistant/components/recorder/filters.py +++ b/homeassistant/components/recorder/filters.py @@ -187,8 +187,6 @@ class Filters: if self._included_domains or self._included_entity_globs: return or_( i_entities, - # https://github.com/sqlalchemy/sqlalchemy/issues/9190 - # pylint: disable-next=invalid-unary-operand-type (~e_entities & (i_entity_globs | (~e_entity_globs & i_domains))), ).self_group() diff --git a/homeassistant/components/samsungtv/bridge.py b/homeassistant/components/samsungtv/bridge.py index 0cc4dd556d5..03a9c35c9ba 100644 --- a/homeassistant/components/samsungtv/bridge.py +++ b/homeassistant/components/samsungtv/bridge.py @@ -548,7 +548,6 @@ class SamsungTVWSBridge( return RESULT_AUTH_MISSING except (ConnectionFailure, OSError, AsyncioTimeoutError) as err: LOGGER.debug("Failing config: %s, %s error: %s", config, type(err), err) - # pylint: disable-next=useless-else-on-loop else: # noqa: PLW0120 if result: return result diff --git a/homeassistant/components/supla/__init__.py b/homeassistant/components/supla/__init__.py index 14d617ba88e..9652cae4aa4 100644 --- a/homeassistant/components/supla/__init__.py +++ b/homeassistant/components/supla/__init__.py @@ -102,7 +102,6 @@ async def discover_devices(hass, hass_config): async with asyncio.timeout(SCAN_INTERVAL.total_seconds()): channels = { channel["id"]: channel - # pylint: disable-next=cell-var-from-loop for channel in await server.get_channels( # noqa: B023 include=["iodevice", "state", "connected"] ) From e003903bc5655a580c80ec6f290add701300e12b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Aug 2023 12:26:12 -0500 Subject: [PATCH 121/124] Bump zeroconf to 0.83.0 (#99091) --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 6b04b6c7c4a..2f75e4008fd 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.82.1"] + "requirements": ["zeroconf==0.83.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 71f8a37ff40..b032ff6c148 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,7 +53,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.82.1 +zeroconf==0.83.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index 29f5527746c..85c76129b25 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2759,7 +2759,7 @@ zamg==0.2.4 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.82.1 +zeroconf==0.83.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dcc4abbce29..5e5e8373075 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2029,7 +2029,7 @@ youtubeaio==1.1.5 zamg==0.2.4 # homeassistant.components.zeroconf -zeroconf==0.82.1 +zeroconf==0.83.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 407aa31adc326d5a6fccd10ee429abec44218379 Mon Sep 17 00:00:00 2001 From: uvjustin <46082645+uvjustin@users.noreply.github.com> Date: Sun, 27 Aug 2023 01:39:40 +0800 Subject: [PATCH 122/124] Generate Stream snapshots using next keyframe (#96991) * Add wait_for_next_keyframe option to stream images Add STREAM_SNAPSHOT to CameraEntityFeature Use wait_for_next_keyframe option for snapshots using stream * Update stream test comments * Add generic camera snapshot test * Get stream still images directly in camera Remove getting stream images from generic, nest, and ONVIF Refactor camera preferences Add use_stream_for_stills setting to camera Update tests * Only attempt to get stream image if integration supports stream * Use property instead of entity registry setting * Split out getting stream prerequisites from stream_source in nest * Use cached_property for rtsp live stream trait * Make rtsp live stream trait NestCamera attribute * Update homeassistant/components/nest/camera.py Co-authored-by: Allen Porter * Change usage of async_timeout * Change import formatting in generic/test_camera * Simplify Nest camera property initialization --------- Co-authored-by: Allen Porter --- homeassistant/components/camera/__init__.py | 39 +++++++++++-- homeassistant/components/generic/camera.py | 9 +-- homeassistant/components/nest/camera.py | 42 +++++++------- homeassistant/components/onvif/camera.py | 8 ++- homeassistant/components/stream/__init__.py | 5 +- homeassistant/components/stream/core.py | 21 +++++-- homeassistant/components/stream/worker.py | 2 +- tests/components/camera/test_init.py | 58 ++++++++++++++++++++ tests/components/generic/test_camera.py | 47 +--------------- tests/components/generic/test_config_flow.py | 2 +- tests/components/nest/test_camera.py | 6 -- tests/components/stream/test_worker.py | 30 ++++++++-- 12 files changed, 174 insertions(+), 95 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index af64b2f1953..07394ca75b2 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -168,9 +168,14 @@ async def _async_get_image( """ with suppress(asyncio.CancelledError, asyncio.TimeoutError): async with asyncio.timeout(timeout): - if image_bytes := await camera.async_camera_image( - width=width, height=height - ): + image_bytes = ( + await _async_get_stream_image( + camera, width=width, height=height, wait_for_next_keyframe=False + ) + if camera.use_stream_for_stills + else await camera.async_camera_image(width=width, height=height) + ) + if image_bytes: content_type = camera.content_type image = Image(content_type, image_bytes) if ( @@ -205,6 +210,21 @@ async def async_get_image( return await _async_get_image(camera, timeout, width, height) +async def _async_get_stream_image( + camera: Camera, + width: int | None = None, + height: int | None = None, + wait_for_next_keyframe: bool = False, +) -> bytes | None: + if not camera.stream and camera.supported_features & SUPPORT_STREAM: + camera.stream = await camera.async_create_stream() + if camera.stream: + return await camera.stream.async_get_image( + width=width, height=height, wait_for_next_keyframe=wait_for_next_keyframe + ) + return None + + @bind_hass async def async_get_stream_source(hass: HomeAssistant, entity_id: str) -> str | None: """Fetch the stream source for a camera entity.""" @@ -360,6 +380,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await component.async_setup(config) async def preload_stream(_event: Event) -> None: + """Load stream prefs and start stream if preload_stream is True.""" for camera in list(component.entities): stream_prefs = await prefs.get_dynamic_stream_settings(camera.entity_id) if not stream_prefs.preload_stream: @@ -459,6 +480,11 @@ class Camera(Entity): return self._attr_entity_picture return ENTITY_IMAGE_URL.format(self.entity_id, self.access_tokens[-1]) + @property + def use_stream_for_stills(self) -> bool: + """Whether or not to use stream to generate stills.""" + return False + @property def supported_features(self) -> CameraEntityFeature: """Flag supported features.""" @@ -926,7 +952,12 @@ async def async_handle_snapshot_service( f"Cannot write `{snapshot_file}`, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`" ) - image = await camera.async_camera_image() + async with asyncio.timeout(CAMERA_IMAGE_TIMEOUT): + image = ( + await _async_get_stream_image(camera, wait_for_next_keyframe=True) + if camera.use_stream_for_stills + else await camera.async_camera_image() + ) if image is None: return diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index c171c95e659..621566a70f5 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -172,15 +172,16 @@ class GenericCamera(Camera): self._last_url = None self._last_image = None + @property + def use_stream_for_stills(self) -> bool: + """Whether or not to use stream to generate stills.""" + return not self._still_image_url + async def async_camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return a still image response from the camera.""" if not self._still_image_url: - if not self.stream: - await self.async_create_stream() - if self.stream: - return await self.stream.async_get_image(width, height) return None try: url = self._still_image_url.async_render(parse_result=False) diff --git a/homeassistant/components/nest/camera.py b/homeassistant/components/nest/camera.py index 721af504fd8..90c4056161e 100644 --- a/homeassistant/components/nest/camera.py +++ b/homeassistant/components/nest/camera.py @@ -7,6 +7,7 @@ import datetime import functools import logging from pathlib import Path +from typing import cast from google_nest_sdm.camera_traits import ( CameraImageTrait, @@ -71,9 +72,24 @@ class NestCamera(Camera): self._stream: RtspStream | None = None self._create_stream_url_lock = asyncio.Lock() self._stream_refresh_unsub: Callable[[], None] | None = None - self._attr_is_streaming = CameraLiveStreamTrait.NAME in self._device.traits + self._attr_is_streaming = False + self._attr_supported_features = CameraEntityFeature(0) + self._rtsp_live_stream_trait: CameraLiveStreamTrait | None = None + if CameraLiveStreamTrait.NAME in self._device.traits: + self._attr_is_streaming = True + self._attr_supported_features |= CameraEntityFeature.STREAM + trait = cast( + CameraLiveStreamTrait, self._device.traits[CameraLiveStreamTrait.NAME] + ) + if StreamingProtocol.RTSP in trait.supported_protocols: + self._rtsp_live_stream_trait = trait self.stream_options[CONF_EXTRA_PART_WAIT_TIME] = 3 + @property + def use_stream_for_stills(self) -> bool: + """Whether or not to use stream to generate stills.""" + return self._rtsp_live_stream_trait is not None + @property def unique_id(self) -> str: """Return a unique ID.""" @@ -95,14 +111,6 @@ class NestCamera(Camera): """Return the camera model.""" return self._device_info.device_model - @property - def supported_features(self) -> CameraEntityFeature: - """Flag supported features.""" - supported_features = CameraEntityFeature(0) - if CameraLiveStreamTrait.NAME in self._device.traits: - supported_features |= CameraEntityFeature.STREAM - return supported_features - @property def frontend_stream_type(self) -> StreamType | None: """Return the type of stream supported by this camera.""" @@ -125,18 +133,15 @@ class NestCamera(Camera): async def stream_source(self) -> str | None: """Return the source of the stream.""" - if not self.supported_features & CameraEntityFeature.STREAM: - return None - if CameraLiveStreamTrait.NAME not in self._device.traits: - return None - trait = self._device.traits[CameraLiveStreamTrait.NAME] - if StreamingProtocol.RTSP not in trait.supported_protocols: + if not self._rtsp_live_stream_trait: return None async with self._create_stream_url_lock: if not self._stream: _LOGGER.debug("Fetching stream url") try: - self._stream = await trait.generate_rtsp_stream() + self._stream = ( + await self._rtsp_live_stream_trait.generate_rtsp_stream() + ) except ApiException as err: raise HomeAssistantError(f"Nest API error: {err}") from err self._schedule_stream_refresh() @@ -204,10 +209,7 @@ class NestCamera(Camera): ) -> bytes | None: """Return bytes of camera image.""" # Use the thumbnail from RTSP stream, or a placeholder if stream is - # not supported (e.g. WebRTC) - stream = await self.async_create_stream() - if stream: - return await stream.async_get_image(width, height) + # not supported (e.g. WebRTC) as a fallback when 'use_stream_for_stills' if False return await self.hass.async_add_executor_job(self.placeholder_image) @classmethod diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index 7a87ec66c83..96ce70344fd 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -114,6 +114,11 @@ class ONVIFCameraEntity(ONVIFBaseEntity, Camera): self._stream_uri: str | None = None self._stream_uri_future: asyncio.Future[str] | None = None + @property + def use_stream_for_stills(self) -> bool: + """Whether or not to use stream to generate stills.""" + return bool(self.stream and self.stream.dynamic_stream_settings.preload_stream) + @property def name(self) -> str: """Return the name of this camera.""" @@ -140,9 +145,6 @@ class ONVIFCameraEntity(ONVIFBaseEntity, Camera): ) -> bytes | None: """Return a still image response from the camera.""" - if self.stream and self.stream.dynamic_stream_settings.preload_stream: - return await self.stream.async_get_image(width, height) - if self.device.capabilities.snapshot: try: if image := await self.device.device.get_snapshot( diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index 63269401a40..691ba262ee2 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -537,6 +537,7 @@ class Stream: self, width: int | None = None, height: int | None = None, + wait_for_next_keyframe: bool = False, ) -> bytes | None: """Fetch an image from the Stream and return it as a jpeg in bytes. @@ -548,7 +549,9 @@ class Stream: self.add_provider(HLS_PROVIDER) await self.start() return await self._keyframe_converter.async_get_image( - width=width, height=height + width=width, + height=height, + wait_for_next_keyframe=wait_for_next_keyframe, ) def get_diagnostics(self) -> dict[str, Any]: diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index f3591e7e5d7..6b8e6c44a1c 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -441,7 +441,8 @@ class KeyFrameConverter: # pylint: disable-next=import-outside-toplevel from homeassistant.components.camera.img_util import TurboJPEGSingleton - self.packet: Packet = None + self._packet: Packet = None + self._event: asyncio.Event = asyncio.Event() self._hass = hass self._image: bytes | None = None self._turbojpeg = TurboJPEGSingleton.instance() @@ -450,6 +451,14 @@ class KeyFrameConverter: self._stream_settings = stream_settings self._dynamic_stream_settings = dynamic_stream_settings + def stash_keyframe_packet(self, packet: Packet) -> None: + """Store the keyframe and set the asyncio.Event from the event loop. + + This is called from the worker thread. + """ + self._packet = packet + self._hass.loop.call_soon_threadsafe(self._event.set) + def create_codec_context(self, codec_context: CodecContext) -> None: """Create a codec context to be used for decoding the keyframes. @@ -482,10 +491,10 @@ class KeyFrameConverter: at a time per instance. """ - if not (self._turbojpeg and self.packet and self._codec_context): + if not (self._turbojpeg and self._packet and self._codec_context): return - packet = self.packet - self.packet = None + packet = self._packet + self._packet = None for _ in range(2): # Retry once if codec context needs to be flushed try: # decode packet (flush afterwards) @@ -519,10 +528,14 @@ class KeyFrameConverter: self, width: int | None = None, height: int | None = None, + wait_for_next_keyframe: bool = False, ) -> bytes | None: """Fetch an image from the Stream and return it as a jpeg in bytes.""" # Use a lock to ensure only one thread is working on the keyframe at a time + if wait_for_next_keyframe: + self._event.clear() + await self._event.wait() async with self._lock: await self._hass.async_add_executor_job(self._generate_image, width, height) return self._image diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index 07d274e655c..cc4970c8a5e 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -624,4 +624,4 @@ def stream_worker( muxer.mux_packet(packet) if packet.is_keyframe and is_video(packet): - keyframe_converter.packet = packet + keyframe_converter.stash_keyframe_packet(packet) diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 8d37eba219a..2a91a375a13 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -909,3 +909,61 @@ async def test_rtsp_to_web_rtc_offer_not_accepted( assert mock_provider.called unsub() + + +async def test_use_stream_for_stills( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_camera, +) -> None: + """Test that the component can grab images from stream.""" + + client = await hass_client() + + with patch( + "homeassistant.components.demo.camera.DemoCamera.stream_source", + return_value=None, + ) as mock_stream_source, patch( + "homeassistant.components.demo.camera.DemoCamera.use_stream_for_stills", + return_value=True, + ): + # First test when the integration does not support stream should fail + resp = await client.get("/api/camera_proxy/camera.demo_camera") + await hass.async_block_till_done() + mock_stream_source.assert_not_called() + assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR + # Test when the integration does not provide a stream_source should fail + with patch( + "homeassistant.components.demo.camera.DemoCamera.supported_features", + return_value=camera.SUPPORT_STREAM, + ): + resp = await client.get("/api/camera_proxy/camera.demo_camera") + await hass.async_block_till_done() + mock_stream_source.assert_called_once() + assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR + + with patch( + "homeassistant.components.demo.camera.DemoCamera.stream_source", + return_value="rtsp://some_source", + ) as mock_stream_source, patch( + "homeassistant.components.camera.create_stream" + ) as mock_create_stream, patch( + "homeassistant.components.demo.camera.DemoCamera.supported_features", + return_value=camera.SUPPORT_STREAM, + ), patch( + "homeassistant.components.demo.camera.DemoCamera.use_stream_for_stills", + return_value=True, + ): + # Now test when creating the stream succeeds + mock_stream = Mock() + mock_stream.async_get_image = AsyncMock() + mock_stream.async_get_image.return_value = b"stream_keyframe_image" + mock_create_stream.return_value = mock_stream + + # should start the stream and get the image + resp = await client.get("/api/camera_proxy/camera.demo_camera") + await hass.async_block_till_done() + mock_create_stream.assert_called_once() + mock_stream.async_get_image.assert_called_once() + assert resp.status == HTTPStatus.OK + assert await resp.read() == b"stream_keyframe_image" diff --git a/tests/components/generic/test_camera.py b/tests/components/generic/test_camera.py index e83966c0912..f7f7c390e0d 100644 --- a/tests/components/generic/test_camera.py +++ b/tests/components/generic/test_camera.py @@ -27,7 +27,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import AsyncMock, Mock, MockConfigEntry +from tests.common import Mock, MockConfigEntry from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -503,51 +503,6 @@ async def test_timeout_cancelled( assert await resp.read() == fakeimgbytes_png -async def test_no_still_image_url( - hass: HomeAssistant, hass_client: ClientSessionGenerator -) -> None: - """Test that the component can grab images from stream with no still_image_url.""" - assert await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "generic", - "stream_source": "rtsp://example.com:554/rtsp/", - }, - }, - ) - await hass.async_block_till_done() - - client = await hass_client() - - with patch( - "homeassistant.components.generic.camera.GenericCamera.stream_source", - return_value=None, - ) as mock_stream_source: - # First test when there is no stream_source should fail - resp = await client.get("/api/camera_proxy/camera.config_test") - await hass.async_block_till_done() - mock_stream_source.assert_called_once() - assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR - - with patch("homeassistant.components.camera.create_stream") as mock_create_stream: - # Now test when creating the stream succeeds - mock_stream = Mock() - mock_stream.async_get_image = AsyncMock() - mock_stream.async_get_image.return_value = b"stream_keyframe_image" - mock_create_stream.return_value = mock_stream - - # should start the stream and get the image - resp = await client.get("/api/camera_proxy/camera.config_test") - await hass.async_block_till_done() - mock_create_stream.assert_called_once() - mock_stream.async_get_image.assert_called_once() - assert resp.status == HTTPStatus.OK - assert await resp.read() == b"stream_keyframe_image" - - async def test_frame_interval_property(hass: HomeAssistant) -> None: """Test that the frame interval is calculated and returned correctly.""" diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index db9787fb283..c4d11d4af22 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -423,7 +423,7 @@ async def test_form_only_stream( await hass.async_block_till_done() with patch( - "homeassistant.components.generic.camera.GenericCamera.async_camera_image", + "homeassistant.components.camera._async_get_stream_image", return_value=fakeimgbytes_jpg, ): image_obj = await async_get_image(hass, "camera.127_0_0_1") diff --git a/tests/components/nest/test_camera.py b/tests/components/nest/test_camera.py index 9e082dc1b05..56c5bedaf0d 100644 --- a/tests/components/nest/test_camera.py +++ b/tests/components/nest/test_camera.py @@ -244,8 +244,6 @@ async def test_camera_stream( stream_source = await camera.async_get_stream_source(hass, "camera.my_camera") assert stream_source == "rtsp://some/url?auth=g.0.streamingToken" - assert await async_get_image(hass) == IMAGE_BYTES_FROM_STREAM - async def test_camera_ws_stream( hass: HomeAssistant, @@ -280,8 +278,6 @@ async def test_camera_ws_stream( assert msg["success"] assert msg["result"]["url"] == "http://home.assistant/playlist.m3u8" - assert await async_get_image(hass) == IMAGE_BYTES_FROM_STREAM - async def test_camera_ws_stream_failure( hass: HomeAssistant, @@ -746,8 +742,6 @@ async def test_camera_multiple_streams( stream_source = await camera.async_get_stream_source(hass, "camera.my_camera") assert stream_source == "rtsp://some/url?auth=g.0.streamingToken" - assert await async_get_image(hass) == IMAGE_BYTES_FROM_STREAM - # WebRTC stream client = await hass_ws_client(hass) await client.send_json( diff --git a/tests/components/stream/test_worker.py b/tests/components/stream/test_worker.py index e0152190d90..bd998b008be 100644 --- a/tests/components/stream/test_worker.py +++ b/tests/components/stream/test_worker.py @@ -643,7 +643,7 @@ async def test_pts_out_of_order(hass: HomeAssistant) -> None: async def test_stream_stopped_while_decoding(hass: HomeAssistant) -> None: - """Tests that worker quits when stop() is called while decodign.""" + """Tests that worker quits when stop() is called while decoding.""" # Add some synchronization so that the test can pause the background # worker. When the worker is stopped, the test invokes stop() which # will cause the worker thread to exit once it enters the decode @@ -966,7 +966,7 @@ async def test_h265_video_is_hvc1(hass: HomeAssistant, worker_finished_stream) - async def test_get_image(hass: HomeAssistant, h264_video, filename) -> None: - """Test that the has_keyframe metadata matches the media.""" + """Test getting an image from the stream.""" await async_setup_component(hass, "stream", {"stream": {}}) # Since libjpeg-turbo is not installed on the CI runner, we use a mock @@ -976,10 +976,30 @@ async def test_get_image(hass: HomeAssistant, h264_video, filename) -> None: mock_turbo_jpeg_singleton.instance.return_value = mock_turbo_jpeg() stream = create_stream(hass, h264_video, {}, dynamic_stream_settings()) - with patch.object(hass.config, "is_allowed_path", return_value=True): + worker_wake = threading.Event() + + temp_av_open = av.open + + def blocking_open(stream_source, *args, **kwargs): + # Block worker thread until test wakes up + worker_wake.wait() + return temp_av_open(stream_source, *args, **kwargs) + + with patch.object(hass.config, "is_allowed_path", return_value=True), patch( + "av.open", new=blocking_open + ): make_recording = hass.async_create_task(stream.async_record(filename)) + assert stream._keyframe_converter._image is None + # async_get_image should not work because there is no keyframe yet + assert not await stream.async_get_image() + # async_get_image should work if called with wait_for_next_keyframe=True + next_keyframe_request = hass.async_create_task( + stream.async_get_image(wait_for_next_keyframe=True) + ) + worker_wake.set() await make_recording - assert stream._keyframe_converter._image is None + + assert await next_keyframe_request == EMPTY_8_6_JPEG assert await stream.async_get_image() == EMPTY_8_6_JPEG @@ -1008,7 +1028,7 @@ async def test_worker_disable_ll_hls(hass: HomeAssistant) -> None: async def test_get_image_rotated(hass: HomeAssistant, h264_video, filename) -> None: - """Test that the has_keyframe metadata matches the media.""" + """Test getting a rotated image.""" await async_setup_component(hass, "stream", {"stream": {}}) # Since libjpeg-turbo is not installed on the CI runner, we use a mock From 2fc728db420b604dcf3d9cad4bb134cc14bd9a1b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 26 Aug 2023 19:42:49 +0200 Subject: [PATCH 123/124] Remove unused variable from Airthings BLE (#99085) * Remove unused variable from Airthings BLE * Remove unused variable from Airthings BLE --- homeassistant/components/airthings_ble/sensor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py index 7f44d71a9fa..4783f3e3b35 100644 --- a/homeassistant/components/airthings_ble/sensor.py +++ b/homeassistant/components/airthings_ble/sensor.py @@ -166,7 +166,6 @@ class AirthingsSensor( name += f" ({identifier})" self._attr_unique_id = f"{name}_{entity_description.key}" - self._id = airthings_device.address self._attr_device_info = DeviceInfo( connections={ ( From a81e6d5811356b966c6444dd6a8a162e36a9cb0a Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Sat, 26 Aug 2023 21:13:25 +0200 Subject: [PATCH 124/124] Bump python bsblan 0.5.14 (#99089) * update python-bsblan to 0.5.14 * fix test diagnostics --- homeassistant/components/bsblan/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../bsblan/snapshots/test_diagnostics.ambr | 18 +++++++++--------- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/bsblan/manifest.json b/homeassistant/components/bsblan/manifest.json index 5abb888513d..d5866bf8b42 100644 --- a/homeassistant/components/bsblan/manifest.json +++ b/homeassistant/components/bsblan/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["bsblan"], - "requirements": ["python-bsblan==0.5.11"] + "requirements": ["python-bsblan==0.5.14"] } diff --git a/requirements_all.txt b/requirements_all.txt index 85c76129b25..d8d3890bbc6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2071,7 +2071,7 @@ python-awair==0.2.4 python-blockchain-api==0.0.2 # homeassistant.components.bsblan -python-bsblan==0.5.11 +python-bsblan==0.5.14 # homeassistant.components.clementine python-clementine-remote==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5e5e8373075..dc7358f8811 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1539,7 +1539,7 @@ pytautulli==23.1.1 python-awair==0.2.4 # homeassistant.components.bsblan -python-bsblan==0.5.11 +python-bsblan==0.5.14 # homeassistant.components.ecobee python-ecobee-api==0.2.14 diff --git a/tests/components/bsblan/snapshots/test_diagnostics.ambr b/tests/components/bsblan/snapshots/test_diagnostics.ambr index 2fff33de046..b172d26c249 100644 --- a/tests/components/bsblan/snapshots/test_diagnostics.ambr +++ b/tests/components/bsblan/snapshots/test_diagnostics.ambr @@ -9,21 +9,21 @@ }), 'info': dict({ 'controller_family': dict({ - 'dataType': 0, + 'data_type': 0, 'desc': '', 'name': 'Device family', 'unit': '', 'value': '211', }), 'controller_variant': dict({ - 'dataType': 0, + 'data_type': 0, 'desc': '', 'name': 'Device variant', 'unit': '', 'value': '127', }), 'device_identification': dict({ - 'dataType': 7, + 'data_type': 7, 'desc': '', 'name': 'Gerte-Identifikation', 'unit': '', @@ -32,42 +32,42 @@ }), 'state': dict({ 'current_temperature': dict({ - 'dataType': 0, + 'data_type': 0, 'desc': '', 'name': 'Room temp 1 actual value', 'unit': '°C', 'value': '18.6', }), 'hvac_action': dict({ - 'dataType': 1, + 'data_type': 1, 'desc': 'Raumtemp’begrenzung', 'name': 'Status heating circuit 1', 'unit': '', 'value': '122', }), 'hvac_mode': dict({ - 'dataType': 1, + 'data_type': 1, 'desc': 'Komfort', 'name': 'Operating mode', 'unit': '', 'value': 'heat', }), 'hvac_mode2': dict({ - 'dataType': 1, + 'data_type': 1, 'desc': 'Reduziert', 'name': 'Operating mode', 'unit': '', 'value': '2', }), 'room1_thermostat_mode': dict({ - 'dataType': 1, + 'data_type': 1, 'desc': 'Kein Bedarf', 'name': 'Raumthermostat 1', 'unit': '', 'value': '0', }), 'target_temperature': dict({ - 'dataType': 0, + 'data_type': 0, 'desc': '', 'name': 'Room temperature Comfort setpoint', 'unit': '°C',